From c141960ada4869ee6a3bb8a665e75c0c18ad7f19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Tue, 25 Nov 2025 15:46:02 +0100 Subject: [PATCH] feat: Non-elbow arrow snapping and behavior changes (#9670) * Fixed point binding for simple arrows Tests added Fix binding Remove unneeded params Unfinished simple arrow avoidance Fix newly created jumping arrow when gets outside Do not apply the jumping logic to elbow arrows for new elements Existing arrows now jump out Type updates to support fixed binding for simple arrows Fix crash for elbow arrws in mutateElement() Refactored simple arrow creation Updating tests No confirm threshold when inside biding range Fix multi-point arrow grid off Make elbow arrows respect grids Unbind arrow if bound and moved at shaft of arrow key Fix binding test Fix drag unbind when the bound element is in the selection Do not move mid point for simple arrows bound on both ends Add test for mobing mid points for simple arrows when bound on the same element on both ends Fix linear editor bug when both midpoint and endpoint is moved Fix all point multipoint arrow highlight and binding Arrow dragging gets a little drag to avoid accidental unbinding Fixed point binding for simple arrows when the arrow doesn't point to the element Fix binding disabled use-case triggering arrow editor Timed binding mode change for simple arrows Apply fixes Remove code to unbind on drag Update simple arrow fixed point when arrow is dragged or moved by arrow keys Binding highlight fixes Change bind mode timeout logic Fix tests Add Alt bindMode switch No dragging of arrows when bound, similar to elbow Fix timeout not taking effect immediately Bumop z-index for arrows when dragged Signed-off-by: Mark Tolmacs Only transparent bindables allow binding fallthrough Signed-off-by: Mark Tolmacs Fix lint Signed-off-by: Mark Tolmacs Fix point click array creation interaction with fixed point binding Signed-off-by: Mark Tolmacs Restrict new behavior to arrows only Signed-off-by: Mark Tolmacs Allow binding inside images Signed-off-by: Mark Tolmacs Fix already existing fixed binding retention Signed-off-by: Mark Tolmacs Refactor and implement fixed point binding for unfilled elements Restore drag Removed point binding Binding code refactor Added centered focus point Binding & focus point debug Add invariants to check binding integrity in elements Binding fixes Small refactors Completely rewritten binding Include point updates after binding update Fix point updates when endpoint dragged and opposite endpoint orbits centered focus point only for new arrows Make z-index arrow reorder on bind Turn off inside binding mode after leaving a shape Remove invariants from debug feat: expose `applyTo` options, don't commit empty text element (#9744) * Expose applyTo options, skip re-draw for empty text * Don't commit empty text elements test: added test file for distribute (#9754) z-index update Bind mode on precise binding Fix binding to inside element Fix initial arrow not following cursor (white dot) Fix elbow arrow Fix z-index so it works on hover Fix fixed angle orbiting Move point click arrow creation over to common strategy Signed-off-by: Mark Tolmacs Add binding strategy for drag arrow creation Fix elbow arrow Fix point handles Snap to center Fix transparent shape binding Internal arrow creation fix Fix point binding Fix selection bug Fix new arrow focus point Images now always bind inside Flashing arrow creation on binding band Add watchState debug method to window.h Fix debug canvas crash Remove non-needed bind mode Fix restore No keyboard movement when bound Add actionFinalize when arrow in edit mode Add drag to the Stats panel when bound arrow is moved Further simplify curve tracking Add typing to action register() Signed-off-by: Mark Tolmacs Fix point at finalize Signed-off-by: Mark Tolmacs Fix type errors Signed-off-by: Mark Tolmacs New arrow binding rules Signed-off-by: Mark Tolmacs Fix cyclical dep Signed-off-by: Mark Tolmacs Fix jiggly arrows Fix jiggly arrow x2 Long inside-other binding Click-click binding Fix arrows Performance [PERF] Replace in-place Jacobian derivation with analytical version Different approach to inside binding Signed-off-by: Mark Tolmacs Fixes Fix inconsistent arrow start jump out Change how images are bound to on new arrow creation Lower timeout Small insurance fix Fix curve test Signed-off-by: Mark Tolmacs No center focus point 90% inside center binding Fixing tests fix: Elbow arrow fixes fix: More arrow fixes Do not trigger arrow binding for linear elements fix: Linear elements fix: Refactor actionFinalize for linear Binding tests updated fix: Jump when cursor not moved fix: history tests Fix history snapshot Fix undo issue fix(eraser): Remove binding from the other element fix(tests): Update tests chore: Attempt filtering new set state Fix excessive history recording Signed-off-by: Mark Tolmacs Fix all tests Signed-off-by: Mark Tolmacs fix(transform): Fix group resize and rotate fix(binding): Harmonize binding param usage fix: Center focus point Signed-off-by: Mark Tolmacs chore: Trigger build Remove binding gap Signed-off-by: Mark Tolmacs Binding highlight refactor fix: Refactored timeout bind mode handling fix: Center when orbiting feat: Color change on highlight Fix orbit binding highlight fix: hiding arrow Fix arrow binding Fix arrow drag selection logic Binding highlight is now hot pink Change inside binding logic for start point Render focus point in debug mode Fix snap to center Fix actionFinalize for new arrow creation fix: snapToCenter() 80% by length fix: attempt at fixing the dancing arrows feat: No center snap when start is not bound Fix centering for existing arrows tweak binding highlight color change `appState.suggestedBindings` -> `suggestedBinding` & remove unused code Refactor delayed bind mode change Binding highlight rotation support + image support fix(highlight): Overdraw fixes feat: Do not allow drag bound arrow closer to the shape than dragging distance feat: Stroke width adaptive fixed binding distance chore: More point dragging centralization New element behavior Refactor dragging Fix incorrect highlight sizing Signed-off-by: Mark Tolmacs Fix delayed bind mode for multiElement arrows Signed-off-by: Mark Tolmacs Fix multi-point Signed-off-by: Mark Tolmacs Fix elbow arrows Simplify state Small positional fixes Fix jiggly arrows Signed-off-by: Mark Tolmacs Fixes for arrow dragging Signed-off-by: Mark Tolmacs Elbow arrow fixes Highlight fixes Fix elbow arrow binding Frame highlight Fix elbow mid-point binding Fix binding suggestion for disabled binding state Implement Alt Remove debug * CHange new arrow creation * fix: allow inside binding via timeout if arrow has no startBinding * fix: Delete invariant violation with arrows * fix: Deleted arrow causes problems * fix: Dragging issues * fix: Dragging fix 2 * fix: Disable drag drag when arrow is bound * fix: Multipoint arrow opposite point movement * fix: Ctrl+Alt precedence * feat: Alt inside start binding mode change * Fix multipoint arrow orbit Signed-off-by: Mark Tolmacs * fix: Arrow start inside binding switch Signed-off-by: Mark Tolmacs * fix: New arrow never binds inside Signed-off-by: Mark Tolmacs * chore: Small refactor Signed-off-by: Mark Tolmacs * fix: Multi-point arrows and linears Signed-off-by: Mark Tolmacs * fix: Lint Signed-off-by: Mark Tolmacs * feat: Nested shapes handling Signed-off-by: Mark Tolmacs * fix: Overlap behavior * Alt unbind fix Signed-off-by: Mark Tolmacs * fix: Existing arrow nested bindable Signed-off-by: Mark Tolmacs * fix: Binding suggestions Signed-off-by: Mark Tolmacs * fix: Circular dep Signed-off-by: Mark Tolmacs * fix: snapshots Signed-off-by: Mark Tolmacs * fix: Alt immediate update Signed-off-by: Mark Tolmacs * chore: Laxing on invariants Signed-off-by: Mark Tolmacs * fix: New highlight overdraws arrow Signed-off-by: Mark Tolmacs * fix: Image binding rule changed * Trigger Rebuild * fix:Highlight flicker Signed-off-by: Mark Tolmacs * fix: Fully nested shapes * fix: Tune nested shape binding * fix: Size-based orbit jump-in * fix: Binding highlight stroke on sharp bindables * fix: Nested shape binding * fix: history * fix:More precise element nesting check * feat:Add tolerance to shape nesting detection * fix: Reverse * fix:Change center binding to circular * ignore invisible elements when binding * feat: Center point with more precise highlight outlines * fix:Arrow tool hover stuck highlight * fix:More stroke width for highlight * POC: highlight center on hover * tweak binding highlight width * render highlight on the outside * fix: Locked elbow arrow creation * update hints * reduce timeout * handle overlap when both elements the same size * tweak highlight stroke width some more * fix:Add intersection padding * fix: New arrow start orbit when nested binds on the end * fix: Update history snapshot * feat: Allow inside binding for new arrows in nested cases * chore: Logic for measurement * fix: Locked tool + arrow * feat: Remove center binding * fix: Jump arrow inside it gets visially too short Signed-off-by: Mark Tolmacs * chore:Basic interactive canvas animation re-render trigger for highlights Signed-off-by: Mark Tolmacs * feat:Highlight animations Signed-off-by: Mark Tolmacs * fix:Refactored and fixed highlight animation * fix:Poisoned arrow * fix Arrow edit mode selection * fix:Tool lock binding behavior restored * fix:Overlap inside binding * fix:Animated binding highlight * alt anims + increase timeout to 700 * tweak animation some more + remove countdown * fix: False bind timeout indicator Signed-off-by: Mark Tolmacs * feat: better file normalization (#10024) * feat: better file normalization * fix lint * fix png detection * optimize * fix type * fix: increase rejection delay for opening files with legacy api (#8961) * Increased input change interval to 1000 ms to fix IOS 18 file opening issue * increase more --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> * feat: library search (#9903) * feat(utils): add support for search input type in isWritableElement * feat(i18n): add search text * feat(cmdp+lib): add search functionality for command pallete and lib menu items * chore: fix formats, and whitespaces * fix: opt to optimal code changes * chore: fix for linting * focus input on mount * tweak placeholder * design and UX changes * tweak item hover/active/seletected states * unrelated: move publish button above delete/clear to keep it more stable * esc to clear search input / close sidebar * refactor command pallete library stuff * make library commands bigger --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> * fix: Allow already inside bound arrows to continue inside binding Signed-off-by: Mark Tolmacs * feat: No angle lock over bindable elements Signed-off-by: Mark Tolmacs * feat: Center binding on SHIFT key Signed-off-by: Mark Tolmacs * Fix ghost start binding Signed-off-by: Mark Tolmacs * FEAT: No binding to frame cutout * feat: Bind to frame when frame-bound object hidden part is approached * fix: revert preferred selection to box once you switch to `full` UI (#10160) * fix: mobile UI and other fixes (#10177) * remove legacy openMenu=shape state and unused actions * close menus/popups in applicable cases when opening a different one * split ui z-indexes to account prefer different overlap * make top canvas area clickable on mobile * make mobile main menu closable by clicking outside and reduce width * offset picker popups from viewport border on mobile * reduce items gap in mobile main menu * show top picks for canvas bg colors in all ui modes * fix menu separator visibility on mobile * fix command palette items not being filtered * fix: Increase transform handle offset (#10180) * fix: Increase transform handle offset Signed-off-by: Mark Tolmacs * fix: Temporarily disable transform handles for linear elements on mobile and tablets Signed-off-by: Mark Tolmacs * fix: Linear hidden resize Signed-off-by: Mark Tolmacs * disable mobielOrTablet linear element bbox completely * fix: Test Signed-off-by: Mark Tolmacs * fix: Lint Signed-off-by: Mark Tolmacs --------- Signed-off-by: Mark Tolmacs Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> * fix: context menu getting covered (#10199) * do not show z-index actions on mobile or tablet * fix: context menu getting covered * fix lint * fix style popup getting covered * put contextmenu z-index above sidebar --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> * feat: More prominent keyboard shortcuts in hints (#10057) * Initial * Memoize * Styling * Use double angle brackets for keyboard shortcuts * Use rem in gap * Use an existing function for substituting tags in a string * Revert styling * Avoid unique key warnings * Styling * getTransChildren -> nodesFromTextWithTags * Use height and padding instead of padding only * Initial new idea * WIP shortcut substitutions * Use simple regex for parsing shortcuts * Use single shortcut for combos * Use kbd instead of span * shortcutFromKeyString -> getTaggedShortcutKey * Bug fix * FlowChart -> Flowchart * memo is useless here * Trigger CI * Translate in getShortcutKey * More normalized shortcuts * improve shortcut normalization and replacement & support multi-key tagged shortcuts * fix regex * tweak css --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> * fix: small tweaks to shortcut hints (#10214) * fix: Test Signed-off-by: Mark Tolmacs * fix: Bind mode * feat: Support special key shortcut highlight * fix: Lint * fix: Remove non-needed function * fix: Skip frame cutout for hover, but keep shape for binding * fix: Lint * fix: Restore removal of deleted elements on restore * fix: Inside-inside during drag Signed-off-by: Mark Tolmacs * fix: Arrow vanishes when orbit binding to the same element * feat: Feature flag support * Simplified binding * fix: Diamond corner binding * feat: Binding highlight band re-added * feat: Settings menu * fix: Same shape binding * fix: set radix PropertiesPopover collision boundary (#10221) * Set collision boundary * Calculate collisionPadding dynamically based on container * Add appState offsetTop and offsetLeft to padding calculation. Refactor collisionPadding calculation to use app state offsets. * Update PropertiesPopover.tsx * popover positioning relative to container * fix: prevent wrap text in a container to only text that are not bound to a container (#10250) * fix: only enable wrap text in a container when at least one text element selected is unbound * Trigger Rebuild --------- Co-authored-by: Mark Tolmacs * chore: Uncap the nodejs version requirement (#10238) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> * refactor: single source of truths with editor interface (#10178) * refactor device to editor interface and derive styles panel * allow host app to control form factor and ui mode * add editor interface event listener * put new props inside UIOptions * refactor: move related apis into one file * expose getFormFactor * privatize the setting of desktop mode and fix snapshots * refactor and fix test * remove unimplemented code * export getFormFactor() * replace `getFormFactor` with `getEditorInterface` * remove dead & useless * comment * fix ts --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> * chore: Update translations from Crowdin (#7429) * New translations en.json (Punjabi) * New translations en.json (Polish) * New translations en.json (Russian) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Traditional) * New translations en.json (Vietnamese) * New translations en.json (Galician) * New translations en.json (Portuguese, Brazilian) * New translations en.json (Persian) * New translations en.json (Khmer) * New translations en.json (Tamil) * New translations en.json (Bengali) * New translations en.json (Marathi) * New translations en.json (Thai) * New translations en.json (Norwegian Nynorsk) * New translations en.json (Kazakh) * New translations en.json (Latvian) * New translations en.json (Azerbaijani) * New translations en.json (Hindi) * New translations en.json (Burmese) * New translations en.json (Chinese Traditional, Hong Kong) * New translations en.json (Sinhala) * New translations en.json (Uzbek) * New translations en.json (Norwegian Bokmal) * New translations en.json (Occitan) * New translations en.json (German, Switzerland) * New translations en.json (Bengali, India) * New translations en.json (Kabyle) * New translations en.json (Karakalpak) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Marathi) * New translations en.json (Hindi) * New translations en.json (German) * New translations en.json (Chinese Simplified) * New translations en.json (Polish) * New translations en.json (Romanian) * New translations en.json (Korean) * New translations en.json (Chinese Traditional) * New translations en.json (Hebrew) * New translations en.json (Hebrew) * New translations en.json (Slovak) * New translations en.json (Slovak) * New translations en.json (Hungarian) * New translations en.json (Hungarian) * New translations en.json (Slovak) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Korean) * New translations en.json (Chinese Traditional) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Bulgarian) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Basque) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Kurdish) * New translations en.json (Lithuanian) * New translations en.json (Dutch) * New translations en.json (Punjabi) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Slovenian) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Vietnamese) * New translations en.json (Galician) * New translations en.json (Portuguese, Brazilian) * New translations en.json (Indonesian) * New translations en.json (Persian) * New translations en.json (Khmer) * New translations en.json (Tamil) * New translations en.json (Bengali) * New translations en.json (Marathi) * New translations en.json (Thai) * New translations en.json (Norwegian Nynorsk) * New translations en.json (Kazakh) * New translations en.json (Latvian) * New translations en.json (Azerbaijani) * New translations en.json (Hindi) * New translations en.json (Burmese) * New translations en.json (Chinese Traditional, Hong Kong) * New translations en.json (Sinhala) * New translations en.json (Uzbek) * New translations en.json (Norwegian Bokmal) * New translations en.json (Occitan) * New translations en.json (German, Switzerland) * New translations en.json (Bengali, India) * New translations en.json (Kabyle) * New translations en.json (Karakalpak) * New translations en.json (Romanian) * New translations en.json (German) * New translations en.json (Slovenian) * New translations en.json (Chinese Simplified) * New translations en.json (Spanish) * New translations en.json (Russian) * New translations en.json (Chinese Traditional) * New translations en.json (Turkish) * New translations en.json (Slovak) * New translations en.json (Slovak) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Korean) * New translations en.json (Chinese Traditional) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Bulgarian) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Basque) * New translations en.json (Finnish) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Kurdish) * New translations en.json (Lithuanian) * New translations en.json (Dutch) * New translations en.json (Punjabi) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Slovenian) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Vietnamese) * New translations en.json (Galician) * New translations en.json (Portuguese, Brazilian) * New translations en.json (Indonesian) * New translations en.json (Persian) * New translations en.json (Khmer) * New translations en.json (Tamil) * New translations en.json (Bengali) * New translations en.json (Marathi) * New translations en.json (Thai) * New translations en.json (Norwegian Nynorsk) * New translations en.json (Kazakh) * New translations en.json (Latvian) * New translations en.json (Azerbaijani) * New translations en.json (Hindi) * New translations en.json (Burmese) * New translations en.json (Chinese Traditional, Hong Kong) * New translations en.json (Sinhala) * New translations en.json (Uzbek) * New translations en.json (Norwegian Bokmal) * New translations en.json (Occitan) * New translations en.json (German, Switzerland) * New translations en.json (Bengali, India) * New translations en.json (Kabyle) * New translations en.json (Karakalpak) * New translations en.json (German) * New translations en.json (Russian) * New translations en.json (Romanian) * New translations en.json (Spanish) * New translations en.json (Chinese Simplified) * New translations en.json (Marathi) * New translations en.json (Hindi) * New translations en.json (Slovak) * New translations en.json (German) * New translations en.json (Portuguese) * New translations en.json (Romanian) * New translations en.json (French) * New translations en.json (Spanish) * New translations en.json (Arabic) * New translations en.json (Bulgarian) * New translations en.json (Catalan) * New translations en.json (Czech) * New translations en.json (Danish) * New translations en.json (German) * New translations en.json (Greek) * New translations en.json (Basque) * New translations en.json (Finnish) * New translations en.json (Hebrew) * New translations en.json (Hungarian) * New translations en.json (Italian) * New translations en.json (Japanese) * New translations en.json (Korean) * New translations en.json (Kurdish) * New translations en.json (Lithuanian) * New translations en.json (Dutch) * New translations en.json (Punjabi) * New translations en.json (Polish) * New translations en.json (Portuguese) * New translations en.json (Russian) * New translations en.json (Slovak) * New translations en.json (Slovenian) * New translations en.json (Swedish) * New translations en.json (Turkish) * New translations en.json (Ukrainian) * New translations en.json (Chinese Simplified) * New translations en.json (Chinese Traditional) * New translations en.json (Vietnamese) * New translations en.json (Galician) * New translations en.json (Portuguese, Brazilian) * New translations en.json (Indonesian) * New translations en.json (Persian) * New translations en.json (Khmer) * New translations en.json (Tamil) * New translations en.json (Bengali) * New translations en.json (Marathi) * New translations en.json (Thai) * New translations en.json (Norwegian Nynorsk) * New translations en.json (Kazakh) * New translations en.json (Latvian) * New translations en.json (Azerbaijani) * New translations en.json (Hindi) * New translations en.json (Burmese) * New translations en.json (Chinese Traditional, Hong Kong) * New translations en.json (Sinhala) * New translations en.json (Uzbek) * New translations en.json (Norwegian Bokmal) * New translations en.json (Occitan) * New translations en.json (German, Switzerland) * New translations en.json (Bengali, India) * New translations en.json (Kabyle) * New translations en.json (Karakalpak) * Auto commit: Calculate translation coverage * New translations en.json (Chinese Simplified) * Auto commit: Calculate translation coverage * New translations en.json (Romanian) * Auto commit: Calculate translation coverage * New translations en.json (Romanian) * Auto commit: Calculate translation coverage * New translations en.json (French) * Auto commit: Calculate translation coverage * New translations en.json (Polish) * Auto commit: Calculate translation coverage * New translations en.json (Polish) * Auto commit: Calculate translation coverage * New translations en.json (Turkish) * Auto commit: Calculate translation coverage * New translations en.json (Turkish) --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> * fix: mobile view ui issues (#10284) * hide zen mode when formFactor = phone * tool bar fixes: icon and width * view mode * fix lint * add exit-view-mode button --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> * chore: Update snaps Signed-off-by: Mark Tolmacs * feat: Blue highlight Signed-off-by: Mark Tolmacs * feat: Diagonal binding point Signed-off-by: Mark Tolmacs * chore: Remove settings Signed-off-by: Mark Tolmacs * feat: Jump other binding Signed-off-by: Mark Tolmacs * fix: Hovered arrow mode highlight Signed-off-by: Mark Tolmacs * feat: Alt does not snap Signed-off-by: Mark Tolmacs * chore: Check debug Signed-off-by: Mark Tolmacs * fix: Alt precise positioning * fix: Jump out to orbit for new arrows when dragged outside * fix: New arrow preserved projection * chore: Remove debug * chore: Introduce different debug color for orbit and other binding modes * fix: Restore arrow start point when self binding * fix: Turn of start jump-out * fix: Tests * fix: Select the first possible altBindPoint * fix: Random projection * fix: Use last point for existing arrows * fix: Preserve alternate orbit focus point during drag * fix: Lint * fix: Update tests * fix: Elbow arrow direction at binding * binding gap and distance and binding highlight tweaks * chore: Naming refactors Signed-off-by: Mark Tolmacs * fix: Tests Signed-off-by: Mark Tolmacs * fix: Alt-duplication copied elements placement (#10152) * feat: Animation support (#10042) * fix: banner url (#10315) * feat: Animation support (#10042) * fix: Merge discrepancy Signed-off-by: Mark Tolmacs * chore: Remove non-needed code Signed-off-by: Mark Tolmacs * Trigger build * chore: Remove hint for V1 Signed-off-by: Mark Tolmacs * shorten focus point diagonal helpers to fix corner binding cases * fix: Tests * fix: Multi-point arrow closeness fallback * fix: Finalize multipoint arrow on binding area click * fix: Finalize arrow now truly finalzes Signed-off-by: Mark Tolmacs * fix: Point click arrow creation jumping to orbit Signed-off-by: Mark Tolmacs * fix: Alt+drag movement block Signed-off-by: Mark Tolmacs * fix: Tests Signed-off-by: Mark Tolmacs * Trigger build * feat: hide point highlight when dragging * feat: hide bbox when dragging points * revert binding gap increase for elbow arrows * reset selectionLinearElement on tool change * chore: Remove debug * feat: Better restore for bindings * use elementsMap instead of array when passing to restoreElement * fix: Arrow angle reset * fix: Arrow angle * Arrow angle support * fix trashing cached canvases in `LinearElementEditor.getElementAbsoluteCoords` --------- Signed-off-by: Mark Tolmacs Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- excalidraw-app/components/DebugCanvas.tsx | 128 +- excalidraw-app/data/LocalData.ts | 4 +- excalidraw-app/data/localStorage.ts | 3 +- excalidraw-app/sentry.ts | 13 + packages/common/src/constants.ts | 2 + packages/common/src/utils.ts | 57 +- packages/common/src/visualdebug.ts | 2 +- packages/element/src/binding.ts | 2446 ++++++++--------- packages/element/src/bounds.ts | 7 + packages/element/src/collision.ts | 185 +- packages/element/src/dragElements.ts | 49 +- packages/element/src/elbowArrow.ts | 39 +- packages/element/src/flowchart.ts | 12 +- packages/element/src/index.ts | 22 - packages/element/src/linearElementEditor.ts | 1134 +++++--- packages/element/src/mutateElement.ts | 7 +- packages/element/src/newElement.ts | 5 +- packages/element/src/renderElement.ts | 27 +- packages/element/src/resizeElements.ts | 93 +- packages/element/src/transformHandles.ts | 5 +- packages/element/src/typeChecks.ts | 13 +- packages/element/src/types.ts | 39 +- packages/element/src/utils.ts | 167 +- packages/element/src/zindex.ts | 64 +- packages/element/tests/binding.test.tsx | 1070 ++++--- packages/element/tests/bounds.test.ts | 8 +- packages/element/tests/duplicate.test.tsx | 23 +- packages/element/tests/elbowArrow.test.tsx | 78 +- .../tests/linearElementEditor.test.tsx | 25 +- packages/element/tests/resize.test.tsx | 264 +- packages/excalidraw/actions/actionCanvas.tsx | 9 +- .../excalidraw/actions/actionClipboard.tsx | 8 +- .../actions/actionDeleteSelected.tsx | 23 +- packages/excalidraw/actions/actionExport.tsx | 18 +- .../excalidraw/actions/actionFinalize.tsx | 203 +- .../excalidraw/actions/actionFlip.test.tsx | 26 +- packages/excalidraw/actions/actionFlip.ts | 21 +- .../excalidraw/actions/actionNavigate.tsx | 11 +- .../excalidraw/actions/actionProperties.tsx | 286 +- packages/excalidraw/actions/register.ts | 7 +- packages/excalidraw/actions/types.ts | 8 +- packages/excalidraw/appState.ts | 6 +- packages/excalidraw/components/App.tsx | 1365 ++++++--- .../CommandPalette/CommandPalette.tsx | 2 +- .../components/CommandPalette/types.ts | 3 +- .../components/ConvertElementTypePopup.tsx | 2 +- packages/excalidraw/components/LayerUI.tsx | 2 +- .../excalidraw/components/Stats/Angle.tsx | 5 +- .../components/Stats/MultiDimension.tsx | 4 +- .../components/Stats/MultiPosition.tsx | 8 + .../excalidraw/components/Stats/Position.tsx | 3 + .../excalidraw/components/Stats/index.tsx | 20 +- .../components/Stats/stats.test.tsx | 26 +- packages/excalidraw/components/Stats/utils.ts | 27 +- .../components/canvases/InteractiveCanvas.tsx | 9 +- .../components/canvases/StaticCanvas.tsx | 1 + .../data/__snapshots__/transform.test.ts.snap | 113 +- packages/excalidraw/data/blob.ts | 4 +- packages/excalidraw/data/json.ts | 10 +- packages/excalidraw/data/restore.ts | 159 +- packages/excalidraw/data/transform.test.ts | 10 +- packages/excalidraw/data/transform.ts | 8 +- packages/excalidraw/global.d.ts | 5 +- packages/excalidraw/locales/en.json | 1 + packages/excalidraw/package.json | 4 +- packages/excalidraw/renderer/helpers.ts | 324 +-- .../excalidraw/renderer/interactiveScene.ts | 630 ++++- packages/excalidraw/scene/types.ts | 1 + .../__snapshots__/contextmenu.test.tsx.snap | 51 +- .../__snapshots__/dragCreate.test.tsx.snap | 2 - .../tests/__snapshots__/history.test.tsx.snap | 1121 ++++---- .../tests/__snapshots__/move.test.tsx.snap | 36 +- .../multiPointCreate.test.tsx.snap | 16 +- .../regressionTests.test.tsx.snap | 420 ++- .../__snapshots__/selection.test.tsx.snap | 2 - .../data/__snapshots__/restore.test.ts.snap | 4 - packages/excalidraw/tests/dragCreate.test.tsx | 8 +- packages/excalidraw/tests/history.test.tsx | 394 +-- packages/excalidraw/tests/lasso.test.tsx | 9 - packages/excalidraw/tests/library.test.tsx | 3 +- packages/excalidraw/tests/move.test.tsx | 43 +- .../tests/multiPointCreate.test.tsx | 12 +- .../excalidraw/tests/regressionTests.test.tsx | 1 - packages/excalidraw/tests/rotate.test.tsx | 29 +- packages/excalidraw/tests/selection.test.tsx | 15 +- packages/excalidraw/types.ts | 20 +- packages/utils/src/test-utils.ts | 6 +- .../tests/__snapshots__/export.test.ts.snap | 3 +- 88 files changed, 6864 insertions(+), 4694 deletions(-) diff --git a/excalidraw-app/components/DebugCanvas.tsx b/excalidraw-app/components/DebugCanvas.tsx index 71b0133076..9df430376b 100644 --- a/excalidraw-app/components/DebugCanvas.tsx +++ b/excalidraw-app/components/DebugCanvas.tsx @@ -15,7 +15,6 @@ import { getGlobalFixedPointForBindableElement, isArrowElement, isBindableElement, - isFixedPointBinding, } from "@excalidraw/element"; import { @@ -35,7 +34,6 @@ import type { ExcalidrawBindableElement, FixedPointBinding, OrderedExcalidrawElement, - PointBinding, } from "@excalidraw/element/types"; import { STORAGE_KEYS } from "../app_constants"; @@ -91,48 +89,46 @@ const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => { const _renderBinding = ( context: CanvasRenderingContext2D, - binding: FixedPointBinding | PointBinding, + binding: FixedPointBinding, elementsMap: ElementsMap, zoom: number, width: number, height: number, color: string, ) => { - if (isFixedPointBinding(binding)) { - if (!binding.fixedPoint) { - console.warn("Binding must have a fixedPoint"); - return; - } - - const bindable = elementsMap.get( - binding.elementId, - ) as ExcalidrawBindableElement; - const [x, y] = getGlobalFixedPointForBindableElement( - binding.fixedPoint, - bindable, - elementsMap, - ); - - context.save(); - context.strokeStyle = color; - context.lineWidth = 1; - context.beginPath(); - context.moveTo(x * zoom, y * zoom); - context.bezierCurveTo( - x * zoom - width, - y * zoom - height, - x * zoom - width, - y * zoom + height, - x * zoom, - y * zoom, - ); - context.stroke(); - context.restore(); + if (!binding.fixedPoint) { + console.warn("Binding must have a fixedPoint"); + return; } + + const bindable = elementsMap.get( + binding.elementId, + ) as ExcalidrawBindableElement; + const [x, y] = getGlobalFixedPointForBindableElement( + binding.fixedPoint, + bindable, + elementsMap, + ); + + context.save(); + context.strokeStyle = color; + context.lineWidth = 1; + context.beginPath(); + context.moveTo(x * zoom, y * zoom); + context.bezierCurveTo( + x * zoom - width, + y * zoom - height, + x * zoom - width, + y * zoom + height, + x * zoom, + y * zoom, + ); + context.stroke(); + context.restore(); }; const _renderBindableBinding = ( - binding: FixedPointBinding | PointBinding, + binding: FixedPointBinding, context: CanvasRenderingContext2D, elementsMap: ElementsMap, zoom: number, @@ -140,37 +136,35 @@ const _renderBindableBinding = ( height: number, color: string, ) => { - if (isFixedPointBinding(binding)) { - const bindable = elementsMap.get( - binding.elementId, - ) as ExcalidrawBindableElement; - if (!binding.fixedPoint) { - console.warn("Binding must have a fixedPoint"); - return; - } - - const [x, y] = getGlobalFixedPointForBindableElement( - binding.fixedPoint, - bindable, - elementsMap, - ); - - context.save(); - context.strokeStyle = color; - context.lineWidth = 1; - context.beginPath(); - context.moveTo(x * zoom, y * zoom); - context.bezierCurveTo( - x * zoom + width, - y * zoom + height, - x * zoom + width, - y * zoom - height, - x * zoom, - y * zoom, - ); - context.stroke(); - context.restore(); + const bindable = elementsMap.get( + binding.elementId, + ) as ExcalidrawBindableElement; + if (!binding.fixedPoint) { + console.warn("Binding must have a fixedPoint"); + return; } + + const [x, y] = getGlobalFixedPointForBindableElement( + binding.fixedPoint, + bindable, + elementsMap, + ); + + context.save(); + context.strokeStyle = color; + context.lineWidth = 1; + context.beginPath(); + context.moveTo(x * zoom, y * zoom); + context.bezierCurveTo( + x * zoom + width, + y * zoom + height, + x * zoom + width, + y * zoom - height, + x * zoom, + y * zoom, + ); + context.stroke(); + context.restore(); }; const renderBindings = ( @@ -197,12 +191,12 @@ const renderBindings = ( _renderBinding( context, - element.startBinding as FixedPointBinding, + element.startBinding, elementsMap, zoom, dim, dim, - "red", + element.startBinding?.mode === "orbit" ? "red" : "black", ); } @@ -221,7 +215,7 @@ const renderBindings = ( zoom, dim, dim, - "red", + element.endBinding?.mode === "orbit" ? "red" : "black", ); } } diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts index a2a930a1ac..13cdf09ac4 100644 --- a/excalidraw-app/data/LocalData.ts +++ b/excalidraw-app/data/LocalData.ts @@ -16,7 +16,6 @@ import { DEFAULT_SIDEBAR, debounce, } from "@excalidraw/common"; -import { clearElementsForLocalStorage } from "@excalidraw/element"; import { createStore, entries, @@ -28,6 +27,7 @@ import { } from "idb-keyval"; import { appJotaiStore, atom } from "excalidraw-app/app-jotai"; +import { getNonDeletedElements } from "@excalidraw/element"; import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library"; import type { ImportedDataState } from "@excalidraw/excalidraw/data/types"; @@ -88,7 +88,7 @@ const saveDataStateToLocalStorage = ( localStorage.setItem( STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, - JSON.stringify(clearElementsForLocalStorage(elements)), + JSON.stringify(getNonDeletedElements(elements)), ); localStorage.setItem( STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, diff --git a/excalidraw-app/data/localStorage.ts b/excalidraw-app/data/localStorage.ts index bc0df4a678..28c166cd74 100644 --- a/excalidraw-app/data/localStorage.ts +++ b/excalidraw-app/data/localStorage.ts @@ -2,7 +2,6 @@ import { clearAppStateForLocalStorage, getDefaultAppState, } from "@excalidraw/excalidraw/appState"; -import { clearElementsForLocalStorage } from "@excalidraw/element"; import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { AppState } from "@excalidraw/excalidraw/types"; @@ -50,7 +49,7 @@ export const importFromLocalStorage = () => { let elements: ExcalidrawElement[] = []; if (savedElements) { try { - elements = clearElementsForLocalStorage(JSON.parse(savedElements)); + elements = JSON.parse(savedElements); } catch (error: any) { console.error(error); // Do nothing because elements array is already empty diff --git a/excalidraw-app/sentry.ts b/excalidraw-app/sentry.ts index 30b84f3f69..58e34bba53 100644 --- a/excalidraw-app/sentry.ts +++ b/excalidraw-app/sentry.ts @@ -1,3 +1,4 @@ +import { getFeatureFlag } from "@excalidraw/common"; import * as Sentry from "@sentry/browser"; import callsites from "callsites"; @@ -33,6 +34,7 @@ Sentry.init({ Sentry.captureConsoleIntegration({ levels: ["error"], }), + Sentry.featureFlagsIntegration(), ], beforeSend(event) { if (event.request?.url) { @@ -79,3 +81,14 @@ Sentry.init({ return event; }, }); + +const flagsIntegration = + Sentry.getClient()?.getIntegrationByName( + "FeatureFlags", + ); +if (flagsIntegration) { + flagsIntegration.addFeatureFlag( + "COMPLEX_BINDINGS", + getFeatureFlag("COMPLEX_BINDINGS"), + ); +} diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index a003cc1dd6..efacd4075f 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -499,6 +499,8 @@ export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20; export const DOUBLE_TAP_POSITION_THRESHOLD = 35; +export const BIND_MODE_TIMEOUT = 700; // ms + // glass background for mobile action buttons export const MOBILE_ACTION_BUTTON_BG = { background: "var(--mobile-action-button-bg)", diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index 69e854b0b0..356f951521 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -1,10 +1,6 @@ import { average } from "@excalidraw/math"; -import type { - ExcalidrawBindableElement, - FontFamilyValues, - FontString, -} from "@excalidraw/element/types"; +import type { FontFamilyValues, FontString } from "@excalidraw/element/types"; import type { ActiveTool, @@ -382,6 +378,10 @@ export const removeSelection = () => { export const distance = (x: number, y: number) => Math.abs(x - y); +export const isSelectionLikeTool = (type: ToolType | "custom") => { + return type === "selection" || type === "lasso"; +}; + export const updateActiveTool = ( appState: Pick, data: (( @@ -558,9 +558,6 @@ export const isTransparent = (color: string) => { ); }; -export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) => - el.fillStyle !== "solid" || isTransparent(el.backgroundColor); - export type ResolvablePromise = Promise & { resolve: [T] extends [undefined] ? (value?: MaybePromise>) => void @@ -1270,3 +1267,47 @@ export const reduceToCommonValue = ( return commonValue; }; + +type FEATURE_FLAGS = { + COMPLEX_BINDINGS: boolean; +}; + +const FEATURE_FLAGS_STORAGE_KEY = "excalidraw-feature-flags"; +const DEFAULT_FEATURE_FLAGS: FEATURE_FLAGS = { + COMPLEX_BINDINGS: false, +}; +let featureFlags: FEATURE_FLAGS | null = null; + +export const getFeatureFlag = ( + flag: F, +): FEATURE_FLAGS[F] => { + if (!featureFlags) { + try { + const serializedFlags = localStorage.getItem(FEATURE_FLAGS_STORAGE_KEY); + if (serializedFlags) { + const flags = JSON.parse(serializedFlags); + featureFlags = flags ?? DEFAULT_FEATURE_FLAGS; + } + } catch {} + } + + return (featureFlags || DEFAULT_FEATURE_FLAGS)[flag]; +}; + +export const setFeatureFlag = ( + flag: F, + value: FEATURE_FLAGS[F], +) => { + try { + featureFlags = { + ...(featureFlags || DEFAULT_FEATURE_FLAGS), + [flag]: value, + }; + localStorage.setItem( + FEATURE_FLAGS_STORAGE_KEY, + JSON.stringify(featureFlags), + ); + } catch (e) { + console.error("unable to set feature flag", e); + } +}; diff --git a/packages/common/src/visualdebug.ts b/packages/common/src/visualdebug.ts index 961fa919f2..993612959e 100644 --- a/packages/common/src/visualdebug.ts +++ b/packages/common/src/visualdebug.ts @@ -139,7 +139,7 @@ export const debugDrawPoints = ( }: { x: number; y: number; - points: LocalPoint[]; + points: readonly LocalPoint[]; }, options?: any, ) => { diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index fa1355309b..473b2ec7be 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -1,32 +1,27 @@ import { KEYS, arrayToMap, - isBindingFallthroughEnabled, - tupleToCoors, + getFeatureFlag, invariant, - isDevEnv, - isTestEnv, + isTransparent, } from "@excalidraw/common"; import { - lineSegment, - pointFrom, - pointRotateRads, - type GlobalPoint, - vectorFromPoint, - pointDistanceSq, - clamp, - pointDistance, - pointFromVector, - vectorScale, - vectorNormalize, - vectorCross, - pointsEqual, - lineSegmentIntersectionPoints, PRECISION, + clamp, + lineSegment, + pointDistance, + pointDistanceSq, + pointFrom, + pointFromVector, + pointRotateRads, + vectorFromPoint, + vectorNormalize, + vectorScale, + type GlobalPoint, } from "@excalidraw/math"; -import type { LocalPoint, Radians } from "@excalidraw/math"; +import type { LineSegment, LocalPoint, Radians } from "@excalidraw/math"; import type { AppState } from "@excalidraw/excalidraw/types"; @@ -37,7 +32,13 @@ import { getCenterForBounds, getElementBounds, } from "./bounds"; -import { intersectElementWithLineSegment } from "./collision"; +import { + getAllHoveredElementAtPoint, + getHoveredElementForBinding, + intersectElementWithLineSegment, + isBindableElementInsideOtherBindable, + isPointInElement, +} from "./collision"; import { distanceToElement } from "./distance"; import { headingForPointFromElement, @@ -53,46 +54,84 @@ import { isBindableElement, isBoundToContainer, isElbowArrow, - isFixedPointBinding, - isFrameLikeElement, - isLinearElement, isRectanguloidElement, isTextElement, } from "./typeChecks"; import { aabbForElement, elementCenterPoint } from "./bounds"; import { updateElbowArrowPoints } from "./elbowArrow"; +import { projectFixedPointOntoDiagonal } from "./utils"; import type { Scene } from "./Scene"; import type { Bounds } from "./bounds"; import type { ElementUpdate } from "./mutateElement"; import type { - ExcalidrawBindableElement, - ExcalidrawElement, - NonDeleted, - ExcalidrawLinearElement, - PointBinding, - NonDeletedExcalidrawElement, + BindMode, ElementsMap, - NonDeletedSceneElementsMap, - ExcalidrawTextElement, ExcalidrawArrowElement, + ExcalidrawBindableElement, ExcalidrawElbowArrowElement, + ExcalidrawElement, + ExcalidrawTextElement, FixedPoint, FixedPointBinding, + NonDeleted, + NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, + Ordered, PointsPositionUpdates, } from "./types"; -export type SuggestedBinding = - | NonDeleted - | SuggestedPointBinding; +export type BindingStrategy = + // Create a new binding with this mode + | { + mode: BindMode; + element: NonDeleted; + focusPoint: GlobalPoint; + } + // Break the binding + | { + mode: null; + element?: undefined; + focusPoint?: undefined; + } + // Keep the existing binding + | { + mode: undefined; + element?: undefined; + focusPoint?: undefined; + }; -export type SuggestedPointBinding = [ - NonDeleted, - "start" | "end" | "both", - NonDeleted, -]; +/** + * gaps exclude element strokeWidth + * + * IMPORTANT: currently must be > 0 (this also applies to the computed gap) + */ +export const BASE_BINDING_GAP = 10; +export const BASE_BINDING_GAP_ELBOW = 5; + +export const getBindingGap = ( + bindTarget: ExcalidrawBindableElement, + opts: Pick, +): number => { + return ( + (opts.elbowed ? BASE_BINDING_GAP_ELBOW : BASE_BINDING_GAP) + + bindTarget.strokeWidth / 2 + ); +}; + +export const maxBindingDistance_simple = (zoom?: AppState["zoom"]): number => { + const BASE_BINDING_DISTANCE = Math.max(BASE_BINDING_GAP, 15); + const zoomValue = zoom?.value && zoom.value < 1 ? zoom.value : 1; + return clamp( + // reducing zoom impact so that the diff between binding distance and + // binding gap is kept to minimum when possible + BASE_BINDING_DISTANCE / (zoomValue * 1.5), + BASE_BINDING_DISTANCE, + BASE_BINDING_DISTANCE * 2, + ); +}; export const shouldEnableBindingForPointerEvent = ( event: React.PointerEvent, @@ -104,633 +143,875 @@ export const isBindingEnabled = (appState: AppState): boolean => { return appState.isBindingEnabled; }; -export const FIXED_BINDING_DISTANCE = 5; -export const BINDING_HIGHLIGHT_THICKNESS = 10; - -const getNonDeletedElements = ( +export const bindOrUnbindBindingElement = ( + arrow: NonDeleted, + draggingPoints: PointsPositionUpdates, scene: Scene, - ids: readonly ExcalidrawElement["id"][], -): NonDeleted[] => { - const result: NonDeleted[] = []; - ids.forEach((id) => { - const element = scene.getNonDeletedElement(id); - if (element != null) { - result.push(element); + appState: AppState, + opts?: { + newArrow?: boolean; + altKey?: boolean; + initialBinding?: boolean; + }, +) => { + const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints( + arrow, + draggingPoints, + scene.getNonDeletedElementsMap(), + scene.getNonDeletedElements(), + appState, + { + ...opts, + finalize: true, + }, + ); + + bindOrUnbindBindingElementEdge(arrow, start, "start", scene); + bindOrUnbindBindingElementEdge(arrow, end, "end", scene); + if (start.focusPoint || end.focusPoint) { + // If the strategy dictates a focus point override, then + // update the arrow points to point to the focus point. + const updates: PointsPositionUpdates = new Map(); + + if (start.focusPoint) { + updates.set(0, { + point: + updateBoundPoint( + arrow, + "startBinding", + arrow.startBinding, + start.element, + scene.getNonDeletedElementsMap(), + ) || arrow.points[0], + }); } - }); - return result; + + if (end.focusPoint) { + updates.set(arrow.points.length - 1, { + point: + updateBoundPoint( + arrow, + "endBinding", + arrow.endBinding, + end.element, + scene.getNonDeletedElementsMap(), + ) || arrow.points[arrow.points.length - 1], + }); + } + + LinearElementEditor.movePoints(arrow, scene, updates); + } + + return { start, end }; }; -export const bindOrUnbindLinearElement = ( - linearElement: NonDeleted, - startBindingElement: ExcalidrawBindableElement | null | "keep", - endBindingElement: ExcalidrawBindableElement | null | "keep", - scene: Scene, -): void => { - const elementsMap = scene.getNonDeletedElementsMap(); - const boundToElementIds: Set = new Set(); - const unboundFromElementIds: Set = new Set(); - bindOrUnbindLinearElementEdge( - linearElement, - startBindingElement, - endBindingElement, - "start", - boundToElementIds, - unboundFromElementIds, - scene, - elementsMap, - ); - bindOrUnbindLinearElementEdge( - linearElement, - endBindingElement, - startBindingElement, - "end", - boundToElementIds, - unboundFromElementIds, - scene, - elementsMap, - ); - - const onlyUnbound = Array.from(unboundFromElementIds).filter( - (id) => !boundToElementIds.has(id), - ); - - getNonDeletedElements(scene, onlyUnbound).forEach((element) => { - scene.mutateElement(element, { - boundElements: element.boundElements?.filter( - (element) => - element.type !== "arrow" || element.id !== linearElement.id, - ), - }); - }); -}; - -const bindOrUnbindLinearElementEdge = ( - linearElement: NonDeleted, - bindableElement: ExcalidrawBindableElement | null | "keep", - otherEdgeBindableElement: ExcalidrawBindableElement | null | "keep", +const bindOrUnbindBindingElementEdge = ( + arrow: NonDeleted, + { mode, element, focusPoint }: BindingStrategy, startOrEnd: "start" | "end", - // Is mutated - boundToElementIds: Set, - // Is mutated - unboundFromElementIds: Set, scene: Scene, - elementsMap: ElementsMap, ): void => { - // "keep" is for method chaining convenience, a "no-op", so just bail out - if (bindableElement === "keep") { - return; - } - - // null means break the bind, so nothing to consider here - if (bindableElement === null) { - const unbound = unbindLinearElement(linearElement, startOrEnd, scene); - if (unbound != null) { - unboundFromElementIds.add(unbound); - } - return; - } - - // While complext arrows can do anything, simple arrow with both ends trying - // to bind to the same bindable should not be allowed, start binding takes - // precedence - if (isLinearElementSimple(linearElement)) { - if ( - otherEdgeBindableElement == null || - (otherEdgeBindableElement === "keep" - ? // TODO: Refactor - Needlessly complex - !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge( - linearElement, - bindableElement, - startOrEnd, - ) - : startOrEnd === "start" || - otherEdgeBindableElement.id !== bindableElement.id) - ) { - bindLinearElement(linearElement, bindableElement, startOrEnd, scene); - boundToElementIds.add(bindableElement.id); - } - } else { - bindLinearElement(linearElement, bindableElement, startOrEnd, scene); - boundToElementIds.add(bindableElement.id); + if (mode === null) { + // null means break the binding + unbindBindingElement(arrow, startOrEnd, scene); + } else if (mode !== undefined) { + bindBindingElement(arrow, element, mode, startOrEnd, scene, focusPoint); } }; -const getOriginalBindingsIfStillCloseToArrowEnds = ( - linearElement: NonDeleted, +const bindingStrategyForElbowArrowEndpointDragging = ( + arrow: NonDeleted, + draggingPoints: PointsPositionUpdates, elementsMap: NonDeletedSceneElementsMap, + elements: readonly Ordered[], zoom?: AppState["zoom"], -): (NonDeleted | null)[] => - (["start", "end"] as const).map((edge) => { - const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap); - const elementId = - edge === "start" - ? linearElement.startBinding?.elementId - : linearElement.endBinding?.elementId; - if (elementId) { - const element = elementsMap.get(elementId); - if ( - isBindableElement(element) && - bindingBorderTest(element, coors, elementsMap, zoom) - ) { - return element; +): { + start: BindingStrategy; + end: BindingStrategy; +} => { + invariant(draggingPoints.size === 1, "Bound elbow arrows cannot be moved"); + + const update = draggingPoints.entries().next().value; + + invariant( + update, + "There should be a position update for dragging an elbow arrow endpoint", + ); + + const [pointIdx, { point }] = update; + const globalPoint = LinearElementEditor.getPointGlobalCoordinates( + arrow, + point, + elementsMap, + ); + const hit = getHoveredElementForBinding( + globalPoint, + elements, + elementsMap, + (element) => maxBindingDistance_simple(zoom), + ); + + const current = hit + ? { + element: hit, + mode: "orbit" as const, + focusPoint: LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + pointIdx, + elementsMap, + ), + } + : { + mode: null, + }; + const other = { mode: undefined }; + + return pointIdx === 0 + ? { start: current, end: other } + : { start: other, end: current }; +}; + +const bindingStrategyForNewSimpleArrowEndpointDragging = ( + arrow: NonDeleted, + draggingPoints: PointsPositionUpdates, + elementsMap: NonDeletedSceneElementsMap, + elements: readonly Ordered[], + startDragged: boolean, + endDragged: boolean, + startIdx: number, + endIdx: number, + appState: AppState, + globalBindMode?: AppState["bindMode"], + shiftKey?: boolean, +): { + start: BindingStrategy; + end: BindingStrategy; +} => { + let start: BindingStrategy = { mode: undefined }; + let end: BindingStrategy = { mode: undefined }; + + const isMultiPoint = arrow.points.length > 2; + const point = LinearElementEditor.getPointGlobalCoordinates( + arrow, + draggingPoints.get(startDragged ? startIdx : endIdx)!.point, + elementsMap, + ); + const hit = getHoveredElementForBinding(point, elements, elementsMap); + + // With new arrows this handles the binding at arrow creation + if (startDragged) { + if (hit) { + start = { + element: hit, + mode: "inside", + focusPoint: point, + }; + } else { + start = { mode: null }; + } + + return { start, end }; + } + + // With new arrows it represents the continuous dragging of the end point + if (endDragged) { + const origin = appState?.selectedLinearElement?.initialState.origin; + + // Inside -> inside binding + if (hit && arrow.startBinding?.elementId === hit.id) { + const center = pointFrom( + hit.x + hit.width / 2, + hit.y + hit.height / 2, + ); + + return { + start: isMultiPoint + ? { mode: undefined } + : { + mode: "inside", + element: hit, + focusPoint: origin ?? center, + }, + end: isMultiPoint + ? { mode: "orbit", element: hit, focusPoint: point } + : { mode: "inside", element: hit, focusPoint: point }, + }; + } + + // Check and handle nested shapes + if (hit && arrow.startBinding) { + const startBinding = arrow.startBinding; + const allHits = getAllHoveredElementAtPoint(point, elements, elementsMap); + + if (allHits.find((el) => el.id === startBinding.elementId)) { + const otherElement = elementsMap.get( + arrow.startBinding.elementId, + ) as ExcalidrawBindableElement; + + invariant(otherElement, "Other element must be in the elements map"); + + return { + start: isMultiPoint + ? { mode: undefined } + : { + mode: otherElement.id !== hit.id ? "orbit" : "inside", + element: otherElement, + focusPoint: origin ?? pointFrom(arrow.x, arrow.y), + }, + end: { + mode: "orbit", + element: hit, + focusPoint: point, + }, + }; } } - return null; - }); + // Inside -> outside binding + if (arrow.startBinding && arrow.startBinding.elementId !== hit?.id) { + const otherElement = elementsMap.get( + arrow.startBinding.elementId, + ) as ExcalidrawBindableElement; + invariant(otherElement, "Other element must be in the elements map"); -const getBindingStrategyForDraggingArrowEndpoints = ( - selectedElement: NonDeleted, - isBindingEnabled: boolean, - draggingPoints: readonly number[], - elementsMap: NonDeletedSceneElementsMap, - elements: readonly NonDeletedExcalidrawElement[], - zoom?: AppState["zoom"], -): (NonDeleted | null | "keep")[] => { - const startIdx = 0; - const endIdx = selectedElement.points.length - 1; - const startDragged = draggingPoints.findIndex((i) => i === startIdx) > -1; - const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1; - const start = startDragged - ? isBindingEnabled - ? getEligibleElementForBindingElement( - selectedElement, - "start", - elementsMap, - elements, - zoom, - ) - : null // If binding is disabled and start is dragged, break all binds - : "keep"; - const end = endDragged - ? isBindingEnabled - ? getEligibleElementForBindingElement( - selectedElement, - "end", - elementsMap, - elements, - zoom, - ) - : null // If binding is disabled and end is dragged, break all binds - : "keep"; + const otherIsInsideBinding = + !!appState.selectedLinearElement?.initialState.arrowStartIsInside; + const other: BindingStrategy = { + mode: otherIsInsideBinding ? "inside" : "orbit", + element: otherElement, + focusPoint: shiftKey + ? elementCenterPoint(otherElement, elementsMap) + : origin ?? pointFrom(arrow.x, arrow.y), + }; - return [start, end]; -}; + // We are hovering another element with the end point + const isNested = + hit && + isBindableElementInsideOtherBindable(otherElement, hit, elementsMap); + let current: BindingStrategy; + if (hit) { + const isInsideBinding = + globalBindMode === "inside" || globalBindMode === "skip"; + current = { + mode: isInsideBinding && !isNested ? "inside" : "orbit", + element: hit, + focusPoint: isInsideBinding || isNested ? point : point, + }; + } else { + current = { mode: null }; + } -const getBindingStrategyForDraggingArrowOrJoints = ( - selectedElement: NonDeleted, - elementsMap: NonDeletedSceneElementsMap, - elements: readonly NonDeletedExcalidrawElement[], - isBindingEnabled: boolean, - zoom?: AppState["zoom"], -): (NonDeleted | null | "keep")[] => { - // Elbow arrows don't bind when dragged as a whole - if (isElbowArrow(selectedElement)) { - return ["keep", "keep"]; + return { + start: isMultiPoint ? { mode: undefined } : other, + end: current, + }; + } + + // No start binding + if (!arrow.startBinding) { + if (hit) { + const isInsideBinding = + globalBindMode === "inside" || globalBindMode === "skip"; + + end = { + mode: isInsideBinding ? "inside" : "orbit", + element: hit, + focusPoint: point, + }; + } else { + end = { mode: null }; + } + + return { start, end }; + } } - const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds( - selectedElement, - elementsMap, - zoom, - ); - const start = startIsClose - ? isBindingEnabled - ? getEligibleElementForBindingElement( - selectedElement, - "start", - elementsMap, - elements, - zoom, - ) - : null - : null; - const end = endIsClose - ? isBindingEnabled - ? getEligibleElementForBindingElement( - selectedElement, - "end", - elementsMap, - elements, - zoom, - ) - : null - : null; - - return [start, end]; + invariant(false, "New arrow creation should not reach here"); }; -export const bindOrUnbindLinearElements = ( - selectedElements: NonDeleted[], - isBindingEnabled: boolean, - draggingPoints: readonly number[] | null, - scene: Scene, - zoom?: AppState["zoom"], -): void => { - selectedElements.forEach((selectedElement) => { - const [start, end] = draggingPoints?.length - ? // The arrow edge points are dragged (i.e. start, end) - getBindingStrategyForDraggingArrowEndpoints( - selectedElement, - isBindingEnabled, - draggingPoints ?? [], - scene.getNonDeletedElementsMap(), - scene.getNonDeletedElements(), - zoom, - ) - : // The arrow itself (the shaft) or the inner joins are dragged - getBindingStrategyForDraggingArrowOrJoints( - selectedElement, - scene.getNonDeletedElementsMap(), - scene.getNonDeletedElements(), - isBindingEnabled, - zoom, - ); - - bindOrUnbindLinearElement(selectedElement, start, end, scene); - }); -}; - -export const getSuggestedBindingsForArrows = ( - selectedElements: NonDeleted[], +const bindingStrategyForSimpleArrowEndpointDragging_complex = ( + point: GlobalPoint, + currentBinding: FixedPointBinding | null, + oppositeBinding: FixedPointBinding | null, elementsMap: NonDeletedSceneElementsMap, - zoom: AppState["zoom"], -): SuggestedBinding[] => { - // HOT PATH: Bail out if selected elements list is too large - if (selectedElements.length > 50) { - return []; - } + elements: readonly Ordered[], + globalBindMode: AppState["bindMode"], + arrow: NonDeleted, + finalize?: boolean, +): { current: BindingStrategy; other: BindingStrategy } => { + let current: BindingStrategy = { mode: undefined }; + let other: BindingStrategy = { mode: undefined }; - return ( - selectedElements - .filter(isLinearElement) - .flatMap((element) => - getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap, zoom), + const isMultiPoint = arrow.points.length > 2; + const hit = getHoveredElementForBinding(point, elements, elementsMap); + const isOverlapping = oppositeBinding + ? getAllHoveredElementAtPoint(point, elements, elementsMap).some( + (el) => el.id === oppositeBinding.elementId, ) - .filter( - (element): element is NonDeleted => - element !== null, - ) - // Filter out bind candidates which are in the - // same selection / group with the arrow - // - // TODO: Is it worth turning the list into a set to avoid dupes? - .filter( - (element) => - selectedElements.filter((selected) => selected.id === element?.id) - .length === 0, - ) - ); -}; + : false; + const oppositeElement = oppositeBinding + ? (elementsMap.get(oppositeBinding.elementId) as ExcalidrawBindableElement) + : null; + const otherIsTransparent = + isOverlapping && oppositeElement + ? isTransparent(oppositeElement.backgroundColor) + : false; + const isNested = + hit && + oppositeElement && + isBindableElementInsideOtherBindable(oppositeElement, hit, elementsMap); -export const maybeSuggestBindingsForLinearElementAtCoords = ( - linearElement: NonDeleted, - /** scene coords */ - pointerCoords: { - x: number; - y: number; - }[], - scene: Scene, - zoom: AppState["zoom"], - // During line creation the start binding hasn't been written yet - // into `linearElement` - oppositeBindingBoundElement?: ExcalidrawBindableElement | null, -): ExcalidrawBindableElement[] => - Array.from( - pointerCoords.reduce( - (acc: Set>, coords) => { - const hoveredBindableElement = getHoveredElementForBinding( - coords, - scene.getNonDeletedElements(), - scene.getNonDeletedElementsMap(), - zoom, - isElbowArrow(linearElement), - isElbowArrow(linearElement), - ); - - if ( - hoveredBindableElement != null && - !isLinearElementSimpleAndAlreadyBound( - linearElement, - oppositeBindingBoundElement?.id, - hoveredBindableElement, - ) - ) { - acc.add(hoveredBindableElement); + // If the global bind mode is in free binding mode, just bind + // where the pointer is and keep the other end intact + if (globalBindMode === "inside" || globalBindMode === "skip") { + current = hit + ? { + element: + !isOverlapping || !oppositeElement || otherIsTransparent + ? hit + : oppositeElement, + focusPoint: point, + mode: "inside", } + : { mode: null }; + other = + finalize && hit && hit.id === oppositeBinding?.elementId + ? { mode: null } + : other; - return acc; - }, - new Set() as Set>, - ), - ); + return { current, other }; + } -export const maybeBindLinearElement = ( - linearElement: NonDeleted, + // Dragged point is outside of any bindable element + // so we break any existing binding + if (!hit) { + return { current: { mode: null }, other }; + } + + // Already inside binding over the same hit element should remain inside bound + if ( + hit.id === currentBinding?.elementId && + currentBinding.mode === "inside" + ) { + return { + current: { mode: "inside", focusPoint: point, element: hit }, + other, + }; + } + + // The dragged point is inside the hovered bindable element + if (oppositeBinding) { + // The opposite binding is on the same element + if (oppositeBinding.elementId === hit.id) { + // The opposite binding is on the binding gap of the same element + if (oppositeBinding.mode === "orbit") { + current = { element: hit, mode: "orbit", focusPoint: point }; + other = { mode: finalize ? null : undefined }; + + return { current, other: isMultiPoint ? { mode: undefined } : other }; + } + // The opposite binding is inside the same element + // eslint-disable-next-line no-else-return + else { + current = { element: hit, mode: "inside", focusPoint: point }; + + return { current, other: isMultiPoint ? { mode: undefined } : other }; + } + } + // The opposite binding is on a different element (or nested) + // eslint-disable-next-line no-else-return + else { + // Handle the nested element case + if (isOverlapping && oppositeElement && !otherIsTransparent) { + current = { + element: oppositeElement, + mode: "inside", + focusPoint: point, + }; + } else { + current = { + element: hit, + mode: "orbit", + focusPoint: isNested ? point : point, + }; + } + + return { current, other: isMultiPoint ? { mode: undefined } : other }; + } + } + // The opposite binding is on a different element or no binding + else { + current = { + element: hit, + mode: "orbit", + focusPoint: point, + }; + } + + // Must return as only one endpoint is dragged, therefore + // the end binding strategy might accidentally gets overriden + return { current, other: isMultiPoint ? { mode: undefined } : other }; +}; + +export const getBindingStrategyForDraggingBindingElementEndpoints = ( + arrow: NonDeleted, + draggingPoints: PointsPositionUpdates, + elementsMap: NonDeletedSceneElementsMap, + elements: readonly Ordered[], appState: AppState, - pointerCoords: { x: number; y: number }, - scene: Scene, -): void => { - const elements = scene.getNonDeletedElements(); - const elementsMap = scene.getNonDeletedElementsMap(); - - if (appState.startBoundElement != null) { - bindLinearElement( - linearElement, - appState.startBoundElement, - "start", - scene, + opts?: { + newArrow?: boolean; + shiftKey?: boolean; + altKey?: boolean; + finalize?: boolean; + initialBinding?: boolean; + zoom?: AppState["zoom"]; + }, +): { start: BindingStrategy; end: BindingStrategy } => { + if (getFeatureFlag("COMPLEX_BINDINGS")) { + return getBindingStrategyForDraggingBindingElementEndpoints_complex( + arrow, + draggingPoints, + elementsMap, + elements, + appState, + opts, ); } - const hoveredElement = getHoveredElementForBinding( - pointerCoords, - elements, + return getBindingStrategyForDraggingBindingElementEndpoints_simple( + arrow, + draggingPoints, elementsMap, - appState.zoom, - isElbowArrow(linearElement), - isElbowArrow(linearElement), + elements, + appState, + opts, ); - - if (hoveredElement !== null) { - if ( - !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge( - linearElement, - hoveredElement, - "end", - ) - ) { - bindLinearElement(linearElement, hoveredElement, "end", scene); - } - } }; -const normalizePointBinding = ( - binding: { focus: number; gap: number }, - hoveredElement: ExcalidrawBindableElement, -) => ({ - ...binding, - gap: Math.min( - binding.gap, - maxBindingGap(hoveredElement, hoveredElement.width, hoveredElement.height), - ), -}); +const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( + arrow: NonDeleted, + draggingPoints: PointsPositionUpdates, + elementsMap: NonDeletedSceneElementsMap, + elements: readonly Ordered[], + appState: AppState, + opts?: { + newArrow?: boolean; + shiftKey?: boolean; + altKey?: boolean; + finalize?: boolean; + initialBinding?: boolean; + zoom?: AppState["zoom"]; + }, +): { start: BindingStrategy; end: BindingStrategy } => { + const startIdx = 0; + const endIdx = arrow.points.length - 1; + const startDragged = draggingPoints.has(startIdx); + const endDragged = draggingPoints.has(endIdx); -export const bindLinearElement = ( - linearElement: NonDeleted, - hoveredElement: ExcalidrawBindableElement, - startOrEnd: "start" | "end", - scene: Scene, -): void => { - if (!isArrowElement(linearElement)) { - return; + let start: BindingStrategy = { mode: undefined }; + let end: BindingStrategy = { mode: undefined }; + + invariant( + arrow.points.length > 1, + "Do not attempt to bind linear elements with a single point", + ); + + // If none of the ends are dragged, we don't change anything + if (!startDragged && !endDragged) { + return { start, end }; } - let binding: PointBinding | FixedPointBinding = { - elementId: hoveredElement.id, - ...normalizePointBinding( - calculateFocusAndGap( - linearElement, - hoveredElement, - startOrEnd, - scene.getNonDeletedElementsMap(), - ), - hoveredElement, - ), - }; + // If both ends are dragged, we don't bind to anything + // and break existing bindings + if (startDragged && endDragged) { + return { start: { mode: null }, end: { mode: null } }; + } - if (isElbowArrow(linearElement)) { + // If binding is disabled and an endpoint is dragged, + // we actively break the end binding + if (!isBindingEnabled(appState)) { + start = startDragged ? { mode: null } : start; + end = endDragged ? { mode: null } : end; + + return { start, end }; + } + + // Handle simpler elbow arrow binding + if (isElbowArrow(arrow)) { + return bindingStrategyForElbowArrowEndpointDragging( + arrow, + draggingPoints, + elementsMap, + elements, + opts?.zoom, + ); + } + + const otherBinding = startDragged ? arrow.endBinding : arrow.startBinding; + const localPoint = draggingPoints.get( + startDragged ? startIdx : endIdx, + )?.point; + invariant( + localPoint, + `Local point must be defined for ${ + startDragged ? "start" : "end" + } dragging`, + ); + const globalPoint = LinearElementEditor.getPointGlobalCoordinates( + arrow, + localPoint, + elementsMap, + ); + const hit = getHoveredElementForBinding( + globalPoint, + elements, + elementsMap, + (e) => maxBindingDistance_simple(appState.zoom), + ); + const pointInElement = hit && isPointInElement(globalPoint, hit, elementsMap); + const otherBindableElement = otherBinding + ? (elementsMap.get( + otherBinding.elementId, + ) as NonDeleted) + : undefined; + const otherFocusPoint = + otherBinding && + otherBindableElement && + getGlobalFixedPointForBindableElement( + otherBinding.fixedPoint, + otherBindableElement, + elementsMap, + ); + const otherFocusPointIsInElement = + otherBindableElement && + otherFocusPoint && + isPointInElement(otherFocusPoint, otherBindableElement, elementsMap); + + // Handle outside-outside binding to the same element + if (otherBinding && otherBinding.elementId === hit?.id) { + invariant( + !opts?.newArrow || appState.selectedLinearElement?.initialState.origin, + "appState.selectedLinearElement.initialState.origin must be defined for new arrows", + ); + + return { + start: { + mode: "inside", + element: hit, + focusPoint: startDragged + ? globalPoint + : // NOTE: Can only affect the start point because new arrows always drag the end point + opts?.newArrow + ? appState.selectedLinearElement!.initialState.origin! + : LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + 0, + elementsMap, + ), // startFixedPoint, + }, + end: { + mode: "inside", + element: hit, + focusPoint: endDragged + ? globalPoint + : LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + -1, + elementsMap, + ), // endFixedPoint + }, + }; + } + + // Handle special alt key case to inside bind no matter what + if (opts?.altKey) { + return { + start: startDragged + ? hit + ? { + mode: "inside", + element: hit, + focusPoint: globalPoint, + } + : { mode: null } + : start, + end: endDragged + ? hit + ? { + mode: "inside", + element: hit, + focusPoint: globalPoint, + } + : { mode: null } + : end, + }; + } + + // Handle normal cases + const current: BindingStrategy = hit + ? pointInElement + ? { + mode: "inside", + element: hit, + focusPoint: globalPoint, + } + : { + mode: "orbit", + element: hit, + focusPoint: + projectFixedPointOntoDiagonal( + arrow, + globalPoint, + hit, + startDragged ? "start" : "end", + elementsMap, + ) || globalPoint, + } + : { mode: null }; + + const other: BindingStrategy = + otherBindableElement && + !otherFocusPointIsInElement && + appState.selectedLinearElement?.initialState.altFocusPoint + ? { + mode: "orbit", + element: otherBindableElement, + focusPoint: appState.selectedLinearElement.initialState.altFocusPoint, + } + : { mode: undefined }; + + return { + start: startDragged ? current : other, + end: endDragged ? current : other, + }; +}; + +const getBindingStrategyForDraggingBindingElementEndpoints_complex = ( + arrow: NonDeleted, + draggingPoints: PointsPositionUpdates, + elementsMap: NonDeletedSceneElementsMap, + elements: readonly Ordered[], + appState: AppState, + opts?: { + newArrow?: boolean; + shiftKey?: boolean; + finalize?: boolean; + initialBinding?: boolean; + }, +): { start: BindingStrategy; end: BindingStrategy } => { + const globalBindMode = appState.bindMode || "orbit"; + const startIdx = 0; + const endIdx = arrow.points.length - 1; + const startDragged = draggingPoints.has(startIdx); + const endDragged = draggingPoints.has(endIdx); + + let start: BindingStrategy = { mode: undefined }; + let end: BindingStrategy = { mode: undefined }; + + invariant( + arrow.points.length > 1, + "Do not attempt to bind linear elements with a single point", + ); + + // If none of the ends are dragged, we don't change anything + if (!startDragged && !endDragged) { + return { start, end }; + } + + // If both ends are dragged, we don't bind to anything + // and break existing bindings + if (startDragged && endDragged) { + return { start: { mode: null }, end: { mode: null } }; + } + + // If binding is disabled and an endpoint is dragged, + // we actively break the end binding + if (!isBindingEnabled(appState)) { + start = startDragged ? { mode: null } : start; + end = endDragged ? { mode: null } : end; + + return { start, end }; + } + + // Handle simpler elbow arrow binding + if (isElbowArrow(arrow)) { + return bindingStrategyForElbowArrowEndpointDragging( + arrow, + draggingPoints, + elementsMap, + elements, + ); + } + + // Handle new arrow creation separately, as it is special + if (opts?.newArrow) { + const { start, end } = bindingStrategyForNewSimpleArrowEndpointDragging( + arrow, + draggingPoints, + elementsMap, + elements, + startDragged, + endDragged, + startIdx, + endIdx, + appState, + globalBindMode, + opts?.shiftKey, + ); + + return { start, end }; + } + + // Only the start point is dragged + if (startDragged) { + const localPoint = draggingPoints.get(startIdx)?.point; + invariant(localPoint, "Local point must be defined for start dragging"); + const globalPoint = LinearElementEditor.getPointGlobalCoordinates( + arrow, + localPoint, + elementsMap, + ); + + const { current, other } = + bindingStrategyForSimpleArrowEndpointDragging_complex( + globalPoint, + arrow.startBinding, + arrow.endBinding, + elementsMap, + elements, + globalBindMode, + arrow, + opts?.finalize, + ); + + return { start: current, end: other }; + } + + // Only the end point is dragged + if (endDragged) { + const localPoint = draggingPoints.get(endIdx)?.point; + invariant(localPoint, "Local point must be defined for end dragging"); + const globalPoint = LinearElementEditor.getPointGlobalCoordinates( + arrow, + localPoint, + elementsMap, + ); + const { current, other } = + bindingStrategyForSimpleArrowEndpointDragging_complex( + globalPoint, + arrow.endBinding, + arrow.startBinding, + elementsMap, + elements, + globalBindMode, + arrow, + opts?.finalize, + ); + + return { start: other, end: current }; + } + + return { start, end }; +}; + +export const bindOrUnbindBindingElements = ( + selectedArrows: NonDeleted[], + scene: Scene, + appState: AppState, +): void => { + selectedArrows.forEach((arrow) => { + bindOrUnbindBindingElement( + arrow, + new Map(), // No dragging points in this case + scene, + appState, + ); + }); +}; + +export const bindBindingElement = ( + arrow: NonDeleted, + hoveredElement: ExcalidrawBindableElement, + mode: BindMode, + startOrEnd: "start" | "end", + scene: Scene, + focusPoint?: GlobalPoint, +): void => { + const elementsMap = scene.getNonDeletedElementsMap(); + + let binding: FixedPointBinding; + + if (isElbowArrow(arrow)) { binding = { - ...binding, + elementId: hoveredElement.id, + mode: "orbit", ...calculateFixedPointForElbowArrowBinding( - linearElement, + arrow, hoveredElement, startOrEnd, - scene.getNonDeletedElementsMap(), + elementsMap, + ), + }; + } else { + binding = { + elementId: hoveredElement.id, + mode, + ...calculateFixedPointForNonElbowArrowBinding( + arrow, + hoveredElement, + startOrEnd, + elementsMap, + focusPoint, ), }; } - scene.mutateElement(linearElement, { + scene.mutateElement(arrow, { [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding, }); const boundElementsMap = arrayToMap(hoveredElement.boundElements || []); - if (!boundElementsMap.has(linearElement.id)) { + if (!boundElementsMap.has(arrow.id)) { scene.mutateElement(hoveredElement, { boundElements: (hoveredElement.boundElements || []).concat({ - id: linearElement.id, + id: arrow.id, type: "arrow", }), }); } }; -// Don't bind both ends of a simple segment -const isLinearElementSimpleAndAlreadyBoundOnOppositeEdge = ( - linearElement: NonDeleted, - bindableElement: ExcalidrawBindableElement, - startOrEnd: "start" | "end", -): boolean => { - const otherBinding = - linearElement[startOrEnd === "start" ? "endBinding" : "startBinding"]; - return isLinearElementSimpleAndAlreadyBound( - linearElement, - otherBinding?.elementId, - bindableElement, - ); -}; - -export const isLinearElementSimpleAndAlreadyBound = ( - linearElement: NonDeleted, - alreadyBoundToId: ExcalidrawBindableElement["id"] | undefined, - bindableElement: ExcalidrawBindableElement, -): boolean => { - return ( - alreadyBoundToId === bindableElement.id && - isLinearElementSimple(linearElement) - ); -}; - -const isLinearElementSimple = ( - linearElement: NonDeleted, -): boolean => linearElement.points.length < 3 && !isElbowArrow(linearElement); - -const unbindLinearElement = ( - linearElement: NonDeleted, +export const unbindBindingElement = ( + arrow: NonDeleted, startOrEnd: "start" | "end", scene: Scene, ): ExcalidrawBindableElement["id"] | null => { const field = startOrEnd === "start" ? "startBinding" : "endBinding"; - const binding = linearElement[field]; + const binding = arrow[field]; + if (binding == null) { return null; } - scene.mutateElement(linearElement, { [field]: null }); - return binding.elementId; -}; -export const getHoveredElementForBinding = ( - pointerCoords: { - x: number; - y: number; - }, - elements: readonly NonDeletedExcalidrawElement[], - elementsMap: NonDeletedSceneElementsMap, - zoom?: AppState["zoom"], - fullShape?: boolean, - considerAllElements?: boolean, -): NonDeleted | null => { - if (considerAllElements) { - let cullRest = false; - const candidateElements = getAllElementsAtPositionForBinding( - elements, - (element) => - isBindableElement(element, false) && - bindingBorderTest( - element, - pointerCoords, - elementsMap, - zoom, - (fullShape || - !isBindingFallthroughEnabled( - element as ExcalidrawBindableElement, - )) && - // disable fullshape snapping for frame elements so we - // can bind to frame children - !isFrameLikeElement(element), - ), - ).filter((element) => { - if (cullRest) { - return false; - } - - if (!isBindingFallthroughEnabled(element as ExcalidrawBindableElement)) { - cullRest = true; - } - - return true; - }) as NonDeleted[] | null; - - // Return early if there are no candidates or just one candidate - if (!candidateElements || candidateElements.length === 0) { - return null; - } - - if (candidateElements.length === 1) { - return candidateElements[0] as NonDeleted; - } - - // Prefer the shape with the border being tested (if any) - const borderTestElements = candidateElements.filter((element) => - bindingBorderTest(element, pointerCoords, elementsMap, zoom, false), - ); - if (borderTestElements.length === 1) { - return borderTestElements[0]; - } - - // Prefer smaller shapes - return candidateElements - .sort( - (a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2), - ) - .pop() as NonDeleted; - } - - const hoveredElement = getElementAtPositionForBinding( - elements, - (element) => - isBindableElement(element, false) && - bindingBorderTest( - element, - pointerCoords, - elementsMap, - zoom, - // disable fullshape snapping for frame elements so we - // can bind to frame children - (fullShape || !isBindingFallthroughEnabled(element)) && - !isFrameLikeElement(element), + const oppositeBinding = + arrow[startOrEnd === "start" ? "endBinding" : "startBinding"]; + if (!oppositeBinding || oppositeBinding.elementId !== binding.elementId) { + // Only remove the record on the bound element if the other + // end is not bound to the same element + const boundElement = scene + .getNonDeletedElementsMap() + .get(binding.elementId) as ExcalidrawBindableElement; + scene.mutateElement(boundElement, { + boundElements: boundElement.boundElements?.filter( + (element) => element.id !== arrow.id, ), - ); - - return hoveredElement as NonDeleted | null; -}; - -const getElementAtPositionForBinding = ( - elements: readonly NonDeletedExcalidrawElement[], - isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean, -) => { - let hitElement = null; - // 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]; - if (element.isDeleted) { - continue; - } - if (isAtPositionFn(element)) { - hitElement = element; - break; - } + }); } - return hitElement; -}; + scene.mutateElement(arrow, { [field]: null }); -const getAllElementsAtPositionForBinding = ( - elements: readonly NonDeletedExcalidrawElement[], - isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean, -) => { - const elementsAtPosition: NonDeletedExcalidrawElement[] = []; - // 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]; - if (element.isDeleted) { - continue; - } - - if (isAtPositionFn(element)) { - elementsAtPosition.push(element); - } - } - - return elementsAtPosition; -}; - -const calculateFocusAndGap = ( - linearElement: NonDeleted, - hoveredElement: ExcalidrawBindableElement, - startOrEnd: "start" | "end", - elementsMap: NonDeletedSceneElementsMap, -): { focus: number; gap: number } => { - const direction = startOrEnd === "start" ? -1 : 1; - const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; - const adjacentPointIndex = edgePointIndex - direction; - - const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( - linearElement, - edgePointIndex, - elementsMap, - ); - const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( - linearElement, - adjacentPointIndex, - elementsMap, - ); - - return { - focus: determineFocusDistance( - hoveredElement, - elementsMap, - adjacentPoint, - edgePoint, - ), - gap: Math.max(1, distanceToElement(hoveredElement, elementsMap, edgePoint)), - }; + return binding.elementId; }; // Supports translating, rotating and scaling `changedElement` with bound @@ -740,7 +1021,6 @@ export const updateBoundElements = ( scene: Scene, options?: { simultaneouslyUpdated?: readonly ExcalidrawElement[]; - newSize?: { width: number; height: number }; changedElements?: Map; }, ) => { @@ -748,7 +1028,7 @@ export const updateBoundElements = ( return; } - const { newSize, simultaneouslyUpdated } = options ?? {}; + const { simultaneouslyUpdated } = options ?? {}; const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( simultaneouslyUpdated, ); @@ -762,7 +1042,7 @@ export const updateBoundElements = ( } boundElementsVisitor(elementsMap, changedElement, (element) => { - if (!isLinearElement(element) || element.isDeleted) { + if (!isArrowElement(element) || element.isDeleted) { return; } @@ -776,32 +1056,14 @@ export const updateBoundElements = ( ? elementsMap.get(element.startBinding.elementId) : null; const endBindingElement = element.endBinding - ? elementsMap.get(element.endBinding.elementId) + ? // PERF: If the arrow is bound to the same element on both ends. + startBindingElement?.id === element.endBinding.elementId + ? startBindingElement + : elementsMap.get(element.endBinding.elementId) : null; - let startBounds: Bounds | null = null; - let endBounds: Bounds | null = null; - if (startBindingElement && endBindingElement) { - startBounds = getElementBounds(startBindingElement, elementsMap); - endBounds = getElementBounds(endBindingElement, elementsMap); - } - - const bindings = { - startBinding: maybeCalculateNewGapWhenScaling( - changedElement, - element.startBinding, - newSize, - ), - endBinding: maybeCalculateNewGapWhenScaling( - changedElement, - element.endBinding, - newSize, - ), - }; - // `linearElement` is being moved/scaled already, just update the binding if (simultaneouslyUpdatedElementIds.has(element.id)) { - scene.mutateElement(element, bindings); return; } @@ -814,16 +1076,15 @@ export const updateBoundElements = ( isBindableElement(bindableElement) && (bindingProp === "startBinding" || bindingProp === "endBinding") && (changedElement.id === element[bindingProp]?.elementId || - (changedElement.id === + changedElement.id === element[ bindingProp === "startBinding" ? "endBinding" : "startBinding" - ]?.elementId && - !doBoundsIntersect(startBounds, endBounds))) + ]?.elementId) ) { const point = updateBoundPoint( element, bindingProp, - bindings[bindingProp], + element[bindingProp], bindableElement, elementsMap, ); @@ -843,12 +1104,9 @@ export const updateBoundElements = ( ); LinearElementEditor.movePoints(element, scene, new Map(updates), { - ...(changedElement.id === element.startBinding?.elementId - ? { startBinding: bindings.startBinding } - : {}), - ...(changedElement.id === element.endBinding?.elementId - ? { endBinding: bindings.endBinding } - : {}), + moveMidPointsWithElement: + !!startBindingElement && + startBindingElement?.id === endBindingElement?.id, }); const boundText = getBoundTextElement(element, elementsMap); @@ -861,14 +1119,14 @@ export const updateBoundElements = ( export const updateBindings = ( latestElement: ExcalidrawElement, scene: Scene, + appState: AppState, options?: { simultaneouslyUpdated?: readonly ExcalidrawElement[]; newSize?: { width: number; height: number }; - zoom?: AppState["zoom"]; }, ) => { - if (isLinearElement(latestElement)) { - bindOrUnbindLinearElements([latestElement], true, [], scene, options?.zoom); + if (isArrowElement(latestElement)) { + bindOrUnbindBindingElement(latestElement, new Map(), scene, appState); } else { updateBoundElements(latestElement, scene, { ...options, @@ -878,7 +1136,7 @@ export const updateBindings = ( }; const doesNeedUpdate = ( - boundElement: NonDeleted, + boundElement: NonDeleted, changedElement: ExcalidrawBindableElement, ) => { return ( @@ -931,79 +1189,86 @@ const getDistanceForBinding = ( zoom?: AppState["zoom"], ) => { const distance = distanceToElement(bindableElement, elementsMap, point); - const bindDistance = maxBindingGap( - bindableElement, - bindableElement.width, - bindableElement.height, - zoom, - ); + const bindDistance = maxBindingDistance_simple(zoom); return distance > bindDistance ? null : distance; }; export const bindPointToSnapToElementOutline = ( - arrow: ExcalidrawElbowArrowElement, + arrowElement: ExcalidrawArrowElement, bindableElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", elementsMap: ElementsMap, + customIntersector?: LineSegment, ): GlobalPoint => { - if (isDevEnv() || isTestEnv()) { - invariant(arrow.points.length > 1, "Arrow should have at least 2 points"); + const elbowed = isElbowArrow(arrowElement); + const point = + customIntersector && !elbowed + ? customIntersector[0] + : LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrowElement, + startOrEnd === "start" ? 0 : -1, + elementsMap, + ); + + if (arrowElement.points.length < 2) { + // New arrow creation, so no snapping + return point; } - const aabb = aabbForElement(bindableElement, elementsMap); - const localP = - arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1]; - const globalP = pointFrom( - arrow.x + localP[0], - arrow.y + localP[1], - ); const edgePoint = isRectanguloidElement(bindableElement) - ? avoidRectangularCorner(bindableElement, elementsMap, globalP) - : globalP; - const elbowed = isElbowArrow(arrow); - const center = getCenterForBounds(aabb); - const adjacentPointIdx = startOrEnd === "start" ? 1 : arrow.points.length - 2; - const adjacentPoint = pointRotateRads( - pointFrom( - arrow.x + arrow.points[adjacentPointIdx][0], - arrow.y + arrow.points[adjacentPointIdx][1], - ), - center, - arrow.angle ?? 0, - ); + ? avoidRectangularCorner(arrowElement, bindableElement, elementsMap, point) + : point; + const adjacentPoint = + customIntersector && !elbowed + ? customIntersector[1] + : LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrowElement, + startOrEnd === "start" ? 1 : -2, + elementsMap, + ); + const bindingGap = getBindingGap(bindableElement, arrowElement); + const aabb = aabbForElement(bindableElement, elementsMap); + const bindableCenter = getCenterForBounds(aabb); let intersection: GlobalPoint | null = null; if (elbowed) { const isHorizontal = headingIsHorizontal( - headingForPointFromElement(bindableElement, aabb, globalP), + headingForPointFromElement(bindableElement, aabb, point), + ); + const snapPoint = snapToMid( + arrowElement, + bindableElement, + elementsMap, + edgePoint, ); - const snapPoint = snapToMid(bindableElement, elementsMap, edgePoint); const otherPoint = pointFrom( - isHorizontal ? center[0] : snapPoint[0], - !isHorizontal ? center[1] : snapPoint[1], + isHorizontal ? bindableCenter[0] : snapPoint[0], + !isHorizontal ? bindableCenter[1] : snapPoint[1], ); - const intersector = lineSegment( - otherPoint, - pointFromVector( - vectorScale( - vectorNormalize(vectorFromPoint(snapPoint, otherPoint)), - Math.max(bindableElement.width, bindableElement.height) * 2, - ), + const intersector = + customIntersector ?? + lineSegment( otherPoint, - ), - ); + pointFromVector( + vectorScale( + vectorNormalize(vectorFromPoint(snapPoint, otherPoint)), + Math.max(bindableElement.width, bindableElement.height) * 2, + ), + otherPoint, + ), + ); intersection = intersectElementWithLineSegment( bindableElement, elementsMap, intersector, - FIXED_BINDING_DISTANCE, + bindingGap, ).sort(pointDistanceSq)[0]; if (!intersection) { const anotherPoint = pointFrom( - !isHorizontal ? center[0] : snapPoint[0], - isHorizontal ? center[1] : snapPoint[1], + !isHorizontal ? bindableCenter[0] : snapPoint[0], + isHorizontal ? bindableCenter[1] : snapPoint[1], ); const anotherIntersector = lineSegment( anotherPoint, @@ -1019,29 +1284,39 @@ export const bindPointToSnapToElementOutline = ( bindableElement, elementsMap, anotherIntersector, - FIXED_BINDING_DISTANCE, + BASE_BINDING_GAP_ELBOW, ).sort(pointDistanceSq)[0]; } } else { - intersection = intersectElementWithLineSegment( - bindableElement, - elementsMap, - lineSegment( - adjacentPoint, - pointFromVector( - vectorScale( - vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)), - pointDistance(edgePoint, adjacentPoint) + - Math.max(bindableElement.width, bindableElement.height) * 2, - ), - adjacentPoint, - ), - ), - FIXED_BINDING_DISTANCE, - ).sort( - (g, h) => - pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint), - )[0]; + let intersector = customIntersector; + if (!intersector) { + const halfVector = vectorScale( + vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)), + pointDistance(edgePoint, adjacentPoint) + + Math.max(bindableElement.width, bindableElement.height) + + bindingGap * 2, + ); + intersector = + customIntersector ?? + lineSegment( + pointFromVector(halfVector, adjacentPoint), + pointFromVector(vectorScale(halfVector, -1), adjacentPoint), + ); + } + + intersection = + pointDistance(edgePoint, adjacentPoint) < 1 + ? edgePoint + : intersectElementWithLineSegment( + bindableElement, + elementsMap, + intersector, + bindingGap, + ).sort( + (g, h) => + pointDistanceSq(g, adjacentPoint) - + pointDistanceSq(h, adjacentPoint), + )[0]; } if ( @@ -1052,120 +1327,123 @@ export const bindPointToSnapToElementOutline = ( return edgePoint; } - return elbowed ? intersection : edgePoint; + return intersection; }; export const avoidRectangularCorner = ( - element: ExcalidrawBindableElement, + arrowElement: ExcalidrawArrowElement, + bindTarget: ExcalidrawBindableElement, elementsMap: ElementsMap, p: GlobalPoint, ): GlobalPoint => { - const center = elementCenterPoint(element, elementsMap); - const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians); + const center = elementCenterPoint(bindTarget, elementsMap); + const nonRotatedPoint = pointRotateRads( + p, + center, + -bindTarget.angle as Radians, + ); - if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) { + const bindingGap = getBindingGap(bindTarget, arrowElement); + + if (nonRotatedPoint[0] < bindTarget.x && nonRotatedPoint[1] < bindTarget.y) { // Top left - if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) { + if (nonRotatedPoint[1] - bindTarget.y > -bindingGap) { return pointRotateRads( - pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y), + pointFrom(bindTarget.x - bindingGap, bindTarget.y), center, - element.angle, + bindTarget.angle, ); } return pointRotateRads( - pointFrom(element.x, element.y - FIXED_BINDING_DISTANCE), + pointFrom(bindTarget.x, bindTarget.y - bindingGap), center, - element.angle, + bindTarget.angle, ); } else if ( - nonRotatedPoint[0] < element.x && - nonRotatedPoint[1] > element.y + element.height + nonRotatedPoint[0] < bindTarget.x && + nonRotatedPoint[1] > bindTarget.y + bindTarget.height ) { // Bottom left - if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) { + if (nonRotatedPoint[0] - bindTarget.x > -bindingGap) { return pointRotateRads( - pointFrom( - element.x, - element.y + element.height + FIXED_BINDING_DISTANCE, - ), + pointFrom(bindTarget.x, bindTarget.y + bindTarget.height + bindingGap), center, - element.angle, + bindTarget.angle, ); } return pointRotateRads( - pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y + element.height), + pointFrom(bindTarget.x - bindingGap, bindTarget.y + bindTarget.height), center, - element.angle, + bindTarget.angle, ); } else if ( - nonRotatedPoint[0] > element.x + element.width && - nonRotatedPoint[1] > element.y + element.height + nonRotatedPoint[0] > bindTarget.x + bindTarget.width && + nonRotatedPoint[1] > bindTarget.y + bindTarget.height ) { // Bottom right - if ( - nonRotatedPoint[0] - element.x < - element.width + FIXED_BINDING_DISTANCE - ) { + if (nonRotatedPoint[0] - bindTarget.x < bindTarget.width + bindingGap) { return pointRotateRads( pointFrom( - element.x + element.width, - element.y + element.height + FIXED_BINDING_DISTANCE, + bindTarget.x + bindTarget.width, + bindTarget.y + bindTarget.height + bindingGap, ), center, - element.angle, + bindTarget.angle, ); } return pointRotateRads( pointFrom( - element.x + element.width + FIXED_BINDING_DISTANCE, - element.y + element.height, + bindTarget.x + bindTarget.width + bindingGap, + bindTarget.y + bindTarget.height, ), center, - element.angle, + bindTarget.angle, ); } else if ( - nonRotatedPoint[0] > element.x + element.width && - nonRotatedPoint[1] < element.y + nonRotatedPoint[0] > bindTarget.x + bindTarget.width && + nonRotatedPoint[1] < bindTarget.y ) { // Top right - if ( - nonRotatedPoint[0] - element.x < - element.width + FIXED_BINDING_DISTANCE - ) { + if (nonRotatedPoint[0] - bindTarget.x < bindTarget.width + bindingGap) { return pointRotateRads( - pointFrom( - element.x + element.width, - element.y - FIXED_BINDING_DISTANCE, - ), + pointFrom(bindTarget.x + bindTarget.width, bindTarget.y - bindingGap), center, - element.angle, + bindTarget.angle, ); } return pointRotateRads( - pointFrom(element.x + element.width + FIXED_BINDING_DISTANCE, element.y), + pointFrom(bindTarget.x + bindTarget.width + bindingGap, bindTarget.y), center, - element.angle, + bindTarget.angle, ); } return p; }; -export const snapToMid = ( - element: ExcalidrawBindableElement, +const snapToMid = ( + arrowElement: ExcalidrawArrowElement, + bindTarget: ExcalidrawBindableElement, elementsMap: ElementsMap, p: GlobalPoint, tolerance: number = 0.05, ): GlobalPoint => { - const { x, y, width, height, angle } = element; - const center = elementCenterPoint(element, elementsMap, -0.1, -0.1); + 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); + // snap-to-center point is adaptive to element size, but we don't want to go // above and below certain px distance const verticalThreshold = clamp(tolerance * height, 5, 80); const horizontalThreshold = clamp(tolerance * width, 5, 80); + // Too close to the center makes it hard to resolve direction precisely + if (pointDistance(center, nonRotated) < bindingGap) { + return p; + } + if ( nonRotated[0] <= x + width / 2 && nonRotated[1] > center[1] - verticalThreshold && @@ -1173,7 +1451,7 @@ export const snapToMid = ( ) { // LEFT return pointRotateRads( - pointFrom(x - FIXED_BINDING_DISTANCE, center[1]), + pointFrom(x - bindingGap, center[1]), center, angle, ); @@ -1183,11 +1461,7 @@ export const snapToMid = ( nonRotated[0] < center[0] + horizontalThreshold ) { // TOP - return pointRotateRads( - pointFrom(center[0], y - FIXED_BINDING_DISTANCE), - center, - angle, - ); + return pointRotateRads(pointFrom(center[0], y - bindingGap), center, angle); } else if ( nonRotated[0] >= x + width / 2 && nonRotated[1] > center[1] - verticalThreshold && @@ -1195,7 +1469,7 @@ export const snapToMid = ( ) { // RIGHT return pointRotateRads( - pointFrom(x + width + FIXED_BINDING_DISTANCE, center[1]), + pointFrom(x + width + bindingGap, center[1]), center, angle, ); @@ -1206,12 +1480,12 @@ export const snapToMid = ( ) { // DOWN return pointRotateRads( - pointFrom(center[0], y + height + FIXED_BINDING_DISTANCE), + pointFrom(center[0], y + height + bindingGap), center, angle, ); - } else if (element.type === "diamond") { - const distance = FIXED_BINDING_DISTANCE; + } else if (bindTarget.type === "diamond") { + const distance = bindingGap; const topLeft = pointFrom( x + width / 4 - distance, y + height / 4 - distance, @@ -1258,131 +1532,174 @@ export const snapToMid = ( return p; }; -const updateBoundPoint = ( - linearElement: NonDeleted, +const compareElementArea = ( + a: ExcalidrawBindableElement, + b: ExcalidrawBindableElement, +) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2); + +export const updateBoundPoint = ( + arrow: NonDeleted, startOrEnd: "startBinding" | "endBinding", - binding: PointBinding | null | undefined, + binding: FixedPointBinding | null | undefined, bindableElement: ExcalidrawBindableElement, elementsMap: ElementsMap, + customIntersector?: LineSegment, ): 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 && - linearElement.points.length > 2) + (binding.elementId !== bindableElement.id && arrow.points.length > 2) ) { return null; } - const direction = startOrEnd === "startBinding" ? -1 : 1; - const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; - - if (isElbowArrow(linearElement) && isFixedPointBinding(binding)) { - const fixedPoint = - normalizeFixedPoint(binding.fixedPoint) ?? - calculateFixedPointForElbowArrowBinding( - linearElement, - bindableElement, - startOrEnd === "startBinding" ? "start" : "end", - elementsMap, - ).fixedPoint; - const globalMidPoint = elementCenterPoint(bindableElement, elementsMap); - const global = pointFrom( - bindableElement.x + fixedPoint[0] * bindableElement.width, - bindableElement.y + fixedPoint[1] * bindableElement.height, - ); - const rotatedGlobal = pointRotateRads( - global, - globalMidPoint, - bindableElement.angle, - ); - - return LinearElementEditor.pointFromAbsoluteCoords( - linearElement, - rotatedGlobal, - elementsMap, - ); - } - - const adjacentPointIndex = edgePointIndex - direction; - const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( - linearElement, - adjacentPointIndex, - elementsMap, - ); - const focusPointAbsolute = determineFocusPoint( + const global = getGlobalFixedPointForBindableElement( + normalizeFixedPoint(binding.fixedPoint), bindableElement, elementsMap, - binding.focus, - adjacentPoint, ); + 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); - let newEdgePoint: GlobalPoint; - - // The linear element was not originally pointing inside the bound shape, - // we can point directly at the focus point - if (binding.gap === 0) { - newEdgePoint = focusPointAbsolute; - } else { - const edgePointAbsolute = - LinearElementEditor.getPointAtIndexGlobalCoordinates( - linearElement, - edgePointIndex, - elementsMap, - ); - - const center = elementCenterPoint(bindableElement, elementsMap); - const interceptorLength = - pointDistance(adjacentPoint, edgePointAbsolute) + - pointDistance(adjacentPoint, center) + - Math.max(bindableElement.width, bindableElement.height) * 2; - const intersections = [ - ...intersectElementWithLineSegment( - bindableElement, - elementsMap, - lineSegment( - adjacentPoint, - pointFromVector( - vectorScale( - vectorNormalize( - vectorFromPoint(focusPointAbsolute, adjacentPoint), - ), - interceptorLength, - ), - adjacentPoint, - ), - ), - binding.gap, - ).sort( - (g, h) => - pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint), - ), - // Fallback when arrow doesn't point to the shape - pointFromVector( - vectorScale( - vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)), - pointDistance(adjacentPoint, edgePointAbsolute), - ), - adjacentPoint, - ), - ]; - - if (intersections.length > 1) { - // The adjacent point is outside the shape (+ gap) - newEdgePoint = intersections[0]; - } else if (intersections.length === 1) { - // The adjacent point is inside the shape (+ gap) - newEdgePoint = focusPointAbsolute; - } else { - // Shouldn't happend, but just in case - newEdgePoint = edgePointAbsolute; + // 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; } } - return LinearElementEditor.pointFromAbsoluteCoords( - linearElement, - newEdgePoint, + const isNested = (arrowTooShort || isOverlapping) && isLargerThanOther; + + let _customIntersector = customIntersector; + if (!elbowed && !_customIntersector) { + const [x1, y1, x2, y2] = LinearElementEditor.getElementAbsoluteCoords( + arrow, + elementsMap, + ); + const center = pointFrom((x1 + x2) / 2, (y1 + y2) / 2); + const edgePoint = isRectanguloidElement(bindableElement) + ? avoidRectangularCorner(arrow, bindableElement, elementsMap, global) + : global; + const adjacentPoint = pointRotateRads( + pointFrom( + arrow.x + + arrow.points[pointIndex === 0 ? 1 : arrow.points.length - 2][0], + arrow.y + + arrow.points[pointIndex === 0 ? 1 : arrow.points.length - 2][1], + ), + center, + arrow.angle as Radians, + ); + const bindingGap = getBindingGap(bindableElement, arrow); + const halfVector = vectorScale( + vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)), + pointDistance(edgePoint, adjacentPoint) + + Math.max(bindableElement.width, bindableElement.height) + + bindingGap * 2, + ); + _customIntersector = lineSegment( + pointFromVector(halfVector, adjacentPoint), + pointFromVector(vectorScale(halfVector, -1), adjacentPoint), + ); + } + + const maybeOutlineGlobal = + 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; + + return LinearElementEditor.createPointAt( + arrow, elementsMap, + maybeOutlineGlobal[0], + maybeOutlineGlobal[1], + null, ); }; @@ -1424,58 +1741,43 @@ export const calculateFixedPointForElbowArrowBinding = ( }; }; -const maybeCalculateNewGapWhenScaling = ( - changedElement: ExcalidrawBindableElement, - currentBinding: PointBinding | null | undefined, - newSize: { width: number; height: number } | undefined, -): PointBinding | null | undefined => { - if (currentBinding == null || newSize == null) { - return currentBinding; - } - const { width: newWidth, height: newHeight } = newSize; - const { width, height } = changedElement; - const newGap = Math.max( - 1, - Math.min( - maxBindingGap(changedElement, newWidth, newHeight), - currentBinding.gap * - (newWidth < newHeight ? newWidth / width : newHeight / height), - ), - ); - - return { ...currentBinding, gap: newGap }; -}; - -const getEligibleElementForBindingElement = ( - linearElement: NonDeleted, +export const calculateFixedPointForNonElbowArrowBinding = ( + linearElement: NonDeleted, + hoveredElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", - elementsMap: NonDeletedSceneElementsMap, - elements: readonly NonDeletedExcalidrawElement[], - zoom?: AppState["zoom"], -): NonDeleted | null => { - return getHoveredElementForBinding( - getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap), - elements, - elementsMap, - zoom, - isElbowArrow(linearElement), - isElbowArrow(linearElement), - ); -}; + elementsMap: ElementsMap, + focusPoint?: GlobalPoint, +): { fixedPoint: FixedPoint } => { + const edgePoint = focusPoint + ? focusPoint + : LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + startOrEnd === "start" ? 0 : -1, + elementsMap, + ); -const getLinearElementEdgeCoors = ( - linearElement: NonDeleted, - startOrEnd: "start" | "end", - elementsMap: NonDeletedSceneElementsMap, -): { x: number; y: number } => { - const index = startOrEnd === "start" ? 0 : -1; - return tupleToCoors( - LinearElementEditor.getPointAtIndexGlobalCoordinates( - linearElement, - index, - elementsMap, - ), + // Convert the global point to element-local coordinates + const elementCenter = pointFrom( + hoveredElement.x + hoveredElement.width / 2, + hoveredElement.y + hoveredElement.height / 2, ); + + // Rotate the point to account for element rotation + const nonRotatedPoint = pointRotateRads( + edgePoint, + elementCenter, + -hoveredElement.angle as Radians, + ); + + // Calculate the ratio relative to the element's bounds + const fixedPointX = + (nonRotatedPoint[0] - hoveredElement.x) / hoveredElement.width; + const fixedPointY = + (nonRotatedPoint[1] - hoveredElement.y) / hoveredElement.height; + + return { + fixedPoint: normalizeFixedPoint([fixedPointX, fixedPointY]), + }; }; export const fixDuplicatedBindingsAfterDuplication = ( @@ -1591,324 +1893,6 @@ const newBoundElements = ( return nextBoundElements; }; -export const bindingBorderTest = ( - element: NonDeleted, - { x, y }: { x: number; y: number }, - elementsMap: NonDeletedSceneElementsMap, - zoom?: AppState["zoom"], - fullShape?: boolean, -): boolean => { - const p = pointFrom(x, y); - const threshold = maxBindingGap(element, element.width, element.height, zoom); - const shouldTestInside = - // disable fullshape snapping for frame elements so we - // can bind to frame children - (fullShape || !isBindingFallthroughEnabled(element)) && - !isFrameLikeElement(element); - - // PERF: Run a cheap test to see if the binding element - // is even close to the element - const bounds = [ - x - threshold, - y - threshold, - x + threshold, - y + threshold, - ] as Bounds; - const elementBounds = getElementBounds(element, elementsMap); - if (!doBoundsIntersect(bounds, elementBounds)) { - return false; - } - - // Do the intersection test against the element since it's close enough - const intersections = intersectElementWithLineSegment( - element, - elementsMap, - lineSegment(elementCenterPoint(element, elementsMap), p), - ); - const distance = distanceToElement(element, elementsMap, p); - - return shouldTestInside - ? intersections.length === 0 || distance <= threshold - : intersections.length > 0 && distance <= threshold; -}; - -export const maxBindingGap = ( - element: ExcalidrawElement, - elementWidth: number, - elementHeight: number, - zoom?: AppState["zoom"], -): number => { - const zoomValue = zoom?.value && zoom.value < 1 ? zoom.value : 1; - - // Aligns diamonds with rectangles - const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1; - const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight); - - return Math.max( - 16, - // bigger bindable boundary for bigger elements - Math.min(0.25 * smallerDimension, 32), - // keep in sync with the zoomed highlight - BINDING_HIGHLIGHT_THICKNESS / zoomValue + FIXED_BINDING_DISTANCE, - ); -}; - -// The focus distance is the oriented ratio between the size of -// the `element` and the "focus image" of the element on which -// all focus points lie, so it's a number between -1 and 1. -// The line going through `a` and `b` is a tangent to the "focus image" -// of the element. -const determineFocusDistance = ( - element: ExcalidrawBindableElement, - elementsMap: ElementsMap, - // Point on the line, in absolute coordinates - a: GlobalPoint, - // Another point on the line, in absolute coordinates (closer to element) - b: GlobalPoint, -): number => { - const center = elementCenterPoint(element, elementsMap); - - if (pointsEqual(a, b)) { - return 0; - } - - const rotatedA = pointRotateRads(a, center, -element.angle as Radians); - const rotatedB = pointRotateRads(b, center, -element.angle as Radians); - const sign = - Math.sign( - vectorCross( - vectorFromPoint(rotatedB, a), - vectorFromPoint(rotatedB, center), - ), - ) * -1; - const rotatedInterceptor = lineSegment( - rotatedB, - pointFromVector( - vectorScale( - vectorNormalize(vectorFromPoint(rotatedB, rotatedA)), - Math.max(element.width * 2, element.height * 2), - ), - rotatedB, - ), - ); - const axes = - element.type === "diamond" - ? [ - lineSegment( - pointFrom(element.x + element.width / 2, element.y), - pointFrom( - element.x + element.width / 2, - element.y + element.height, - ), - ), - lineSegment( - pointFrom(element.x, element.y + element.height / 2), - pointFrom( - element.x + element.width, - element.y + element.height / 2, - ), - ), - ] - : [ - lineSegment( - pointFrom(element.x, element.y), - pointFrom( - element.x + element.width, - element.y + element.height, - ), - ), - lineSegment( - pointFrom(element.x + element.width, element.y), - pointFrom(element.x, element.y + element.height), - ), - ]; - const interceptees = - element.type === "diamond" - ? [ - lineSegment( - pointFrom( - element.x + element.width / 2, - element.y - element.height, - ), - pointFrom( - element.x + element.width / 2, - element.y + element.height * 2, - ), - ), - lineSegment( - pointFrom( - element.x - element.width, - element.y + element.height / 2, - ), - pointFrom( - element.x + element.width * 2, - element.y + element.height / 2, - ), - ), - ] - : [ - lineSegment( - pointFrom( - element.x - element.width, - element.y - element.height, - ), - pointFrom( - element.x + element.width * 2, - element.y + element.height * 2, - ), - ), - lineSegment( - pointFrom( - element.x + element.width * 2, - element.y - element.height, - ), - pointFrom( - element.x - element.width, - element.y + element.height * 2, - ), - ), - ]; - - const ordered = [ - lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[0]), - lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[1]), - ] - .filter((p): p is GlobalPoint => p !== null) - .sort((g, h) => pointDistanceSq(g, b) - pointDistanceSq(h, b)) - .map( - (p, idx): number => - (sign * pointDistance(center, p)) / - (element.type === "diamond" - ? pointDistance(axes[idx][0], axes[idx][1]) / 2 - : Math.sqrt(element.width ** 2 + element.height ** 2) / 2), - ) - .sort((g, h) => Math.abs(g) - Math.abs(h)); - - const signedDistanceRatio = ordered[0] ?? 0; - - return signedDistanceRatio; -}; - -const determineFocusPoint = ( - element: ExcalidrawBindableElement, - elementsMap: ElementsMap, - // The oriented, relative distance from the center of `element` of the - // returned focusPoint - focus: number, - adjacentPoint: GlobalPoint, -): GlobalPoint => { - const center = elementCenterPoint(element, elementsMap); - - if (focus === 0) { - return center; - } - - const candidates = ( - element.type === "diamond" - ? [ - pointFrom(element.x, element.y + element.height / 2), - pointFrom(element.x + element.width / 2, element.y), - pointFrom( - element.x + element.width, - element.y + element.height / 2, - ), - pointFrom( - element.x + element.width / 2, - element.y + element.height, - ), - ] - : [ - pointFrom(element.x, element.y), - pointFrom(element.x + element.width, element.y), - pointFrom( - element.x + element.width, - element.y + element.height, - ), - pointFrom(element.x, element.y + element.height), - ] - ) - .map((p) => - pointFromVector( - vectorScale(vectorFromPoint(p, center), Math.abs(focus)), - center, - ), - ) - .map((p) => pointRotateRads(p, center, element.angle as Radians)); - - const selected = [ - vectorCross( - vectorFromPoint(adjacentPoint, candidates[0]), - vectorFromPoint(candidates[1], candidates[0]), - ) > 0 && // TOP - (focus > 0 - ? vectorCross( - vectorFromPoint(adjacentPoint, candidates[1]), - vectorFromPoint(candidates[2], candidates[1]), - ) < 0 - : vectorCross( - vectorFromPoint(adjacentPoint, candidates[3]), - vectorFromPoint(candidates[0], candidates[3]), - ) < 0), - vectorCross( - vectorFromPoint(adjacentPoint, candidates[1]), - vectorFromPoint(candidates[2], candidates[1]), - ) > 0 && // RIGHT - (focus > 0 - ? vectorCross( - vectorFromPoint(adjacentPoint, candidates[2]), - vectorFromPoint(candidates[3], candidates[2]), - ) < 0 - : vectorCross( - vectorFromPoint(adjacentPoint, candidates[0]), - vectorFromPoint(candidates[1], candidates[0]), - ) < 0), - vectorCross( - vectorFromPoint(adjacentPoint, candidates[2]), - vectorFromPoint(candidates[3], candidates[2]), - ) > 0 && // BOTTOM - (focus > 0 - ? vectorCross( - vectorFromPoint(adjacentPoint, candidates[3]), - vectorFromPoint(candidates[0], candidates[3]), - ) < 0 - : vectorCross( - vectorFromPoint(adjacentPoint, candidates[1]), - vectorFromPoint(candidates[2], candidates[1]), - ) < 0), - vectorCross( - vectorFromPoint(adjacentPoint, candidates[3]), - vectorFromPoint(candidates[0], candidates[3]), - ) > 0 && // LEFT - (focus > 0 - ? vectorCross( - vectorFromPoint(adjacentPoint, candidates[0]), - vectorFromPoint(candidates[1], candidates[0]), - ) < 0 - : vectorCross( - vectorFromPoint(adjacentPoint, candidates[2]), - vectorFromPoint(candidates[3], candidates[2]), - ) < 0), - ]; - - const focusPoint = selected[0] - ? focus > 0 - ? candidates[1] - : candidates[0] - : selected[1] - ? focus > 0 - ? candidates[2] - : candidates[1] - : selected[2] - ? focus > 0 - ? candidates[3] - : candidates[2] - : focus > 0 - ? candidates[0] - : candidates[3]; - - return focusPoint; -}; - export const bindingProperties: Set = new Set([ "boundElements", "frameId", @@ -2218,7 +2202,7 @@ export class BindableElement { } export const getGlobalFixedPointForBindableElement = ( - fixedPointRatio: [number, number], + fixedPointRatio: FixedPoint, element: ExcalidrawBindableElement, elementsMap: ElementsMap, ): GlobalPoint => { @@ -2235,7 +2219,7 @@ export const getGlobalFixedPointForBindableElement = ( }; export const getGlobalFixedPoints = ( - arrow: ExcalidrawElbowArrowElement, + arrow: ExcalidrawArrowElement, elementsMap: ElementsMap, ): [GlobalPoint, GlobalPoint] => { const startElement = diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index 0f3970db80..fdc851062c 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -1276,6 +1276,13 @@ export const elementCenterPoint = ( xOffset: number = 0, yOffset: number = 0, ) => { + if (isLinearElement(element)) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const [x, y] = pointFrom((x1 + x2) / 2, (y1 + y2) / 2); + + return pointFrom(x + xOffset, y + yOffset); + } + const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap)); return pointFrom(x + xOffset, y + yOffset); diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts index cc15947edb..17dc9b1987 100644 --- a/packages/element/src/collision.ts +++ b/packages/element/src/collision.ts @@ -1,4 +1,4 @@ -import { isTransparent } from "@excalidraw/common"; +import { invariant, isTransparent } from "@excalidraw/common"; import { curveIntersectLineSegment, isPointWithinBounds, @@ -34,10 +34,14 @@ import { elementCenterPoint, getCenterForBounds, getCubicBezierCurveBound, + getDiamondPoints, getElementBounds, + pointInsideBounds, } from "./bounds"; import { hasBoundTextElement, + isBindableElement, + isFrameLikeElement, isFreeDrawElement, isIframeLikeElement, isImageElement, @@ -58,12 +62,17 @@ import { distanceToElement } from "./distance"; import type { ElementsMap, + ExcalidrawBindableElement, ExcalidrawDiamondElement, ExcalidrawElement, ExcalidrawEllipseElement, ExcalidrawFreeDrawElement, ExcalidrawLinearElement, ExcalidrawRectanguloidElement, + NonDeleted, + NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, + Ordered, } from "./types"; export const shouldTestInside = (element: ExcalidrawElement) => { @@ -94,6 +103,7 @@ export type HitTestArgs = { threshold: number; elementsMap: ElementsMap; frameNameBound?: FrameNameBounds | null; + overrideShouldTestInside?: boolean; }; export const hitElementItself = ({ @@ -102,6 +112,7 @@ export const hitElementItself = ({ threshold, elementsMap, frameNameBound = null, + overrideShouldTestInside = false, }: HitTestArgs) => { // Hit test against a frame's name const hitFrameName = frameNameBound @@ -134,7 +145,9 @@ export const hitElementItself = ({ } // Do the precise (and relatively costly) hit test - const hitElement = shouldTestInside(element) + const hitElement = ( + overrideShouldTestInside ? true : shouldTestInside(element) + ) ? // Since `inShape` tests STRICTLY againt the insides of a shape // we would need `onShape` as well to include the "borders" isPointInElement(point, element, elementsMap) || @@ -193,6 +206,116 @@ export const hitElementBoundText = ( return isPointInElement(point, boundTextElement, elementsMap); }; +const bindingBorderTest = ( + element: NonDeleted, + [x, y]: Readonly, + elementsMap: NonDeletedSceneElementsMap, + tolerance: number = 0, +): boolean => { + const p = pointFrom(x, y); + const shouldTestInside = + // disable fullshape snapping for frame elements so we + // can bind to frame children + !isFrameLikeElement(element); + + // PERF: Run a cheap test to see if the binding element + // is even close to the element + const t = Math.max(1, tolerance); + const bounds = [x - t, y - t, x + t, y + t] as Bounds; + const elementBounds = getElementBounds(element, elementsMap); + if (!doBoundsIntersect(bounds, elementBounds)) { + return false; + } + + // If the element is inside a frame, we should clip the element + if (element.frameId) { + const enclosingFrame = elementsMap.get(element.frameId); + if (enclosingFrame && isFrameLikeElement(enclosingFrame)) { + const enclosingFrameBounds = getElementBounds( + enclosingFrame, + elementsMap, + ); + if (!pointInsideBounds(p, enclosingFrameBounds)) { + return false; + } + } + } + + // Do the intersection test against the element since it's close enough + const intersections = intersectElementWithLineSegment( + element, + elementsMap, + lineSegment(elementCenterPoint(element, elementsMap), p), + ); + const distance = distanceToElement(element, elementsMap, p); + + return shouldTestInside + ? intersections.length === 0 || distance <= tolerance + : intersections.length > 0 && distance <= t; +}; + +export const getAllHoveredElementAtPoint = ( + point: Readonly, + elements: readonly Ordered[], + elementsMap: NonDeletedSceneElementsMap, + toleranceFn?: (element: ExcalidrawBindableElement) => number, +): NonDeleted[] => { + const candidateElements: NonDeleted[] = []; + // 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, toleranceFn?.(element)) + ) { + candidateElements.push(element); + + if (!isTransparent(element.backgroundColor)) { + break; + } + } + } + + return candidateElements; +}; + +export const getHoveredElementForBinding = ( + point: Readonly, + elements: readonly Ordered[], + elementsMap: NonDeletedSceneElementsMap, + toleranceFn?: (element: ExcalidrawBindableElement) => number, +): NonDeleted | null => { + const candidateElements = getAllHoveredElementAtPoint( + point, + elements, + elementsMap, + toleranceFn, + ); + + if (!candidateElements || candidateElements.length === 0) { + return null; + } + + if (candidateElements.length === 1) { + return candidateElements[0]; + } + + // Prefer smaller shapes + return candidateElements + .sort( + (a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2), + ) + .pop() as NonDeleted; +}; + /** * Intersect a line with an element for binding test * @@ -554,3 +677,61 @@ export const isPointInElement = ( return intersections.length % 2 === 1; }; + +export const isBindableElementInsideOtherBindable = ( + innerElement: ExcalidrawBindableElement, + outerElement: ExcalidrawBindableElement, + elementsMap: ElementsMap, +): boolean => { + // Get corner points of the inner element based on its type + const getCornerPoints = ( + element: ExcalidrawElement, + offset: number, + ): GlobalPoint[] => { + const { x, y, width, height, angle } = element; + const center = elementCenterPoint(element, elementsMap); + + if (element.type === "diamond") { + // Diamond has 4 corner points at the middle of each side + const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = + getDiamondPoints(element); + const corners: GlobalPoint[] = [ + pointFrom(x + topX, y + topY - offset), // top + pointFrom(x + rightX + offset, y + rightY), // right + pointFrom(x + bottomX, y + bottomY + offset), // bottom + pointFrom(x + leftX - offset, y + leftY), // left + ]; + return corners.map((corner) => pointRotateRads(corner, center, angle)); + } + if (element.type === "ellipse") { + // For ellipse, test points at the extremes (top, right, bottom, left) + const cx = x + width / 2; + const cy = y + height / 2; + const rx = width / 2; + const ry = height / 2; + const corners: GlobalPoint[] = [ + pointFrom(cx, cy - ry - offset), // top + pointFrom(cx + rx + offset, cy), // right + pointFrom(cx, cy + ry + offset), // bottom + pointFrom(cx - rx - offset, cy), // left + ]; + return corners.map((corner) => pointRotateRads(corner, center, angle)); + } + // Rectangle and other rectangular shapes (image, text, etc.) + const corners: GlobalPoint[] = [ + pointFrom(x - offset, y - offset), // top-left + pointFrom(x + width + offset, y - offset), // top-right + pointFrom(x + width + offset, y + height + offset), // bottom-right + pointFrom(x - offset, y + height + offset), // bottom-left + ]; + return corners.map((corner) => pointRotateRads(corner, center, angle)); + }; + + const offset = (-1 * Math.max(innerElement.width, innerElement.height)) / 20; // 5% offset + const innerCorners = getCornerPoints(innerElement, offset); + + // Check if all corner points of the inner element are inside the outer element + return innerCorners.every((corner) => + isPointInElement(corner, outerElement, elementsMap), + ); +}; diff --git a/packages/element/src/dragElements.ts b/packages/element/src/dragElements.ts index 4b17ba20c3..9e82953cc9 100644 --- a/packages/element/src/dragElements.ts +++ b/packages/element/src/dragElements.ts @@ -2,6 +2,7 @@ import { TEXT_AUTOWRAP_THRESHOLD, getGridPoint, getFontString, + DRAGGING_THRESHOLD, } from "@excalidraw/common"; import type { @@ -13,7 +14,7 @@ import type { import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; -import { updateBoundElements } from "./binding"; +import { unbindBindingElement, updateBoundElements } from "./binding"; import { getCommonBounds } from "./bounds"; import { getPerfectElementSize } from "./sizeHelpers"; import { getBoundTextElement } from "./textElement"; @@ -102,9 +103,26 @@ export const dragSelectedElements = ( gridSize, ); + const elementsToUpdateIds = new Set( + Array.from(elementsToUpdate, (el) => el.id), + ); + elementsToUpdate.forEach((element) => { - updateElementCoords(pointerDownState, element, scene, adjustedOffset); + const isArrow = !isArrowElement(element); + const isStartBoundElementSelected = + isArrow || + (element.startBinding + ? elementsToUpdateIds.has(element.startBinding.elementId) + : false); + const isEndBoundElementSelected = + isArrow || + (element.endBinding + ? elementsToUpdateIds.has(element.endBinding.elementId) + : false); + if (!isArrowElement(element)) { + updateElementCoords(pointerDownState, element, scene, adjustedOffset); + // skip arrow labels since we calculate its position during render const textElement = getBoundTextElement( element, @@ -121,6 +139,33 @@ export const dragSelectedElements = ( updateBoundElements(element, scene, { simultaneouslyUpdated: Array.from(elementsToUpdate), }); + } else if ( + // NOTE: Add a little initial drag to the arrow dragging when the arrow + // is the single element being dragged to avoid accidentally unbinding + // the arrow when the user just wants to select it. + + elementsToUpdate.size > 1 || + Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) > + DRAGGING_THRESHOLD || + (!element.startBinding && !element.endBinding) + ) { + updateElementCoords(pointerDownState, element, scene, adjustedOffset); + + const shouldUnbindStart = + element.startBinding && !isStartBoundElementSelected; + const shouldUnbindEnd = element.endBinding && !isEndBoundElementSelected; + if (shouldUnbindStart || shouldUnbindEnd) { + // NOTE: Moving the bound arrow should unbind it, otherwise we would + // have weird situations, like 0 lenght arrow when the user moves + // the arrow outside a filled shape suddenly forcing the arrow start + // and end point to jump "outside" the shape. + if (shouldUnbindStart) { + unbindBindingElement(element, "start", scene); + } + if (shouldUnbindEnd) { + unbindBindingElement(element, "end", scene); + } + } } }); }; diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index b988eb25bb..d01648e490 100644 --- a/packages/element/src/elbowArrow.ts +++ b/packages/element/src/elbowArrow.ts @@ -17,7 +17,6 @@ import { BinaryHeap, invariant, isAnyTrue, - tupleToCoors, getSizeFromPoints, isDevEnv, arrayToMap, @@ -27,10 +26,11 @@ import type { AppState } from "@excalidraw/excalidraw/types"; import { bindPointToSnapToElementOutline, - FIXED_BINDING_DISTANCE, getHeadingForElbowArrowSnap, getGlobalFixedPointForBindableElement, - getHoveredElementForBinding, + getBindingGap, + maxBindingDistance_simple, + BASE_BINDING_GAP_ELBOW, } from "./binding"; import { distanceToElement } from "./distance"; import { @@ -51,8 +51,8 @@ import { type ExcalidrawElbowArrowElement, type NonDeletedSceneElementsMap, } from "./types"; - import { aabbForElement, pointInsideBounds } from "./bounds"; +import { getHoveredElementForBinding } from "./collision"; import type { Bounds } from "./bounds"; import type { Heading } from "./heading"; @@ -63,6 +63,7 @@ import type { FixedPointBinding, FixedSegment, NonDeletedExcalidrawElement, + Ordered, } from "./types"; type GridAddress = [number, number] & { _brand: "gridaddress" }; @@ -1243,6 +1244,7 @@ const getElbowArrowData = ( const startGlobalPoint = getGlobalPoint( { ...arrow, + angle: 0, type: "arrow", elbowed: true, points: nextPoints, @@ -1257,6 +1259,7 @@ const getElbowArrowData = ( const endGlobalPoint = getGlobalPoint( { ...arrow, + angle: 0, type: "arrow", elbowed: true, points: nextPoints, @@ -1274,6 +1277,7 @@ const getElbowArrowData = ( hoveredStartElement, origStartGlobalPoint, elementsMap, + options?.zoom, ); const endHeading = getBindPointHeading( endGlobalPoint, @@ -1281,6 +1285,7 @@ const getElbowArrowData = ( hoveredEndElement, origEndGlobalPoint, elementsMap, + options?.zoom, ); const startPointBounds = [ startGlobalPoint[0] - 2, @@ -1301,8 +1306,8 @@ const getElbowArrowData = ( offsetFromHeading( startHeading, arrow.startArrowhead - ? FIXED_BINDING_DISTANCE * 6 - : FIXED_BINDING_DISTANCE * 2, + ? getBindingGap(hoveredStartElement, { elbowed: true }) * 6 + : getBindingGap(hoveredStartElement, { elbowed: true }) * 2, 1, ), ) @@ -1314,8 +1319,8 @@ const getElbowArrowData = ( offsetFromHeading( endHeading, arrow.endArrowhead - ? FIXED_BINDING_DISTANCE * 6 - : FIXED_BINDING_DISTANCE * 2, + ? getBindingGap(hoveredEndElement, { elbowed: true }) * 6 + : getBindingGap(hoveredEndElement, { elbowed: true }) * 2, 1, ), ) @@ -1362,8 +1367,8 @@ const getElbowArrowData = ( ? 0 : BASE_PADDING - (arrow.startArrowhead - ? FIXED_BINDING_DISTANCE * 6 - : FIXED_BINDING_DISTANCE * 2), + ? BASE_BINDING_GAP_ELBOW * 6 + : BASE_BINDING_GAP_ELBOW * 2), BASE_PADDING, ), boundsOverlap @@ -1378,8 +1383,8 @@ const getElbowArrowData = ( ? 0 : BASE_PADDING - (arrow.endArrowhead - ? FIXED_BINDING_DISTANCE * 6 - : FIXED_BINDING_DISTANCE * 2), + ? BASE_BINDING_GAP_ELBOW * 6 + : BASE_BINDING_GAP_ELBOW * 2), BASE_PADDING, ), boundsOverlap, @@ -2239,6 +2244,7 @@ const getBindPointHeading = ( hoveredElement: ExcalidrawBindableElement | null | undefined, origPoint: GlobalPoint, elementsMap: ElementsMap, + zoom?: AppState["zoom"], ): Heading => getHeadingForElbowArrowSnap( p, @@ -2257,21 +2263,20 @@ const getBindPointHeading = ( ), origPoint, elementsMap, + zoom, ); const getHoveredElement = ( origPoint: GlobalPoint, elementsMap: NonDeletedSceneElementsMap, - elements: readonly NonDeletedExcalidrawElement[], + elements: readonly Ordered[], zoom?: AppState["zoom"], ) => { return getHoveredElementForBinding( - tupleToCoors(origPoint), + origPoint, elements, elementsMap, - zoom, - true, - true, + (element) => maxBindingDistance_simple(zoom), ); }; diff --git a/packages/element/src/flowchart.ts b/packages/element/src/flowchart.ts index 6cffb56a83..daa98ed397 100644 --- a/packages/element/src/flowchart.ts +++ b/packages/element/src/flowchart.ts @@ -7,7 +7,7 @@ import type { PendingExcalidrawElements, } from "@excalidraw/excalidraw/types"; -import { bindLinearElement } from "./binding"; +import { bindBindingElement } from "./binding"; import { updateElbowArrowPoints } from "./elbowArrow"; import { HEADING_DOWN, @@ -446,8 +446,14 @@ const createBindingArrow = ( const elementsMap = scene.getNonDeletedElementsMap(); - bindLinearElement(bindingArrow, startBindingElement, "start", scene); - bindLinearElement(bindingArrow, endBindingElement, "end", scene); + bindBindingElement( + bindingArrow, + startBindingElement, + "orbit", + "start", + scene, + ); + bindBindingElement(bindingArrow, endBindingElement, "orbit", "end", scene); const changedElements = new Map(); changedElements.set( diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index d677859ad5..a365c517de 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -1,7 +1,6 @@ import { toIterable } from "@excalidraw/common"; import { isInvisiblySmallElement } from "./sizeHelpers"; -import { isLinearElementType } from "./typeChecks"; import type { ExcalidrawElement, @@ -55,27 +54,6 @@ export const isNonDeletedElement = ( element: T, ): element is NonDeleted => !element.isDeleted; -const _clearElements = ( - elements: readonly ExcalidrawElement[], -): ExcalidrawElement[] => - getNonDeletedElements(elements).map((element) => - isLinearElementType(element.type) - ? { ...element, lastCommittedPoint: null } - : element, - ); - -export const clearElementsForDatabase = ( - elements: readonly ExcalidrawElement[], -) => _clearElements(elements); - -export const clearElementsForExport = ( - elements: readonly ExcalidrawElement[], -) => _clearElements(elements); - -export const clearElementsForLocalStorage = ( - elements: readonly ExcalidrawElement[], -) => _clearElements(elements); - export * from "./align"; export * from "./binding"; export * from "./bounds"; diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 995d866b54..7e7f662786 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -9,6 +9,7 @@ import { vectorFromPoint, curveLength, curvePointAtLength, + lineSegment, } from "@excalidraw/math"; import { getCurvePathOps } from "@excalidraw/utils/shape"; @@ -19,13 +20,16 @@ import { shouldRotateWithDiscreteAngle, getGridPoint, invariant, - tupleToCoors, - viewportCoordsToSceneCoords, + isShallowEqual, + getFeatureFlag, } from "@excalidraw/common"; import { deconstructLinearOrFreeDrawElement, + getHoveredElementForBinding, isPathALoop, + moveArrowAboveBindable, + projectFixedPointOntoDiagonal, type Store, } from "@excalidraw/element"; @@ -40,13 +44,11 @@ import type { Zoom, } from "@excalidraw/excalidraw/types"; -import type { Mutable } from "@excalidraw/common/utility-types"; - import { - bindOrUnbindLinearElement, - getHoveredElementForBinding, + calculateFixedPointForNonElbowArrowBinding, + getBindingStrategyForDraggingBindingElementEndpoints, isBindingEnabled, - maybeSuggestBindingsForLinearElementAtCoords, + updateBoundPoint, } from "./binding"; import { getElementAbsoluteCoords, @@ -57,11 +59,7 @@ import { import { headingIsHorizontal, vectorToHeading } from "./heading"; import { mutateElement } from "./mutateElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement"; -import { - isBindingElement, - isElbowArrow, - isFixedPointBinding, -} from "./typeChecks"; +import { isArrowElement, isBindingElement, isElbowArrow } from "./typeChecks"; import { ShapeCache, toggleLinePolygonState } from "./shape"; @@ -76,8 +74,6 @@ import type { NonDeleted, ExcalidrawLinearElement, ExcalidrawElement, - PointBinding, - ExcalidrawBindableElement, ExcalidrawTextElementWithContainer, ElementsMap, NonDeletedSceneElementsMap, @@ -85,6 +81,9 @@ import type { FixedSegment, ExcalidrawElbowArrowElement, PointsPositionUpdates, + NonDeletedExcalidrawElement, + Ordered, + ExcalidrawBindableElement, } from "./types"; /** @@ -116,6 +115,13 @@ const getNormalizedPoints = ({ }; }; +type PointMoveOtherUpdates = { + startBinding?: FixedPointBinding | null; + endBinding?: FixedPointBinding | null; + moveMidPointsWithElement?: boolean | null; + suggestedBinding?: AppState["suggestedBinding"] | null; +}; + export class LinearElementEditor { public readonly elementId: ExcalidrawElement["id"] & { _brand: "excalidrawLinearElementId"; @@ -123,34 +129,36 @@ export class LinearElementEditor { /** indices */ public readonly selectedPointsIndices: readonly number[] | null; - public readonly pointerDownState: Readonly<{ + public readonly initialState: Readonly<{ prevSelectedPointsIndices: readonly number[] | null; /** index */ lastClickedPoint: number; - lastClickedIsEndPoint: boolean; - origin: Readonly<{ x: number; y: number }> | null; + origin: Readonly | null; segmentMidpoint: { value: GlobalPoint | null; index: number | null; added: boolean; }; + arrowStartIsInside: boolean; + altFocusPoint: Readonly | null; }>; /** whether you're dragging a point */ public readonly isDragging: boolean; public readonly lastUncommittedPoint: LocalPoint | null; + public readonly lastCommittedPoint: LocalPoint | null; public readonly pointerOffset: Readonly<{ x: number; y: number }>; - public readonly startBindingElement: - | ExcalidrawBindableElement - | null - | "keep"; - public readonly endBindingElement: ExcalidrawBindableElement | null | "keep"; public readonly hoverPointIndex: number; public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly elbowed: boolean; public readonly customLineAngle: number | null; public readonly isEditing: boolean; + // @deprecated renamed to initialState because the data is used during linear + // element click creation as well (with multiple pointer down events) + // @ts-ignore + public readonly pointerDownState: never; + constructor( element: NonDeleted, elementsMap: ElementsMap, @@ -169,14 +177,12 @@ export class LinearElementEditor { } this.selectedPointsIndices = null; this.lastUncommittedPoint = null; + this.lastCommittedPoint = null; this.isDragging = false; this.pointerOffset = { x: 0, y: 0 }; - this.startBindingElement = "keep"; - this.endBindingElement = "keep"; - this.pointerDownState = { + this.initialState = { prevSelectedPointsIndices: null, lastClickedPoint: -1, - lastClickedIsEndPoint: false, origin: null, segmentMidpoint: { @@ -184,6 +190,8 @@ export class LinearElementEditor { index: null, added: false, }, + arrowStartIsInside: false, + altFocusPoint: null, }; this.hoverPointIndex = -1; this.segmentMidPointHoveredCoords = null; @@ -276,222 +284,381 @@ export class LinearElementEditor { }); } - /** - * @returns whether point was dragged - */ + static handlePointerMove( + event: PointerEvent, + app: AppClassProperties, + scenePointerX: number, + scenePointerY: number, + linearElementEditor: LinearElementEditor, + ): Pick | null { + const elementsMap = app.scene.getNonDeletedElementsMap(); + const elements = app.scene.getNonDeletedElements(); + const { elementId } = linearElementEditor; + + const element = LinearElementEditor.getElement(elementId, elementsMap); + + invariant(element, "Element being dragged must exist in the scene"); + invariant(element.points.length > 1, "Element must have at least 2 points"); + + const idx = element.points.length - 1; + const point = element.points[idx]; + const pivotPoint = element.points[idx - 1]; + const customLineAngle = + linearElementEditor.customLineAngle ?? + determineCustomLinearAngle(pivotPoint, element.points[idx]); + const hoveredElement = getHoveredElementForBinding( + pointFrom(scenePointerX, scenePointerY), + elements, + elementsMap, + ); + + // Determine if point movement should happen and how much + let deltaX = 0; + let deltaY = 0; + if ( + shouldRotateWithDiscreteAngle(event) && + !hoveredElement && + !element.startBinding && + !element.endBinding + ) { + const [width, height] = LinearElementEditor._getShiftLockedDelta( + element, + elementsMap, + pivotPoint, + pointFrom(scenePointerX, scenePointerY), + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + customLineAngle, + ); + const target = pointFrom( + width + pivotPoint[0], + height + pivotPoint[1], + ); + + deltaX = target[0] - point[0]; + deltaY = target[1] - point[1]; + } else { + const newDraggingPointPosition = LinearElementEditor.createPointAt( + element, + elementsMap, + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + ); + deltaX = newDraggingPointPosition[0] - point[0]; + deltaY = newDraggingPointPosition[1] - point[1]; + } + + // Apply the point movement if needed + let suggestedBinding: AppState["suggestedBinding"] = null; + const { positions, updates } = pointDraggingUpdates( + [idx], + deltaX, + deltaY, + elementsMap, + element, + elements, + app, + event.shiftKey, + event.altKey, + ); + + LinearElementEditor.movePoints(element, app.scene, positions, { + startBinding: updates?.startBinding, + endBinding: updates?.endBinding, + moveMidPointsWithElement: updates?.moveMidPointsWithElement, + }); + // Set the suggested binding from the updates if available + if (isBindingElement(element, false)) { + if (isBindingEnabled(app.state)) { + suggestedBinding = updates?.suggestedBinding ?? null; + } + } + + // Move the arrow over the bindable object in terms of z-index + if (isBindingElement(element)) { + moveArrowAboveBindable( + LinearElementEditor.getPointGlobalCoordinates( + element, + element.points[element.points.length - 1], + elementsMap, + ), + element, + elements, + elementsMap, + app.scene, + ); + } + + // PERF: Avoid state updates if not absolutely necessary + if ( + app.state.selectedLinearElement?.customLineAngle === customLineAngle && + linearElementEditor.initialState.altFocusPoint && + (!suggestedBinding || + isShallowEqual(app.state.suggestedBinding ?? [], suggestedBinding)) + ) { + return null; + } + + const startBindingElement = + isBindingElement(element) && + element.startBinding && + (elementsMap.get( + element.startBinding.elementId, + ) as ExcalidrawBindableElement | null); + const newLinearElementEditor = { + ...linearElementEditor, + customLineAngle, + initialState: { + ...linearElementEditor.initialState, + altFocusPoint: + !linearElementEditor.initialState.altFocusPoint && + startBindingElement && + updates?.suggestedBinding?.id !== startBindingElement.id + ? projectFixedPointOntoDiagonal( + element, + pointFrom(element.x, element.y), + startBindingElement, + "start", + elementsMap, + ) + : linearElementEditor.initialState.altFocusPoint, + }, + }; + + return { + selectedLinearElement: newLinearElementEditor, + suggestedBinding, + }; + } + static handlePointDragging( event: PointerEvent, app: AppClassProperties, scenePointerX: number, scenePointerY: number, linearElementEditor: LinearElementEditor, - ): Pick | null { - if (!linearElementEditor) { - return null; - } - const { elementId } = linearElementEditor; + ): Pick | null { const elementsMap = app.scene.getNonDeletedElementsMap(); + const elements = app.scene.getNonDeletedElements(); + const { elbowed, elementId, initialState } = linearElementEditor; + const selectedPointsIndices = Array.from( + linearElementEditor.selectedPointsIndices ?? [], + ); + let { lastClickedPoint } = initialState; const element = LinearElementEditor.getElement(elementId, elementsMap); - let customLineAngle = linearElementEditor.customLineAngle; - if (!element) { - return null; + + invariant(element, "Element being dragged must exist in the scene"); + + invariant(element.points.length > 1, "Element must have at least 2 points"); + + invariant( + selectedPointsIndices, + "There must be selected points in order to drag them", + ); + + if (elbowed) { + selectedPointsIndices.some((pointIdx, idx) => { + if (pointIdx > 0 && pointIdx !== element.points.length - 1) { + selectedPointsIndices[idx] = element.points.length - 1; + lastClickedPoint = element.points.length - 1; + return true; + } + + return false; + }); } - if ( - isElbowArrow(element) && - !linearElementEditor.pointerDownState.lastClickedIsEndPoint && - linearElementEditor.pointerDownState.lastClickedPoint !== 0 - ) { - return null; - } - - const selectedPointsIndices = isElbowArrow(element) - ? [ - !!linearElementEditor.selectedPointsIndices?.includes(0) - ? 0 - : undefined, - !!linearElementEditor.selectedPointsIndices?.find((idx) => idx > 0) - ? element.points.length - 1 - : undefined, - ].filter((idx): idx is number => idx !== undefined) - : linearElementEditor.selectedPointsIndices; - const lastClickedPoint = isElbowArrow(element) - ? linearElementEditor.pointerDownState.lastClickedPoint > 0 - ? element.points.length - 1 - : 0 - : linearElementEditor.pointerDownState.lastClickedPoint; + invariant( + lastClickedPoint > -1 && + selectedPointsIndices.includes(lastClickedPoint) && + element.points[lastClickedPoint], + `There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify( + selectedPointsIndices, + )}) points(0..${ + element.points.length - 1 + }) lastClickedPoint(${lastClickedPoint})`, + ); // point that's being dragged (out of all selected points) const draggingPoint = element.points[lastClickedPoint]; + // The adjacent point to the one dragged point + const pivotPoint = + element.points[lastClickedPoint === 0 ? 1 : lastClickedPoint - 1]; + const singlePointDragged = selectedPointsIndices.length === 1; + const customLineAngle = + linearElementEditor.customLineAngle ?? + determineCustomLinearAngle(pivotPoint, element.points[lastClickedPoint]); + const startIsSelected = selectedPointsIndices.includes(0); + const endIsSelected = selectedPointsIndices.includes( + element.points.length - 1, + ); + const hoveredElement = getHoveredElementForBinding( + pointFrom(scenePointerX, scenePointerY), + elements, + elementsMap, + ); - if (selectedPointsIndices && draggingPoint) { - if ( - shouldRotateWithDiscreteAngle(event) && - selectedPointsIndices.length === 1 && - element.points.length > 1 - ) { - const selectedIndex = selectedPointsIndices[0]; - const referencePoint = - element.points[selectedIndex === 0 ? 1 : selectedIndex - 1]; - customLineAngle = - linearElementEditor.customLineAngle ?? - Math.atan2( - element.points[selectedIndex][1] - referencePoint[1], - element.points[selectedIndex][0] - referencePoint[0], - ); - - const [width, height] = LinearElementEditor._getShiftLockedDelta( - element, - elementsMap, - referencePoint, - pointFrom(scenePointerX, scenePointerY), - event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), - customLineAngle, - ); - - LinearElementEditor.movePoints( - element, - app.scene, - new Map([ - [ - selectedIndex, - { - point: pointFrom( - width + referencePoint[0], - height + referencePoint[1], - ), - isDragging: selectedIndex === lastClickedPoint, - }, - ], - ]), - ); - } else { - const newDraggingPointPosition = LinearElementEditor.createPointAt( - element, - elementsMap, - scenePointerX - linearElementEditor.pointerOffset.x, - scenePointerY - linearElementEditor.pointerOffset.y, - event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), - ); - - const deltaX = newDraggingPointPosition[0] - draggingPoint[0]; - const deltaY = newDraggingPointPosition[1] - draggingPoint[1]; - - LinearElementEditor.movePoints( - element, - app.scene, - new Map( - selectedPointsIndices.map((pointIndex) => { - const newPointPosition: LocalPoint = - pointIndex === lastClickedPoint - ? LinearElementEditor.createPointAt( - element, - elementsMap, - scenePointerX - linearElementEditor.pointerOffset.x, - scenePointerY - linearElementEditor.pointerOffset.y, - event[KEYS.CTRL_OR_CMD] - ? null - : app.getEffectiveGridSize(), - ) - : pointFrom( - element.points[pointIndex][0] + deltaX, - element.points[pointIndex][1] + deltaY, - ); - return [ - pointIndex, - { - point: newPointPosition, - isDragging: pointIndex === lastClickedPoint, - }, - ]; - }), - ), - ); - } - - const boundTextElement = getBoundTextElement(element, elementsMap); - if (boundTextElement) { - handleBindTextResize(element, app.scene, false); - } - - // suggest bindings for first and last point if selected - let suggestedBindings: ExcalidrawBindableElement[] = []; - if (isBindingElement(element, false)) { - const firstSelectedIndex = selectedPointsIndices[0] === 0; - const lastSelectedIndex = - selectedPointsIndices[selectedPointsIndices.length - 1] === - element.points.length - 1; - const coords: { x: number; y: number }[] = []; - - if (!firstSelectedIndex !== !lastSelectedIndex) { - coords.push({ x: scenePointerX, y: scenePointerY }); - } else { - if (firstSelectedIndex) { - coords.push( - tupleToCoors( - LinearElementEditor.getPointGlobalCoordinates( - element, - element.points[0], - elementsMap, - ), - ), - ); - } - - if (lastSelectedIndex) { - coords.push( - tupleToCoors( - LinearElementEditor.getPointGlobalCoordinates( - element, - element.points[ - selectedPointsIndices[selectedPointsIndices.length - 1] - ], - elementsMap, - ), - ), - ); - } - } - - if (coords.length) { - suggestedBindings = maybeSuggestBindingsForLinearElementAtCoords( - element, - coords, - app.scene, - app.state.zoom, - ); - } - } - - const newLinearElementEditor = { - ...linearElementEditor, - selectedPointsIndices, - segmentMidPointHoveredCoords: - lastClickedPoint !== 0 && - lastClickedPoint !== element.points.length - 1 - ? this.getPointGlobalCoordinates( - element, - draggingPoint, - elementsMap, - ) - : null, - hoverPointIndex: - lastClickedPoint === 0 || - lastClickedPoint === element.points.length - 1 - ? lastClickedPoint - : -1, - isDragging: true, + // Determine if point movement should happen and how much + let deltaX = 0; + let deltaY = 0; + if ( + shouldRotateWithDiscreteAngle(event) && + singlePointDragged && + !hoveredElement && + !element.startBinding && + !element.endBinding + ) { + const [width, height] = LinearElementEditor._getShiftLockedDelta( + element, + elementsMap, + pivotPoint, + pointFrom(scenePointerX, scenePointerY), + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), customLineAngle, - }; + ); + const target = pointFrom( + width + pivotPoint[0], + height + pivotPoint[1], + ); - return { - ...app.state, - selectedLinearElement: newLinearElementEditor, - suggestedBindings, - }; + deltaX = target[0] - draggingPoint[0]; + deltaY = target[1] - draggingPoint[1]; + } else { + const newDraggingPointPosition = LinearElementEditor.createPointAt( + element, + elementsMap, + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + ); + deltaX = newDraggingPointPosition[0] - draggingPoint[0]; + deltaY = newDraggingPointPosition[1] - draggingPoint[1]; } - return null; + // Apply the point movement if needed + let suggestedBinding: AppState["suggestedBinding"] = null; + const { positions, updates } = pointDraggingUpdates( + selectedPointsIndices, + deltaX, + deltaY, + elementsMap, + element, + elements, + app, + event.shiftKey, + event.altKey, + ); + + LinearElementEditor.movePoints(element, app.scene, positions, { + startBinding: updates?.startBinding, + endBinding: updates?.endBinding, + moveMidPointsWithElement: updates?.moveMidPointsWithElement, + }); + + // Set the suggested binding from the updates if available + if (isBindingElement(element, false)) { + if (isBindingEnabled(app.state) && (startIsSelected || endIsSelected)) { + suggestedBinding = updates?.suggestedBinding ?? null; + } + } + + // Move the arrow over the bindable object in terms of z-index + if (isBindingElement(element) && startIsSelected !== endIsSelected) { + moveArrowAboveBindable( + LinearElementEditor.getPointGlobalCoordinates( + element, + startIsSelected + ? element.points[0] + : element.points[element.points.length - 1], + elementsMap, + ), + element, + elements, + elementsMap, + app.scene, + ); + } + + // Attached text might need to update if arrow dimensions change + const boundTextElement = getBoundTextElement(element, elementsMap); + if (boundTextElement) { + handleBindTextResize(element, app.scene, false); + } + + // Update selected points for elbow arrows because elbow arrows add and + // remove points as they route + const newSelectedPointsIndices = elbowed + ? endIsSelected + ? [element.points.length - 1] + : [0] + : selectedPointsIndices; + + const newLastClickedPoint = elbowed + ? newSelectedPointsIndices[0] + : lastClickedPoint; + + const newSelectedMidPointHoveredCoords = + !startIsSelected && !endIsSelected + ? LinearElementEditor.getPointGlobalCoordinates( + element, + draggingPoint, + elementsMap, + ) + : null; + + const newHoverPointIndex = newLastClickedPoint; + const startBindingElement = + isBindingElement(element) && + element.startBinding && + (elementsMap.get( + element.startBinding.elementId, + ) as ExcalidrawBindableElement | null); + const endBindingElement = + isBindingElement(element) && + element.endBinding && + (elementsMap.get( + element.endBinding.elementId, + ) as ExcalidrawBindableElement | null); + 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 + ? 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 + ? endBindingElement + : null; + + const newLinearElementEditor: LinearElementEditor = { + ...linearElementEditor, + selectedPointsIndices: newSelectedPointsIndices, + initialState: { + ...linearElementEditor.initialState, + lastClickedPoint: newLastClickedPoint, + altFocusPoint: + !linearElementEditor.initialState.altFocusPoint && // We only set it once per arrow drag + isBindingElement(element) && + altFocusPointBindableElement + ? projectFixedPointOntoDiagonal( + element, + pointFrom(element.x, element.y), + altFocusPointBindableElement, + "start", + elementsMap, + ) + : linearElementEditor.initialState.altFocusPoint, + }, + segmentMidPointHoveredCoords: newSelectedMidPointHoveredCoords, + hoverPointIndex: newHoverPointIndex, + isDragging: true, + customLineAngle, + }; + + return { + selectedLinearElement: newLinearElementEditor, + suggestedBinding, + }; } static handlePointerUp( @@ -501,25 +668,18 @@ export class LinearElementEditor { scene: Scene, ): LinearElementEditor { const elementsMap = scene.getNonDeletedElementsMap(); - const elements = scene.getNonDeletedElements(); - const pointerCoords = viewportCoordsToSceneCoords(event, appState); - const { elementId, selectedPointsIndices, isDragging, pointerDownState } = - editingLinearElement; + const { + elementId, + selectedPointsIndices, + isDragging, + initialState: pointerDownState, + } = editingLinearElement; const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return editingLinearElement; } - const bindings: Mutable< - Partial< - Pick< - InstanceType, - "startBindingElement" | "endBindingElement" - > - > - > = {}; - if (isDragging && selectedPointsIndices) { for (const selectedPoint of selectedPointsIndices) { if ( @@ -555,36 +715,12 @@ export class LinearElementEditor { ]), ); } - - const bindingElement = isBindingEnabled(appState) - ? getHoveredElementForBinding( - (selectedPointsIndices?.length ?? 0) > 1 - ? tupleToCoors( - LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - selectedPoint!, - elementsMap, - ), - ) - : pointerCoords, - elements, - elementsMap, - appState.zoom, - isElbowArrow(element), - isElbowArrow(element), - ) - : null; - - bindings[ - selectedPoint === 0 ? "startBindingElement" : "endBindingElement" - ] = bindingElement; } } } return { ...editingLinearElement, - ...bindings, segmentMidPointHoveredCoords: null, hoverPointIndex: -1, // if clicking without previously dragging a point(s), and not holding @@ -609,6 +745,11 @@ export class LinearElementEditor { isDragging: false, pointerOffset: { x: 0, y: 0 }, customLineAngle: null, + initialState: { + ...editingLinearElement.initialState, + origin: null, + arrowStartIsInside: false, + }, }; } @@ -853,7 +994,6 @@ export class LinearElementEditor { } { const appState = app.state; const elementsMap = scene.getNonDeletedElementsMap(); - const elements = scene.getNonDeletedElements(); const ret: ReturnType = { didAddPoint: false, @@ -871,13 +1011,16 @@ export class LinearElementEditor { if (!element) { return ret; } + const segmentMidpoint = LinearElementEditor.getSegmentMidpointHitCoords( linearElementEditor, scenePointer, appState, elementsMap, ); + const point = pointFrom(scenePointer.x, scenePointer.y); let segmentMidpointIndex = null; + if (segmentMidpoint) { segmentMidpointIndex = LinearElementEditor.getSegmentMidPointIndex( linearElementEditor, @@ -904,29 +1047,26 @@ export class LinearElementEditor { store.scheduleCapture(); ret.linearElementEditor = { ...linearElementEditor, - pointerDownState: { + initialState: { prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices, lastClickedPoint: -1, - lastClickedIsEndPoint: false, - origin: { x: scenePointer.x, y: scenePointer.y }, + origin: point, segmentMidpoint: { value: segmentMidpoint, index: segmentMidpointIndex, added: false, }, + arrowStartIsInside: + !!app.state.newElement && + (app.state.bindMode === "inside" || app.state.bindMode === "skip"), + altFocusPoint: null, }, selectedPointsIndices: [element.points.length - 1], lastUncommittedPoint: null, - endBindingElement: getHoveredElementForBinding( - scenePointer, - elements, - elementsMap, - app.state.zoom, - linearElementEditor.elbowed, - ), }; ret.didAddPoint = true; + return ret; } @@ -941,21 +1081,6 @@ export class LinearElementEditor { // it would get deselected if the point is outside the hitbox area if (clickedPointIndex >= 0 || segmentMidpoint) { ret.hitElement = element; - } else { - // You might be wandering why we are storing the binding elements on - // LinearElementEditor and passing them in, instead of calculating them - // from the end points of the `linearElement` - this is to allow disabling - // binding (which needs to happen at the point the user finishes moving - // the point). - const { startBindingElement, endBindingElement } = linearElementEditor; - if (isBindingEnabled(appState) && isBindingElement(element)) { - bindOrUnbindLinearElement( - element, - startBindingElement, - endBindingElement, - scene, - ); - } } const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); @@ -984,16 +1109,19 @@ export class LinearElementEditor { : null; ret.linearElementEditor = { ...linearElementEditor, - pointerDownState: { + initialState: { prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices, lastClickedPoint: clickedPointIndex, - lastClickedIsEndPoint: clickedPointIndex === element.points.length - 1, - origin: { x: scenePointer.x, y: scenePointer.y }, + origin: point, segmentMidpoint: { value: segmentMidpoint, index: segmentMidpointIndex, added: false, }, + arrowStartIsInside: + !!app.state.newElement && + (app.state.bindMode === "inside" || app.state.bindMode === "skip"), + altFocusPoint: null, }, selectedPointsIndices: nextSelectedPointsIndices, pointerOffset: targetPoint @@ -1020,7 +1148,7 @@ export class LinearElementEditor { return pointsEqual(point1, point2); } - static handlePointerMove( + static handlePointerMoveInEditMode( event: React.PointerEvent, scenePointerX: number, scenePointerY: number, @@ -1055,20 +1183,16 @@ export class LinearElementEditor { let newPoint: LocalPoint; if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) { - const lastCommittedPoint = points[points.length - 2]; - + const anchor = points[points.length - 2]; const [width, height] = LinearElementEditor._getShiftLockedDelta( element, elementsMap, - lastCommittedPoint, + anchor, pointFrom(scenePointerX, scenePointerY), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), ); - newPoint = pointFrom( - width + lastCommittedPoint[0], - height + lastCommittedPoint[1], - ); + newPoint = pointFrom(width + anchor[0], height + anchor[1]); } else { newPoint = LinearElementEditor.createPointAt( element, @@ -1141,7 +1265,6 @@ export class LinearElementEditor { static getPointAtIndexGlobalCoordinates( element: NonDeleted, - indexMaybeFromEnd: number, // -1 for last element elementsMap: ElementsMap, ): GlobalPoint { @@ -1149,19 +1272,18 @@ export class LinearElementEditor { indexMaybeFromEnd < 0 ? element.points.length + indexMaybeFromEnd : indexMaybeFromEnd; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const cx = (x1 + x2) / 2; - const cy = (y1 + y2) / 2; + const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); + const center = pointFrom(cx, cy); const p = element.points[index]; const { x, y } = element; return p ? pointRotateRads( - pointFrom(x + p[0], y + p[1]), - pointFrom(cx, cy), + pointFrom(x + p[0], y + p[1]), + center, element.angle, ) - : pointRotateRads(pointFrom(x, y), pointFrom(cx, cy), element.angle); + : pointRotateRads(pointFrom(x, y), center, element.angle); } static pointFromAbsoluteCoords( @@ -1409,8 +1531,9 @@ export class LinearElementEditor { scene: Scene, pointUpdates: PointsPositionUpdates, otherUpdates?: { - startBinding?: PointBinding | null; - endBinding?: PointBinding | null; + startBinding?: FixedPointBinding | null; + endBinding?: FixedPointBinding | null; + moveMidPointsWithElement?: boolean | null; }, ) { const { points } = element; @@ -1456,6 +1579,15 @@ export class LinearElementEditor { : points.map((p, idx) => { const current = pointUpdates.get(idx)?.point ?? p; + if ( + otherUpdates?.moveMidPointsWithElement && + idx !== 0 && + idx !== points.length - 1 && + !pointUpdates.has(idx) + ) { + return current; + } + return pointFrom( current[0] - offsetX, current[1] - offsetY, @@ -1495,20 +1627,20 @@ export class LinearElementEditor { return false; } - const { segmentMidpoint } = linearElementEditor.pointerDownState; + const { segmentMidpoint } = linearElementEditor.initialState; if ( segmentMidpoint.added || segmentMidpoint.value === null || segmentMidpoint.index === null || - linearElementEditor.pointerDownState.origin === null + linearElementEditor.initialState.origin === null ) { return false; } - const origin = linearElementEditor.pointerDownState.origin!; + const origin = linearElementEditor.initialState.origin!; const dist = pointDistance( - pointFrom(origin.x, origin.y), + origin, pointFrom(pointerCoords.x, pointerCoords.y), ); if ( @@ -1535,12 +1667,12 @@ export class LinearElementEditor { if (!element) { return; } - const { segmentMidpoint } = linearElementEditor.pointerDownState; + const { segmentMidpoint } = linearElementEditor.initialState; const ret: { - pointerDownState: LinearElementEditor["pointerDownState"]; + pointerDownState: LinearElementEditor["initialState"]; selectedPointsIndices: LinearElementEditor["selectedPointsIndices"]; } = { - pointerDownState: linearElementEditor.pointerDownState, + pointerDownState: linearElementEditor.initialState, selectedPointsIndices: linearElementEditor.selectedPointsIndices, }; @@ -1560,9 +1692,9 @@ export class LinearElementEditor { scene.mutateElement(element, { points }); ret.pointerDownState = { - ...linearElementEditor.pointerDownState, + ...linearElementEditor.initialState, segmentMidpoint: { - ...linearElementEditor.pointerDownState.segmentMidpoint, + ...linearElementEditor.initialState.segmentMidpoint, added: true, }, lastClickedPoint: segmentMidpoint.index!, @@ -1578,8 +1710,8 @@ export class LinearElementEditor { offsetX: number, offsetY: number, otherUpdates?: { - startBinding?: PointBinding | null; - endBinding?: PointBinding | null; + startBinding?: FixedPointBinding | null; + endBinding?: FixedPointBinding | null; }, options?: { isDragging?: boolean; @@ -1594,18 +1726,10 @@ export class LinearElementEditor { points?: LocalPoint[]; } = {}; if (otherUpdates?.startBinding !== undefined) { - updates.startBinding = - otherUpdates.startBinding !== null && - isFixedPointBinding(otherUpdates.startBinding) - ? otherUpdates.startBinding - : null; + updates.startBinding = otherUpdates.startBinding; } if (otherUpdates?.endBinding !== undefined) { - updates.endBinding = - otherUpdates.endBinding !== null && - isFixedPointBinding(otherUpdates.endBinding) - ? otherUpdates.endBinding - : null; + updates.endBinding = otherUpdates.endBinding; } updates.points = Array.from(nextPoints); @@ -1825,51 +1949,23 @@ export class LinearElementEditor { elementsMap: ElementsMap, includeBoundText: boolean = false, ): [number, number, number, number, number, number] => { - let coords: [number, number, number, number, number, number]; - let x1; - let y1; - let x2; - let y2; - if (element.points.length < 2 || !ShapeCache.get(element)) { - // XXX this is just a poor estimate and not very useful - const { minX, minY, maxX, maxY } = element.points.reduce( - (limits, [x, y]) => { - limits.minY = Math.min(limits.minY, y); - limits.minX = Math.min(limits.minX, x); + const shape = ShapeCache.generateElementShape(element, null); - limits.maxX = Math.max(limits.maxX, x); - limits.maxY = Math.max(limits.maxY, y); + // first element is always the curve + const ops = getCurvePathOps(shape[0]); - return limits; - }, - { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, - ); - x1 = minX + element.x; - y1 = minY + element.y; - x2 = maxX + element.x; - y2 = maxY + element.y; - } else { - const shape = ShapeCache.generateElementShape(element, null); - - // first element is always the curve - const ops = getCurvePathOps(shape[0]); - - const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops); - x1 = minX + element.x; - y1 = minY + element.y; - x2 = maxX + element.x; - y2 = maxY + element.y; - } + const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops); + const x1 = minX + element.x; + const y1 = minY + element.y; + const x2 = maxX + element.x; + const y2 = maxY + element.y; const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; - coords = [x1, y1, x2, y2, cx, cy]; - if (!includeBoundText) { - return coords; - } - const boundTextElement = getBoundTextElement(element, elementsMap); + const boundTextElement = + includeBoundText && getBoundTextElement(element, elementsMap); if (boundTextElement) { - coords = LinearElementEditor.getMinMaxXYWithBoundText( + return LinearElementEditor.getMinMaxXYWithBoundText( element, elementsMap, [x1, y1, x2, y2], @@ -1877,7 +1973,7 @@ export class LinearElementEditor { ); } - return coords; + return [x1, y1, x2, y2, cx, cy]; }; static moveFixedSegment( @@ -1886,7 +1982,10 @@ export class LinearElementEditor { x: number, y: number, scene: Scene, - ): LinearElementEditor { + ): Pick< + LinearElementEditor, + "segmentMidPointHoveredCoords" | "initialState" + > { const elementsMap = scene.getNonDeletedElementsMap(); const element = LinearElementEditor.getElement( linearElement.elementId, @@ -1948,8 +2047,8 @@ export class LinearElementEditor { return { ...linearElement, segmentMidPointHoveredCoords: point, - pointerDownState: { - ...linearElement.pointerDownState, + initialState: { + ...linearElement.initialState, segmentMidpoint: { added: false, index: element.fixedSegments![offset].index, @@ -1984,3 +2083,296 @@ const normalizeSelectedPoints = ( nextPoints = nextPoints.sort((a, b) => a - b); return nextPoints.length ? nextPoints : null; }; + +const pointDraggingUpdates = ( + selectedPointsIndices: readonly number[], + deltaX: number, + deltaY: number, + elementsMap: NonDeletedSceneElementsMap, + element: NonDeleted, + elements: readonly Ordered[], + app: AppClassProperties, + shiftKey: boolean, + altKey: boolean, +): { + positions: PointsPositionUpdates; + updates?: PointMoveOtherUpdates; +} => { + const naiveDraggingPoints = new Map( + selectedPointsIndices.map((pointIndex) => { + return [ + pointIndex, + { + point: pointFrom( + element.points[pointIndex][0] + deltaX, + element.points[pointIndex][1] + deltaY, + ), + isDragging: true, + }, + ]; + }), + ); + + // Linear elements have no special logic + if (!isArrowElement(element)) { + return { + positions: naiveDraggingPoints, + }; + } + + const startIsDragged = selectedPointsIndices.includes(0); + const endIsDragged = selectedPointsIndices.includes( + element.points.length - 1, + ); + + const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints( + element, + naiveDraggingPoints, + elementsMap, + elements, + app.state, + { + newArrow: !!app.state.newElement, + shiftKey, + altKey, + }, + ); + + if (isElbowArrow(element)) { + return { + positions: naiveDraggingPoints, + updates: { + suggestedBinding: startIsDragged + ? start.element + : endIsDragged + ? end.element + : null, + }, + }; + } + + if (startIsDragged === endIsDragged) { + return { + positions: naiveDraggingPoints, + }; + } + + // Generate the next bindings for the arrow + const updates: PointMoveOtherUpdates = { + suggestedBinding: null, + }; + if (start.mode === null) { + updates.startBinding = null; + } else if (start.mode) { + updates.startBinding = { + elementId: start.element.id, + mode: start.mode, + ...calculateFixedPointForNonElbowArrowBinding( + element, + start.element, + "start", + elementsMap, + start.focusPoint, + ), + }; + + if ( + startIsDragged && + (updates.startBinding.mode === "orbit" || + !getFeatureFlag("COMPLEX_BINDINGS")) + ) { + updates.suggestedBinding = start.element; + } + } else if (startIsDragged) { + updates.suggestedBinding = app.state.suggestedBinding; + } + + if (end.mode === null) { + updates.endBinding = null; + } else if (end.mode) { + updates.endBinding = { + elementId: end.element.id, + mode: end.mode, + ...calculateFixedPointForNonElbowArrowBinding( + element, + end.element, + "end", + elementsMap, + end.focusPoint, + ), + }; + + if ( + endIsDragged && + (updates.endBinding.mode === "orbit" || + !getFeatureFlag("COMPLEX_BINDINGS")) + ) { + updates.suggestedBinding = end.element; + } + } else if (endIsDragged) { + updates.suggestedBinding = app.state.suggestedBinding; + } + + // Simulate the updated arrow for the bind point calculation + const offsetStartLocalPoint = startIsDragged + ? pointFrom( + element.points[0][0] + deltaX, + element.points[0][1] + deltaY, + ) + : element.points[0]; + const offsetEndLocalPoint = endIsDragged + ? pointFrom( + element.points[element.points.length - 1][0] + deltaX, + element.points[element.points.length - 1][1] + deltaY, + ) + : element.points[element.points.length - 1]; + const nextArrow = { + ...element, + points: [ + offsetStartLocalPoint, + ...element.points.slice(1, -1), + offsetEndLocalPoint, + ], + startBinding: + updates.startBinding === undefined + ? element.startBinding + : updates.startBinding === null + ? null + : updates.startBinding, + endBinding: + updates.endBinding === undefined + ? element.endBinding + : updates.endBinding === null + ? null + : 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. + const customIntersector = + start.focusPoint && end.focusPoint + ? lineSegment(start.focusPoint, end.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 = + element.endBinding && + nextArrow.startBinding && + startIsDragged && + nextArrow.startBinding.elementId === element.endBinding.elementId; + const endIsDraggingOverStartElement = + element.startBinding && + nextArrow.endBinding && + endIsDragged && + element.startBinding.elementId === nextArrow.endBinding.elementId; + + // We need to update the non-dragged point too if bound, + // so we look up the old binding to trigger updateBoundPoint + const endBindable = nextArrow.endBinding + ? end.element ?? + (elementsMap.get( + nextArrow.endBinding.elementId, + )! as ExcalidrawBindableElement) + : null; + + const endLocalPoint = startIsDraggingOverEndElement + ? nextArrow.points[nextArrow.points.length - 1] + : endIsDraggingOverStartElement && + app.state.bindMode !== "inside" && + getFeatureFlag("COMPLEX_BINDINGS") + ? nextArrow.points[0] + : endBindable + ? updateBoundPoint( + element, + "endBinding", + nextArrow.endBinding, + endBindable, + elementsMap, + customIntersector, + ) || nextArrow.points[nextArrow.points.length - 1] + : nextArrow.points[nextArrow.points.length - 1]; + + // We need to keep the simulated next arrow up-to-date, because + // updateBoundPoint looks at the opposite point + nextArrow.points[nextArrow.points.length - 1] = endLocalPoint; + + // We need to update the non-dragged point too if bound, + // so we look up the old binding to trigger updateBoundPoint + const startBindable = nextArrow.startBinding + ? start.element ?? + (elementsMap.get( + nextArrow.startBinding.elementId, + )! as ExcalidrawBindableElement) + : null; + + const startLocalPoint = + endIsDraggingOverStartElement && getFeatureFlag("COMPLEX_BINDINGS") + ? nextArrow.points[0] + : startIsDraggingOverEndElement && + app.state.bindMode !== "inside" && + getFeatureFlag("COMPLEX_BINDINGS") + ? nextArrow.points[nextArrow.points.length - 1] + : startBindable + ? updateBoundPoint( + element, + "startBinding", + nextArrow.startBinding, + startBindable, + elementsMap, + customIntersector, + ) || nextArrow.points[0] + : nextArrow.points[0]; + + const endChanged = + pointDistance( + endLocalPoint, + nextArrow.points[nextArrow.points.length - 1], + ) !== 0; + const startChanged = + pointDistance(startLocalPoint, nextArrow.points[0]) !== 0; + + const indicesSet = new Set(selectedPointsIndices); + if (startBindable && startChanged) { + indicesSet.add(0); + } + if (endBindable && endChanged) { + indicesSet.add(element.points.length - 1); + } + const indices = Array.from(indicesSet); + + return { + updates: + updates.startBinding || updates.suggestedBinding + ? { + startBinding: updates.startBinding, + suggestedBinding: updates.suggestedBinding, + } + : undefined, + positions: new Map( + indices.map((idx) => { + return [ + idx, + idx === 0 + ? { + point: startLocalPoint, + isDragging: true, + } + : idx === element.points.length - 1 + ? { + point: endLocalPoint, + isDragging: true, + } + : naiveDraggingPoints.get(idx)!, + ]; + }), + ), + }; +}; + +const determineCustomLinearAngle = ( + pivotPoint: LocalPoint, + draggedPoint: LocalPoint, +) => + Math.atan2(draggedPoint[1] - pivotPoint[1], draggedPoint[0] - pivotPoint[0]); diff --git a/packages/element/src/mutateElement.ts b/packages/element/src/mutateElement.ts index 0fc3e0bb8f..c45c6df08c 100644 --- a/packages/element/src/mutateElement.ts +++ b/packages/element/src/mutateElement.ts @@ -46,16 +46,13 @@ export const mutateElement = >( // casting to any because can't use `in` operator // (see https://github.com/microsoft/TypeScript/issues/21732) - const { points, fixedSegments, startBinding, endBinding, fileId } = - updates as any; + const { points, fixedSegments, fileId } = updates as any; if ( isElbowArrow(element) && (Object.keys(updates).length === 0 || // normalization case typeof points !== "undefined" || // repositioning - typeof fixedSegments !== "undefined" || // segment fixing - typeof startBinding !== "undefined" || - typeof endBinding !== "undefined") // manual binding to element + typeof fixedSegments !== "undefined") // segment fixing ) { updates = { ...updates, diff --git a/packages/element/src/newElement.ts b/packages/element/src/newElement.ts index 69ccaf595f..ec50a81ff2 100644 --- a/packages/element/src/newElement.ts +++ b/packages/element/src/newElement.ts @@ -452,7 +452,6 @@ export const newFreeDrawElement = ( points: opts.points || [], pressures: opts.pressures || [], simulatePressure: opts.simulatePressure, - lastCommittedPoint: null, }; }; @@ -466,7 +465,7 @@ export const newLinearElement = ( const element = { ..._newElementBase(opts.type, opts), points: opts.points || [], - lastCommittedPoint: null, + startBinding: null, endBinding: null, startArrowhead: null, @@ -501,7 +500,6 @@ export const newArrowElement = ( return { ..._newElementBase(opts.type, opts), points: opts.points || [], - lastCommittedPoint: null, startBinding: null, endBinding: null, startArrowhead: opts.startArrowhead || null, @@ -516,7 +514,6 @@ export const newArrowElement = ( return { ..._newElementBase(opts.type, opts), points: opts.points || [], - lastCommittedPoint: null, startBinding: null, endBinding: null, startArrowhead: opts.startArrowhead || null, diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index 8c17863ee0..6a49d4202f 100644 --- a/packages/element/src/renderElement.ts +++ b/packages/element/src/renderElement.ts @@ -98,7 +98,7 @@ const isPendingImageElement = ( const shouldResetImageFilter = ( element: ExcalidrawElement, renderConfig: StaticCanvasRenderConfig, - appState: StaticCanvasAppState, + appState: StaticCanvasAppState | InteractiveCanvasAppState, ) => { return ( appState.theme === THEME.DARK && @@ -225,7 +225,7 @@ const generateElementCanvas = ( elementsMap: NonDeletedSceneElementsMap, zoom: Zoom, renderConfig: StaticCanvasRenderConfig, - appState: StaticCanvasAppState, + appState: StaticCanvasAppState | InteractiveCanvasAppState, ): ExcalidrawElementWithCanvas | null => { const canvas = document.createElement("canvas"); const context = canvas.getContext("2d")!; @@ -277,7 +277,7 @@ const generateElementCanvas = ( context.filter = IMAGE_INVERT_FILTER; } - drawElementOnCanvas(element, rc, context, renderConfig, appState); + drawElementOnCanvas(element, rc, context, renderConfig); context.restore(); @@ -412,7 +412,6 @@ const drawElementOnCanvas = ( rc: RoughCanvas, context: CanvasRenderingContext2D, renderConfig: StaticCanvasRenderConfig, - appState: StaticCanvasAppState, ) => { switch (element.type) { case "rectangle": @@ -558,7 +557,7 @@ const generateElementWithCanvas = ( element: NonDeletedExcalidrawElement, elementsMap: NonDeletedSceneElementsMap, renderConfig: StaticCanvasRenderConfig, - appState: StaticCanvasAppState, + appState: StaticCanvasAppState | InteractiveCanvasAppState, ) => { const zoom: Zoom = renderConfig ? appState.zoom @@ -615,7 +614,7 @@ const drawElementFromCanvas = ( elementWithCanvas: ExcalidrawElementWithCanvas, context: CanvasRenderingContext2D, renderConfig: StaticCanvasRenderConfig, - appState: StaticCanvasAppState, + appState: StaticCanvasAppState | InteractiveCanvasAppState, allElementsMap: NonDeletedSceneElementsMap, ) => { const element = elementWithCanvas.element; @@ -733,7 +732,7 @@ export const renderElement = ( rc: RoughCanvas, context: CanvasRenderingContext2D, renderConfig: StaticCanvasRenderConfig, - appState: StaticCanvasAppState, + appState: StaticCanvasAppState | InteractiveCanvasAppState, ) => { const reduceAlphaForSelection = appState.openDialog?.name === "elementLinkSelector" && @@ -803,7 +802,7 @@ export const renderElement = ( context.translate(cx, cy); context.rotate(element.angle); context.translate(-shiftX, -shiftY); - drawElementOnCanvas(element, rc, context, renderConfig, appState); + drawElementOnCanvas(element, rc, context, renderConfig); context.restore(); } else { const elementWithCanvas = generateElementWithCanvas( @@ -896,13 +895,7 @@ export const renderElement = ( tempCanvasContext.translate(-shiftX, -shiftY); - drawElementOnCanvas( - element, - tempRc, - tempCanvasContext, - renderConfig, - appState, - ); + drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig); tempCanvasContext.translate(shiftX, shiftY); @@ -941,7 +934,7 @@ export const renderElement = ( } context.translate(-shiftX, -shiftY); - drawElementOnCanvas(element, rc, context, renderConfig, appState); + drawElementOnCanvas(element, rc, context, renderConfig); } context.restore(); @@ -1122,7 +1115,7 @@ export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) { smoothing: 0.5, streamline: 0.5, easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine - last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup + last: true, }; return getStroke(inputPoints as number[][], options) as [number, number][]; diff --git a/packages/element/src/resizeElements.ts b/packages/element/src/resizeElements.ts index 8cfd807855..bb9094a5d2 100644 --- a/packages/element/src/resizeElements.ts +++ b/packages/element/src/resizeElements.ts @@ -20,7 +20,11 @@ import type { PointerDownState } from "@excalidraw/excalidraw/types"; import type { Mutable } from "@excalidraw/common/utility-types"; -import { getArrowLocalFixedPoints, updateBoundElements } from "./binding"; +import { + getArrowLocalFixedPoints, + unbindBindingElement, + updateBoundElements, +} from "./binding"; import { getElementAbsoluteCoords, getCommonBounds, @@ -46,6 +50,7 @@ import { import { wrapText } from "./textWrapping"; import { isArrowElement, + isBindingElement, isBoundToContainer, isElbowArrow, isFrameLikeElement, @@ -74,7 +79,9 @@ import type { ExcalidrawImageElement, ElementsMap, ExcalidrawElbowArrowElement, + ExcalidrawArrowElement, } from "./types"; +import type { ElementUpdate } from "./mutateElement"; // Returns true when transform (resizing/rotation) happened export const transformElements = ( @@ -220,7 +227,25 @@ const rotateSingleElement = ( } const boundTextElementId = getBoundTextElementId(element); - scene.mutateElement(element, { angle }); + let update: ElementUpdate = { + angle, + }; + + if (isBindingElement(element)) { + update = { + ...update, + } as ElementUpdate; + + if (element.startBinding) { + unbindBindingElement(element, "start", scene); + } + if (element.endBinding) { + unbindBindingElement(element, "end", scene); + } + } + + scene.mutateElement(element, update); + if (boundTextElementId) { const textElement = scene.getElement(boundTextElementId); @@ -394,6 +419,11 @@ const rotateMultipleElements = ( centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE; } + const rotatedElementsMap = new Map< + ExcalidrawElement["id"], + NonDeletedExcalidrawElement + >(elements.map((element) => [element.id, element])); + for (const element of elements) { if (!isFrameLikeElement(element)) { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); @@ -424,6 +454,19 @@ const rotateMultipleElements = ( simultaneouslyUpdated: elements, }); + if (isBindingElement(element)) { + if (element.startBinding) { + if (!rotatedElementsMap.has(element.startBinding.elementId)) { + unbindBindingElement(element, "start", scene); + } + } + if (element.endBinding) { + if (!rotatedElementsMap.has(element.endBinding.elementId)) { + unbindBindingElement(element, "end", scene); + } + } + } + const boundText = getBoundTextElement(element, elementsMap); if (boundText && !isArrowElement(element)) { const { x, y } = computeBoundTextPosition( @@ -835,13 +878,32 @@ export const resizeSingleElement = ( Number.isFinite(newOrigin.x) && Number.isFinite(newOrigin.y) ) { - const updates = { + let updates: ElementUpdate = { ...newOrigin, width: Math.abs(nextWidth), height: Math.abs(nextHeight), ...rescaledPoints, }; + if (isBindingElement(latestElement)) { + if (latestElement.startBinding) { + updates = { + ...updates, + } as ElementUpdate; + + if (latestElement.startBinding) { + unbindBindingElement(latestElement, "start", scene); + } + } + + if (latestElement.endBinding) { + updates = { + ...updates, + endBinding: null, + } as ElementUpdate; + } + } + scene.mutateElement(latestElement, updates, { informMutation: shouldInformMutation, isDragging: false, @@ -859,10 +921,7 @@ export const resizeSingleElement = ( shouldMaintainAspectRatio, ); - updateBoundElements(latestElement, scene, { - // TODO: confirm with MARK if this actually makes sense - newSize: { width: nextWidth, height: nextHeight }, - }); + updateBoundElements(latestElement, scene); } }; @@ -1396,20 +1455,36 @@ export const resizeMultipleElements = ( } const elementsToUpdate = elementsAndUpdates.map(({ element }) => element); + const resizedElementsMap = new Map< + ExcalidrawElement["id"], + NonDeletedExcalidrawElement + >(elementsAndUpdates.map(({ element }) => [element.id, element])); for (const { element, update: { boundTextFontSize, ...update }, } of elementsAndUpdates) { - const { width, height, angle } = update; + const { angle } = update; scene.mutateElement(element, update); updateBoundElements(element, scene, { simultaneouslyUpdated: elementsToUpdate, - newSize: { width, height }, }); + if (isBindingElement(element)) { + if (element.startBinding) { + if (!resizedElementsMap.has(element.startBinding.elementId)) { + unbindBindingElement(element, "start", scene); + } + } + if (element.endBinding) { + if (!resizedElementsMap.has(element.endBinding.elementId)) { + unbindBindingElement(element, "end", scene); + } + } + } + const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement && boundTextFontSize) { scene.mutateElement(boundTextElement, { diff --git a/packages/element/src/transformHandles.ts b/packages/element/src/transformHandles.ts index 4a9e5f167c..0b9458da63 100644 --- a/packages/element/src/transformHandles.ts +++ b/packages/element/src/transformHandles.ts @@ -330,7 +330,10 @@ export const hasBoundingBox = ( appState: InteractiveCanvasAppState, editorInterface: EditorInterface, ) => { - if (appState.selectedLinearElement?.isEditing) { + if ( + appState.selectedLinearElement?.isEditing || + appState.selectedLinearElement?.isDragging + ) { return false; } if (elements.length > 1) { diff --git a/packages/element/src/typeChecks.ts b/packages/element/src/typeChecks.ts index ab7a1935f5..f328ee947c 100644 --- a/packages/element/src/typeChecks.ts +++ b/packages/element/src/typeChecks.ts @@ -28,8 +28,6 @@ import type { ExcalidrawArrowElement, ExcalidrawElbowArrowElement, ExcalidrawLineElement, - PointBinding, - FixedPointBinding, ExcalidrawFlowchartNodeElement, ExcalidrawLinearElementSubType, } from "./types"; @@ -163,7 +161,7 @@ export const isLinearElementType = ( export const isBindingElement = ( element?: ExcalidrawElement | null, includeLocked = true, -): element is ExcalidrawLinearElement => { +): element is ExcalidrawArrowElement => { return ( element != null && (!element.locked || includeLocked === true) && @@ -358,15 +356,6 @@ export const getDefaultRoundnessTypeForElement = ( return null; }; -export const isFixedPointBinding = ( - binding: PointBinding | FixedPointBinding, -): binding is FixedPointBinding => { - return ( - Object.hasOwn(binding, "fixedPoint") && - (binding as FixedPointBinding).fixedPoint != null - ); -}; - // TODO: Move this to @excalidraw/math export const isBounds = (box: unknown): box is Bounds => Array.isArray(box) && diff --git a/packages/element/src/types.ts b/packages/element/src/types.ts index c2becd3e6c..8067342a20 100644 --- a/packages/element/src/types.ts +++ b/packages/element/src/types.ts @@ -279,23 +279,22 @@ export type ExcalidrawTextElementWithContainer = { export type FixedPoint = [number, number]; -export type PointBinding = { - elementId: ExcalidrawBindableElement["id"]; - focus: number; - gap: number; -}; +export type BindMode = "inside" | "orbit" | "skip"; -export type FixedPointBinding = Merge< - PointBinding, - { - // Represents the fixed point binding information in form of a vertical and - // horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio - // gives the user selected fixed point by multiplying the bound element width - // with fixedPoint[0] and the bound element height with fixedPoint[1] to get the - // bound element-local point coordinate. - fixedPoint: FixedPoint; - } ->; +export type FixedPointBinding = { + elementId: ExcalidrawBindableElement["id"]; + + // Represents the fixed point binding information in form of a vertical and + // horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio + // gives the user selected fixed point by multiplying the bound element width + // with fixedPoint[0] and the bound element height with fixedPoint[1] to get the + // bound element-local point coordinate. + fixedPoint: FixedPoint; + + // Determines whether the arrow remains outside the shape or is allowed to + // go all the way inside the shape up to the exact fixed point. + mode: BindMode; +}; type Index = number; @@ -322,9 +321,8 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase & Readonly<{ type: "line" | "arrow"; points: readonly LocalPoint[]; - lastCommittedPoint: LocalPoint | null; - startBinding: PointBinding | null; - endBinding: PointBinding | null; + startBinding: FixedPointBinding | null; + endBinding: FixedPointBinding | null; startArrowhead: Arrowhead | null; endArrowhead: Arrowhead | null; }>; @@ -351,9 +349,9 @@ export type ExcalidrawElbowArrowElement = Merge< ExcalidrawArrowElement, { elbowed: true; + fixedSegments: readonly FixedSegment[] | null; startBinding: FixedPointBinding | null; endBinding: FixedPointBinding | null; - fixedSegments: readonly FixedSegment[] | null; /** * Marks that the 3rd point should be used as the 2nd point of the arrow in * order to temporarily hide the first segment of the arrow without losing @@ -379,7 +377,6 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase & points: readonly LocalPoint[]; pressures: readonly number[]; simulatePressure: boolean; - lastCommittedPoint: LocalPoint | null; }>; export type FileId = string & { _brand: "FileId" }; diff --git a/packages/element/src/utils.ts b/packages/element/src/utils.ts index 44b0fe79c6..c8e6889864 100644 --- a/packages/element/src/utils.ts +++ b/packages/element/src/utils.ts @@ -1,6 +1,7 @@ import { DEFAULT_ADAPTIVE_RADIUS, DEFAULT_PROPORTIONAL_RADIUS, + invariant, LINE_CONFIRM_THRESHOLD, ROUNDNESS, } from "@excalidraw/common"; @@ -10,10 +11,17 @@ import { curveCatmullRomCubicApproxPoints, curveOffsetPoints, lineSegment, + lineSegmentIntersectionPoints, pointDistance, pointFrom, pointFromArray, + pointFromVector, + pointRotateRads, + pointTranslate, rectangle, + vectorFromPoint, + vectorNormalize, + vectorScale, type GlobalPoint, } from "@excalidraw/math"; @@ -21,11 +29,17 @@ import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math"; import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types"; -import { getDiamondPoints } from "./bounds"; +import { elementCenterPoint, getDiamondPoints } from "./bounds"; import { generateLinearCollisionShape } from "./shape"; +import { isPointInElement } from "./collision"; +import { LinearElementEditor } from "./linearElementEditor"; +import { isRectangularElement } from "./typeChecks"; + import type { + ElementsMap, + ExcalidrawArrowElement, ExcalidrawDiamondElement, ExcalidrawElement, ExcalidrawFreeDrawElement, @@ -400,20 +414,10 @@ export function deconstructDiamondElement( ), // TOP ]; - const corners = - offset > 0 - ? baseCorners.map( - (corner) => - curveCatmullRomCubicApproxPoints( - curveOffsetPoints(corner, offset), - )!, - ) - : [ - [baseCorners[0]], - [baseCorners[1]], - [baseCorners[2]], - [baseCorners[3]], - ]; + const corners = baseCorners.map( + (corner) => + curveCatmullRomCubicApproxPoints(curveOffsetPoints(corner, offset))!, + ); const sides = [ lineSegment( @@ -481,3 +485,136 @@ export const getCornerRadius = (x: number, element: ExcalidrawElement) => { return 0; }; + +const getDiagonalsForBindableElement = ( + element: ExcalidrawElement, + elementsMap: ElementsMap, +) => { + // for rectangles, shrink the diagonals a bit because there's something + // going on with the focus points around the corners. Ask Mark for details. + const OFFSET_PX = element.type === "rectangle" ? 15 : 0; + const shrinkSegment = (seg: LineSegment) => { + const v = vectorNormalize(vectorFromPoint(seg[1], seg[0])); + const offset = vectorScale(v, OFFSET_PX); + return lineSegment( + pointTranslate(seg[0], offset), + pointTranslate(seg[1], vectorScale(offset, -1)), + ); + }; + + const center = elementCenterPoint(element, elementsMap); + const diagonalOne = shrinkSegment( + isRectangularElement(element) + ? lineSegment( + pointRotateRads( + pointFrom(element.x, element.y), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + element.x + element.width, + element.y + element.height, + ), + center, + element.angle, + ), + ) + : lineSegment( + pointRotateRads( + pointFrom(element.x + element.width / 2, element.y), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + element.x + element.width / 2, + element.y + element.height, + ), + center, + element.angle, + ), + ), + ); + const diagonalTwo = shrinkSegment( + isRectangularElement(element) + ? lineSegment( + pointRotateRads( + pointFrom(element.x + element.width, element.y), + center, + element.angle, + ), + pointRotateRads( + pointFrom(element.x, element.y + element.height), + center, + element.angle, + ), + ) + : lineSegment( + pointRotateRads( + pointFrom(element.x, element.y + element.height / 2), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + element.x + element.width, + element.y + element.height / 2, + ), + center, + element.angle, + ), + ), + ); + + return [diagonalOne, diagonalTwo]; +}; + +export const projectFixedPointOntoDiagonal = ( + arrow: ExcalidrawArrowElement, + point: GlobalPoint, + element: ExcalidrawElement, + startOrEnd: "start" | "end", + elementsMap: ElementsMap, +): GlobalPoint | null => { + invariant(arrow.points.length >= 2, "Arrow must have at least two points"); + if (arrow.width < 3 && arrow.height < 3) { + return null; + } + + const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement( + element, + elementsMap, + ); + + const a = LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + startOrEnd === "start" ? 1 : arrow.points.length - 2, + elementsMap, + ); + const b = pointFromVector( + vectorScale( + vectorFromPoint(point, a), + 2 * pointDistance(a, point) + + Math.max( + pointDistance(diagonalOne[0], diagonalOne[1]), + pointDistance(diagonalTwo[0], diagonalTwo[1]), + ), + ), + a, + ); + const intersector = lineSegment(point, b); + 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; + if (d1 != null && d2 != null) { + p = d1 < d2 ? p1 : p2; + } else { + p = p1 || p2 || null; + } + + return p && isPointInElement(p, element, elementsMap) ? p : null; +}; diff --git a/packages/element/src/zindex.ts b/packages/element/src/zindex.ts index fed9378253..0bb0cda9c2 100644 --- a/packages/element/src/zindex.ts +++ b/packages/element/src/zindex.ts @@ -1,18 +1,25 @@ import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common"; import type { AppState } from "@excalidraw/excalidraw/types"; +import type { GlobalPoint } from "@excalidraw/math"; -import { isFrameLikeElement } from "./typeChecks"; - +import { isFrameLikeElement, isTextElement } from "./typeChecks"; import { getElementsInGroup } from "./groups"; - import { syncMovedIndices } from "./fractionalIndex"; - import { getSelectedElements } from "./selection"; +import { getBoundTextElement, getContainerElement } from "./textElement"; +import { getHoveredElementForBinding } from "./collision"; import type { Scene } from "./Scene"; - -import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types"; +import type { + ExcalidrawArrowElement, + ExcalidrawElement, + ExcalidrawFrameLikeElement, + NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, + Ordered, + OrderedExcalidrawElement, +} from "./types"; const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => { return element.frameId === frameId || element.id === frameId; @@ -139,6 +146,51 @@ const getContiguousFrameRangeElements = ( return allElements.slice(rangeStart, rangeEnd + 1); }; +/** + * Moves the arrow element above any bindable elements it intersects with or + * hovers over. + */ +export const moveArrowAboveBindable = ( + point: GlobalPoint, + arrow: ExcalidrawArrowElement, + elements: readonly Ordered[], + elementsMap: NonDeletedSceneElementsMap, + scene: Scene, +): readonly OrderedExcalidrawElement[] => { + const hoveredElement = getHoveredElementForBinding( + point, + elements, + elementsMap, + ); + + if (!hoveredElement) { + return elements; + } + + const boundTextElement = getBoundTextElement(hoveredElement, elementsMap); + const containerElement = isTextElement(hoveredElement) + ? getContainerElement(hoveredElement, elementsMap) + : null; + + const bindableIds = [ + hoveredElement.id, + boundTextElement?.id, + containerElement?.id, + ].filter((id): id is NonDeletedExcalidrawElement["id"] => !!id); + const bindableIdx = elements.findIndex((el) => bindableIds.includes(el.id)); + const arrowIdx = elements.findIndex((el) => el.id === arrow.id); + + if (arrowIdx !== -1 && bindableIdx !== -1 && arrowIdx < bindableIdx) { + const updatedElements = Array.from(elements); + const arrow = updatedElements.splice(arrowIdx, 1)[0]; + updatedElements.splice(bindableIdx, 0, arrow); + + scene.replaceAllElements(updatedElements); + } + + return elements; +}; + /** * Returns next candidate index that's available to be moved to. Currently that * is a non-deleted element, and not inside a group (unless we're editing it). diff --git a/packages/element/tests/binding.test.tsx b/packages/element/tests/binding.test.tsx index 8690439782..e2495c52f9 100644 --- a/packages/element/tests/binding.test.tsx +++ b/packages/element/tests/binding.test.tsx @@ -8,9 +8,13 @@ import { Excalidraw, isLinearElement } from "@excalidraw/excalidraw"; import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui"; -import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils"; +import { + act, + fireEvent, + render, +} from "@excalidraw/excalidraw/tests/test-utils"; -import { LinearElementEditor } from "@excalidraw/element"; +import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n"; import { getTransformHandles } from "../src/transformHandles"; import { @@ -18,459 +22,675 @@ import { TEXT_EDITOR_SELECTOR, } from "../../excalidraw/tests/queries/dom"; +import type { + ExcalidrawArrowElement, + ExcalidrawLinearElement, + FixedPointBinding, +} from "../src/types"; + const { h } = window; const mouse = new Pointer("mouse"); -describe("element binding", () => { - beforeEach(async () => { - await render(); - }); +describe("binding for simple arrows", () => { + describe("when both endpoints are bound inside the same element", () => { + beforeEach(async () => { + mouse.reset(); - it("should create valid binding if duplicate start/end points", async () => { - const rect = API.createElement({ - type: "rectangle", - x: 0, - y: 0, - width: 50, - height: 50, - }); - const arrow = API.createElement({ - type: "arrow", - x: 100, - y: 0, - width: 100, - height: 1, - points: [ - pointFrom(0, 0), - pointFrom(0, 0), - pointFrom(100, 0), - pointFrom(100, 0), - ], - }); - API.setElements([rect, arrow]); - expect(arrow.startBinding).toBe(null); - - // select arrow - mouse.clickAt(150, 0); - - // move arrow start to potential binding position - mouse.downAt(100, 0); - mouse.moveTo(55, 0); - mouse.up(0, 0); - - // Point selection is evaluated like the points are rendered, - // from right to left. So clicking on the first point should move the joint, - // not the start point. - expect(arrow.startBinding).toBe(null); - - // Now that the start point is free, move it into overlapping position - mouse.downAt(100, 0); - mouse.moveTo(55, 0); - mouse.up(0, 0); - - expect(API.getSelectedElements()).toEqual([arrow]); - - expect(arrow.startBinding).toEqual({ - elementId: rect.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + await act(() => { + return setLanguage(defaultLang); + }); + await render(); }); - // Move the end point to the overlapping binding position - mouse.downAt(200, 0); - mouse.moveTo(55, 0); - mouse.up(0, 0); + it("should create an `inside` binding", () => { + // Create a rectangle + UI.clickTool("rectangle"); + mouse.reset(); + mouse.downAt(100, 100); + mouse.moveTo(200, 200); + mouse.up(); - // Both the start and the end points should be bound - expect(arrow.startBinding).toEqual({ - elementId: rect.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + const rect = API.getSelectedElement(); + + // Draw arrow with endpoint inside the filled rectangle + UI.clickTool("arrow"); + mouse.downAt(110, 110); + mouse.moveTo(160, 160); + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + expect(arrow.x).toBe(110); + expect(arrow.y).toBe(110); + + // Should bind to the rectangle since endpoint is inside + expect(arrow.startBinding?.elementId).toBe(rect.id); + expect(arrow.endBinding?.elementId).toBe(rect.id); + + const startBinding = arrow.startBinding as FixedPointBinding; + expect(startBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0); + expect(startBinding.fixedPoint[0]).toBeLessThanOrEqual(1); + expect(startBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0); + expect(startBinding.fixedPoint[1]).toBeLessThanOrEqual(1); + expect(startBinding.mode).toBe("inside"); + + const endBinding = arrow.endBinding as FixedPointBinding; + expect(endBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0); + expect(endBinding.fixedPoint[0]).toBeLessThanOrEqual(1); + expect(endBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0); + expect(endBinding.fixedPoint[1]).toBeLessThanOrEqual(1); + expect(endBinding.mode).toBe("inside"); + + // Move the bindable + mouse.downAt(100, 150); + mouse.moveTo(280, 110); + mouse.up(); + + // Check if the arrow moved + expect(arrow.x).toBe(290); + expect(arrow.y).toBe(70); + + // Restore bindable + mouse.reset(); + mouse.downAt(280, 110); + mouse.moveTo(130, 110); + mouse.up(); + + // Move the start point of the arrow to check if + // the behavior remains the same for old arrows + mouse.reset(); + mouse.downAt(110, 110); + mouse.moveTo(120, 120); + mouse.up(); + + // Move the bindable again + mouse.reset(); + mouse.downAt(130, 110); + mouse.moveTo(280, 110); + mouse.up(); + + // Check if the arrow moved + expect(arrow.x).toBe(290); + expect(arrow.y).toBe(70); }); - expect(arrow.endBinding).toEqual({ - elementId: rect.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + + it("3+ point arrow should be dragged along with the bindable", () => { + // Create two rectangles as binding targets + const rectLeft = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + }); + + const rectRight = API.createElement({ + type: "rectangle", + x: 300, + y: 0, + width: 100, + height: 100, + }); + + // Create a non-elbowed arrow with inner points bound to different elements + const arrow = API.createElement({ + type: "arrow", + x: 100, + y: 50, + width: 200, + height: 0, + points: [ + pointFrom(0, 0), // start point + pointFrom(50, -20), // first inner point + pointFrom(150, 20), // second inner point + pointFrom(200, 0), // end point + ], + startBinding: { + elementId: rectLeft.id, + fixedPoint: [0.5, 0.5], + mode: "orbit", + }, + endBinding: { + elementId: rectRight.id, + fixedPoint: [0.5, 0.5], + mode: "orbit", + }, + }); + + API.setElements([rectLeft, rectRight, arrow]); + + // Store original inner point positions + const originalInnerPoint1 = [...arrow.points[1]]; + const originalInnerPoint2 = [...arrow.points[2]]; + + // Move the right rectangle down by 50 pixels + mouse.reset(); + mouse.downAt(350, 50); // Click on the right rectangle + mouse.moveTo(350, 100); // Move it down + mouse.up(); + + // Verify that inner points did NOT move when bound to different elements + // The arrow should NOT translate inner points proportionally when only one end moves + expect(arrow.points[1][0]).toBe(originalInnerPoint1[0]); + expect(arrow.points[1][1]).toBe(originalInnerPoint1[1]); + expect(arrow.points[2][0]).toBe(originalInnerPoint2[0]); + expect(arrow.points[2][1]).toBe(originalInnerPoint2[1]); }); }); - //@TODO fix the test with rotation - it.skip("rotation of arrow should rebind both ends", () => { - const rectLeft = UI.createElement("rectangle", { - x: 0, - width: 200, - height: 500, - }); - const rectRight = UI.createElement("rectangle", { - x: 400, - width: 200, - height: 500, - }); - const arrow = UI.createElement("arrow", { - x: 210, - y: 250, - width: 180, - height: 1, - }); - expect(arrow.startBinding?.elementId).toBe(rectLeft.id); - expect(arrow.endBinding?.elementId).toBe(rectRight.id); + describe("when arrow is outside of shape", () => { + beforeEach(async () => { + mouse.reset(); - const rotation = getTransformHandles( - arrow, - h.state.zoom, - arrayToMap(h.elements), - "mouse", - ).rotation!; - const rotationHandleX = rotation[0] + rotation[2] / 2; - const rotationHandleY = rotation[1] + rotation[3] / 2; - mouse.down(rotationHandleX, rotationHandleY); - mouse.move(300, 400); - mouse.up(); - expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI); - expect(arrow.angle).toBeLessThan(1.3 * Math.PI); - expect(arrow.startBinding?.elementId).toBe(rectRight.id); - expect(arrow.endBinding?.elementId).toBe(rectLeft.id); + await act(() => { + return setLanguage(defaultLang); + }); + await render(); + }); + + it("should handle new arrow start point binding", () => { + // Create a rectangle + UI.clickTool("rectangle"); + mouse.downAt(100, 100); + mouse.moveTo(200, 200); + mouse.up(); + + const rectangle = API.getSelectedElement(); + + // Create arrow with arrow tool + UI.clickTool("arrow"); + mouse.downAt(205, 150); // Start close to rectangle + mouse.moveTo(250, 150); // End outside + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + + // Arrow should have start binding to rectangle + expect(arrow.startBinding?.elementId).toBe(rectangle.id); + expect(arrow.startBinding?.mode).toBe("orbit"); // Default is orbit, not inside + expect(arrow.endBinding).toBeNull(); + }); + + it("should handle new arrow end point binding", () => { + // Create a rectangle + UI.clickTool("rectangle"); + mouse.downAt(100, 100); + mouse.moveTo(200, 200); + mouse.up(); + + const rectangle = API.getSelectedElement(); + + // Create arrow with end point in binding zone + UI.clickTool("arrow"); + mouse.downAt(50, 150); // Start outside + mouse.moveTo(95, 95); // End near rectangle edge (should bind as orbit) + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + + // Arrow should have end binding to rectangle + expect(arrow.endBinding?.elementId).toBe(rectangle.id); + expect(arrow.endBinding?.mode).toBe("orbit"); + expect(arrow.startBinding).toBeNull(); + }); + + it.skip("should create orbit binding when one of the cursor is inside rectangle", () => { + // Create a filled solid rectangle + UI.clickTool("rectangle"); + mouse.downAt(100, 100); + mouse.moveTo(200, 200); + mouse.up(); + + const rect = API.getSelectedElement(); + API.updateElement(rect, { + fillStyle: "solid", + backgroundColor: "#a5d8ff", + }); + + // Draw arrow with endpoint inside the filled rectangle, since only + // filled bindables bind inside the shape + UI.clickTool("arrow"); + mouse.downAt(10, 10); + mouse.moveTo(160, 160); + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + expect(arrow.x).toBe(10); + expect(arrow.y).toBe(10); + expect(arrow.width).toBeCloseTo(85.75985931287957); + expect(arrow.height).toBeCloseTo(85.75985931288186); + + // Should bind to the rectangle since endpoint is inside + expect(arrow.startBinding).toBe(null); + expect(arrow.endBinding?.elementId).toBe(rect.id); + + const endBinding = arrow.endBinding as FixedPointBinding; + expect(endBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0); + expect(endBinding.fixedPoint[0]).toBeLessThanOrEqual(1); + expect(endBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0); + expect(endBinding.fixedPoint[1]).toBeLessThanOrEqual(1); + + mouse.reset(); + + // Move the bindable + mouse.downAt(130, 110); + mouse.moveTo(280, 110); + mouse.up(); + + // Check if the arrow moved + expect(arrow.x).toBe(10); + expect(arrow.y).toBe(10); + expect(arrow.width).toBeCloseTo(234); + expect(arrow.height).toBeCloseTo(117); + + // Restore bindable + mouse.reset(); + mouse.downAt(280, 110); + mouse.moveTo(130, 110); + mouse.up(); + + // Move the arrow out + mouse.reset(); + mouse.click(10, 10); + mouse.downAt(96.466, 96.466); + mouse.moveTo(50, 50); + mouse.up(); + + expect(arrow.startBinding).toBe(null); + expect(arrow.endBinding).toBe(null); + + // Re-bind the arrow by moving the cursor inside the rectangle + mouse.reset(); + mouse.downAt(50, 50); + mouse.moveTo(150, 150); + mouse.up(); + + // Check if the arrow is still on the outside + expect(arrow.width).toBeCloseTo(86, 0); + expect(arrow.height).toBeCloseTo(86, 0); + }); }); - // TODO fix & reenable once we rewrite tests to work with concurrency - it.skip( - "editing arrow and moving its head to bind it to element A, finalizing the" + - "editing by clicking on element A should end up selecting A", - async () => { - UI.createElement("rectangle", { + describe("additional binding behavior", () => { + beforeEach(async () => { + mouse.reset(); + + await act(() => { + return setLanguage(defaultLang); + }); + await render(); + }); + + it( + "editing arrow and moving its head to bind it to element A, finalizing the" + + "editing by clicking on element A should end up selecting A", + async () => { + UI.createElement("rectangle", { + y: 0, + size: 100, + }); + // Create arrow bound to rectangle + UI.clickTool("arrow"); + mouse.down(50, -100); + mouse.up(0, 80); + + // Edit arrow + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ENTER); + }); + + // move arrow head + mouse.down(); + mouse.up(0, 10); + expect(API.getSelectedElement().type).toBe("arrow"); + + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + mouse.reset(); + mouse.clickAt(-50, -50); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); + expect(API.getSelectedElement().type).toBe("arrow"); + + // Edit arrow + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ENTER); + }); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + mouse.reset(); + mouse.clickAt(0, 0); + expect(h.state.selectedLinearElement).toBeNull(); + expect(API.getSelectedElement().type).toBe("rectangle"); + }, + ); + + it("should unbind on bound element deletion", () => { + const rectangle = UI.createElement("rectangle", { + x: 60, y: 0, size: 100, }); - // Create arrow bound to rectangle + + const arrow = UI.createElement("arrow", { + x: 0, + y: 5, + size: 70, + }); + + expect(arrow.endBinding?.elementId).toBe(rectangle.id); + + mouse.select(rectangle); + expect(API.getSelectedElement().type).toBe("rectangle"); + Keyboard.keyDown(KEYS.DELETE); + expect(arrow.endBinding).toBe(null); + }); + + it("should unbind arrow when arrow is resized", () => { + const rectLeft = UI.createElement("rectangle", { + x: 0, + width: 200, + height: 500, + }); + const rectRight = UI.createElement("rectangle", { + x: 400, + width: 200, + height: 500, + }); UI.clickTool("arrow"); - mouse.down(50, -100); - mouse.up(0, 80); - - // Edit arrow with multi-point - mouse.doubleClick(); - // move arrow head - mouse.down(); - mouse.up(0, 10); - expect(API.getSelectedElement().type).toBe("arrow"); - - // NOTE this mouse down/up + await needs to be done in order to repro - // the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740 mouse.reset(); - expect(h.state.selectedLinearElement?.isEditing).toBe(true); - mouse.down(0, 0); - await new Promise((r) => setTimeout(r, 100)); - expect(h.state.selectedLinearElement?.isEditing).toBe(false); - expect(API.getSelectedElement().type).toBe("rectangle"); + mouse.clickAt(190, 250); + mouse.moveTo(220, 200); + mouse.moveTo(300, 200); + mouse.clickAt(300, 200); + mouse.moveTo(340, 251); + mouse.moveTo(410, 251); + mouse.clickAt(410, 251); + const arrow = h.elements[h.elements.length - 1] as any; + + expect(arrow.startBinding?.elementId).toBe(rectLeft.id); + expect(arrow.endBinding?.elementId).toBe(rectRight.id); + + // Drag arrow off of bound rectangle range + const handles = getTransformHandles( + arrow, + h.state.zoom, + arrayToMap(h.elements), + "mouse", + ).se!; + + const elX = handles[0] + handles[2] / 2; + const elY = handles[1] + handles[3] / 2; + mouse.downAt(elX, elY); + mouse.moveTo(300, 400); mouse.up(); - expect(API.getSelectedElement().type).toBe("rectangle"); - }, - ); - it("should unbind arrow when moving it with keyboard", () => { - const rectangle = UI.createElement("rectangle", { - x: 75, - y: 0, - size: 100, + expect(arrow.startBinding).toBe(null); + expect(arrow.endBinding).toBe(null); }); - // Creates arrow 1px away from bidding with rectangle - const arrow = UI.createElement("arrow", { - x: 0, - y: 0, - size: 49, + it("should unbind arrow when arrow is rotated", () => { + const rectLeft = UI.createElement("rectangle", { + x: 0, + width: 200, + height: 500, + }); + const rectRight = UI.createElement("rectangle", { + x: 400, + width: 200, + height: 500, + }); + + UI.clickTool("arrow"); + mouse.reset(); + mouse.clickAt(190, 250); + mouse.moveTo(220, 200); + mouse.moveTo(300, 200); + mouse.clickAt(300, 200); + mouse.moveTo(350, 251); + mouse.moveTo(410, 251); + mouse.clickAt(410, 251); + + const arrow = API.getSelectedElement() as ExcalidrawArrowElement; + + expect(arrow.startBinding?.elementId).toBe(rectLeft.id); + expect(arrow.endBinding?.elementId).toBe(rectRight.id); + + const rotation = getTransformHandles( + arrow, + h.state.zoom, + arrayToMap(h.elements), + "mouse", + ).rotation!; + const rotationHandleX = rotation[0] + rotation[2] / 2; + const rotationHandleY = rotation[1] + rotation[3] / 2; + mouse.reset(); + mouse.down(rotationHandleX, rotationHandleY); + mouse.move(300, 400); + mouse.up(); + expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI); + expect(arrow.angle).toBeLessThan(1.3 * Math.PI); + expect(arrow.startBinding).toBeNull(); + expect(arrow.endBinding).toBeNull(); }); - expect(arrow.endBinding).toBe(null); + it("should not unbind when duplicating via selection group", () => { + const rectLeft = UI.createElement("rectangle", { + x: 0, + width: 200, + height: 500, + }); + const rectRight = UI.createElement("rectangle", { + x: 400, + y: 200, + width: 200, + height: 500, + }); + const arrow = UI.createElement("arrow", { + x: 190, + y: 250, + width: 217, + height: 1, + }); + expect(arrow.startBinding?.elementId).toBe(rectLeft.id); + expect(arrow.endBinding?.elementId).toBe(rectRight.id); - mouse.downAt(49, 49); - mouse.moveTo(51, 0); - mouse.up(0, 0); - - // Test sticky connection - expect(API.getSelectedElement().type).toBe("arrow"); - Keyboard.keyPress(KEYS.ARROW_RIGHT); - expect(arrow.endBinding?.elementId).toBe(rectangle.id); - Keyboard.keyPress(KEYS.ARROW_LEFT); - expect(arrow.endBinding?.elementId).toBe(rectangle.id); - - // Sever connection - expect(API.getSelectedElement().type).toBe("arrow"); - Keyboard.keyPress(KEYS.ARROW_LEFT); - expect(arrow.endBinding).toBe(null); - Keyboard.keyPress(KEYS.ARROW_RIGHT); - expect(arrow.endBinding).toBe(null); - }); - - it("should unbind on bound element deletion", () => { - const rectangle = UI.createElement("rectangle", { - x: 60, - y: 0, - size: 100, - }); - - const arrow = UI.createElement("arrow", { - x: 0, - y: 0, - size: 50, - }); - - expect(arrow.endBinding?.elementId).toBe(rectangle.id); - - mouse.select(rectangle); - expect(API.getSelectedElement().type).toBe("rectangle"); - Keyboard.keyDown(KEYS.DELETE); - expect(arrow.endBinding).toBe(null); - }); - - it("should unbind on text element deletion by submitting empty text", async () => { - const text = API.createElement({ - type: "text", - text: "ola", - x: 60, - y: 0, - width: 100, - height: 100, - }); - - API.setElements([text]); - - const arrow = UI.createElement("arrow", { - x: 0, - y: 0, - size: 50, - }); - - expect(arrow.endBinding?.elementId).toBe(text.id); - - // edit text element and submit - // ------------------------------------------------------------------------- - - UI.clickTool("text"); - - mouse.clickAt(text.x + 50, text.y + 50); - - const editor = await getTextEditor(); - - fireEvent.change(editor, { target: { value: "" } }); - fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); - - expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); - expect(arrow.endBinding).toBe(null); - }); - - it("should keep binding on text update", async () => { - const text = API.createElement({ - type: "text", - text: "ola", - x: 60, - y: 0, - width: 100, - height: 100, - }); - - API.setElements([text]); - - const arrow = UI.createElement("arrow", { - x: 0, - y: 0, - size: 50, - }); - - expect(arrow.endBinding?.elementId).toBe(text.id); - - // delete text element by submitting empty text - // ------------------------------------------------------------------------- - - UI.clickTool("text"); - - mouse.clickAt(text.x + 50, text.y + 50); - const editor = await getTextEditor(); - - expect(editor).not.toBe(null); - - fireEvent.change(editor, { target: { value: "asdasdasdasdas" } }); - fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); - - expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); - expect(arrow.endBinding?.elementId).toBe(text.id); - }); - - it("should update binding when text containerized", async () => { - const rectangle1 = API.createElement({ - type: "rectangle", - id: "rectangle1", - width: 100, - height: 100, - boundElements: [ - { id: "arrow1", type: "arrow" }, - { id: "arrow2", type: "arrow" }, - ], - }); - - const arrow1 = API.createElement({ - type: "arrow", - id: "arrow1", - points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)], - startBinding: { - elementId: "rectangle1", - focus: 0.2, - gap: 7, - fixedPoint: [0.5, 1], - }, - endBinding: { - elementId: "text1", - focus: 0.2, - gap: 7, - fixedPoint: [1, 0.5], - }, - }); - - const arrow2 = API.createElement({ - type: "arrow", - id: "arrow2", - points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)], - startBinding: { - elementId: "text1", - focus: 0.2, - gap: 7, - fixedPoint: [0.5, 1], - }, - endBinding: { - elementId: "rectangle1", - focus: 0.2, - gap: 7, - fixedPoint: [1, 0.5], - }, - }); - - const text1 = API.createElement({ - type: "text", - id: "text1", - text: "ola", - boundElements: [ - { id: "arrow1", type: "arrow" }, - { id: "arrow2", type: "arrow" }, - ], - }); - - API.setElements([rectangle1, arrow1, arrow2, text1]); - - API.setSelectedElements([text1]); - - expect(h.state.selectedElementIds[text1.id]).toBe(true); - - API.executeAction(actionWrapTextInContainer); - - // new text container will be placed before the text element - const container = h.elements.at(-2)!; - - expect(container.type).toBe("rectangle"); - expect(container.id).not.toBe(rectangle1.id); - - expect(container).toEqual( - expect.objectContaining({ - boundElements: expect.arrayContaining([ - { - type: "text", - id: text1.id, - }, - { - type: "arrow", - id: arrow1.id, - }, - { - type: "arrow", - id: arrow2.id, - }, - ]), - }), - ); - - expect(arrow1.startBinding?.elementId).toBe(rectangle1.id); - expect(arrow1.endBinding?.elementId).toBe(container.id); - expect(arrow2.startBinding?.elementId).toBe(container.id); - expect(arrow2.endBinding?.elementId).toBe(rectangle1.id); - }); - - // #6459 - it("should unbind arrow only from the latest element", () => { - const rectLeft = UI.createElement("rectangle", { - x: 0, - width: 200, - height: 500, - }); - const rectRight = UI.createElement("rectangle", { - x: 400, - width: 200, - height: 500, - }); - const arrow = UI.createElement("arrow", { - x: 210, - y: 250, - width: 180, - height: 1, - }); - expect(arrow.startBinding?.elementId).toBe(rectLeft.id); - expect(arrow.endBinding?.elementId).toBe(rectRight.id); - - // Drag arrow off of bound rectangle range - const [elX, elY] = LinearElementEditor.getPointAtIndexGlobalCoordinates( - arrow, - -1, - h.scene.getNonDeletedElementsMap(), - ); - Keyboard.keyDown(KEYS.CTRL_OR_CMD); - mouse.downAt(elX, elY); - mouse.moveTo(300, 400); - mouse.up(); - - expect(arrow.startBinding).not.toBe(null); - expect(arrow.endBinding).toBe(null); - }); - - it("should not unbind when duplicating via selection group", () => { - const rectLeft = UI.createElement("rectangle", { - x: 0, - width: 200, - height: 500, - }); - const rectRight = UI.createElement("rectangle", { - x: 400, - y: 200, - width: 200, - height: 500, - }); - const arrow = UI.createElement("arrow", { - x: 210, - y: 250, - width: 177, - height: 1, - }); - expect(arrow.startBinding?.elementId).toBe(rectLeft.id); - expect(arrow.endBinding?.elementId).toBe(rectRight.id); - - mouse.downAt(-100, -100); - mouse.moveTo(650, 750); - mouse.up(0, 0); - - expect(API.getSelectedElements().length).toBe(3); - - mouse.moveTo(5, 5); - Keyboard.withModifierKeys({ alt: true }, () => { - mouse.downAt(5, 5); - mouse.moveTo(1000, 1000); + mouse.downAt(-100, -100); + mouse.moveTo(650, 750); mouse.up(0, 0); - expect(window.h.elements.length).toBe(6); - window.h.elements.forEach((element) => { - if (isLinearElement(element)) { - expect(element.startBinding).not.toBe(null); - expect(element.endBinding).not.toBe(null); - } else { - expect(element.boundElements).not.toBe(null); - } + expect(API.getSelectedElements().length).toBe(3); + + mouse.moveTo(5, 5); + Keyboard.withModifierKeys({ alt: true }, () => { + mouse.downAt(5, 5); + mouse.moveTo(1000, 1000); + mouse.up(0, 0); + + expect(window.h.elements.length).toBe(6); + window.h.elements.forEach((element) => { + if (isLinearElement(element)) { + expect(element.startBinding).not.toBe(null); + expect(element.endBinding).not.toBe(null); + } else { + expect(element.boundElements).not.toBe(null); + } + }); }); }); }); + + describe("to text elements", () => { + beforeEach(async () => { + mouse.reset(); + + await act(() => { + return setLanguage(defaultLang); + }); + await render(); + }); + + it("should update binding when text containerized", async () => { + const rectangle1 = API.createElement({ + type: "rectangle", + id: "rectangle1", + width: 100, + height: 100, + boundElements: [ + { id: "arrow1", type: "arrow" }, + { id: "arrow2", type: "arrow" }, + ], + }); + + const arrow1 = API.createElement({ + type: "arrow", + id: "arrow1", + points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)], + startBinding: { + elementId: "rectangle1", + fixedPoint: [0.5, 1], + mode: "orbit", + }, + endBinding: { + elementId: "text1", + fixedPoint: [1, 0.5], + mode: "orbit", + }, + }); + + const arrow2 = API.createElement({ + type: "arrow", + id: "arrow2", + points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)], + startBinding: { + elementId: "text1", + fixedPoint: [0.5, 1], + mode: "orbit", + }, + endBinding: { + elementId: "rectangle1", + fixedPoint: [1, 0.5], + mode: "orbit", + }, + }); + + const text1 = API.createElement({ + type: "text", + id: "text1", + text: "ola", + boundElements: [ + { id: "arrow1", type: "arrow" }, + { id: "arrow2", type: "arrow" }, + ], + }); + + API.setElements([rectangle1, arrow1, arrow2, text1]); + + API.setSelectedElements([text1]); + + expect(h.state.selectedElementIds[text1.id]).toBe(true); + + API.executeAction(actionWrapTextInContainer); + + // new text container will be placed before the text element + const container = h.elements.at(-2)!; + + expect(container.type).toBe("rectangle"); + expect(container.id).not.toBe(rectangle1.id); + + expect(container).toEqual( + expect.objectContaining({ + boundElements: expect.arrayContaining([ + { + type: "text", + id: text1.id, + }, + { + type: "arrow", + id: arrow1.id, + }, + { + type: "arrow", + id: arrow2.id, + }, + ]), + }), + ); + + expect(arrow1.startBinding?.elementId).toBe(rectangle1.id); + expect(arrow1.endBinding?.elementId).toBe(container.id); + expect(arrow2.startBinding?.elementId).toBe(container.id); + expect(arrow2.endBinding?.elementId).toBe(rectangle1.id); + }); + + it("should keep binding on text update", async () => { + const text = API.createElement({ + type: "text", + text: "ola", + x: 60, + y: 0, + width: 100, + height: 100, + }); + + API.setElements([text]); + + const arrow = UI.createElement("arrow", { + x: 0, + y: 0, + size: 65, + }); + + expect(arrow.endBinding?.elementId).toBe(text.id); + + // delete text element by submitting empty text + // ------------------------------------------------------------------------- + + UI.clickTool("text"); + + mouse.clickAt(text.x + 50, text.y + 50); + const editor = await getTextEditor(); + + expect(editor).not.toBe(null); + + fireEvent.change(editor, { target: { value: "asdasdasdasdas" } }); + fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); + + expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); + expect(arrow.endBinding?.elementId).toBe(text.id); + }); + + it("should unbind on text element deletion by submitting empty text", async () => { + const text = API.createElement({ + type: "text", + text: "¡olá!", + x: 60, + y: 0, + width: 100, + height: 100, + }); + + API.setElements([text]); + + const arrow = UI.createElement("arrow", { + x: 0, + y: 0, + size: 65, + }); + + expect(arrow.endBinding?.elementId).toBe(text.id); + + // edit text element and submit + // ------------------------------------------------------------------------- + + UI.clickTool("text"); + + mouse.clickAt(text.x + 50, text.y + 50); + + const editor = await getTextEditor(); + + fireEvent.change(editor, { target: { value: "" } }); + fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); + + expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); + expect(arrow.endBinding).toBe(null); + }); + }); }); diff --git a/packages/element/tests/bounds.test.ts b/packages/element/tests/bounds.test.ts index 22c669f28a..1c7cd3cd28 100644 --- a/packages/element/tests/bounds.test.ts +++ b/packages/element/tests/bounds.test.ts @@ -135,9 +135,9 @@ describe("getElementBounds", () => { } as ExcalidrawLinearElement; const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); - expect(x1).toEqual(360.3176068760539); - expect(y1).toEqual(185.90654264413516); - expect(x2).toEqual(480.87005902729743); - expect(y2).toEqual(320.4751269334226); + expect(x1).toEqual(360.9291017525165); + expect(y1).toEqual(185.24770129343722); + expect(x2).toEqual(481.4815539037601); + expect(y2).toEqual(319.8162855827246); }); }); diff --git a/packages/element/tests/duplicate.test.tsx b/packages/element/tests/duplicate.test.tsx index 10b9346a6c..b8c5bede27 100644 --- a/packages/element/tests/duplicate.test.tsx +++ b/packages/element/tests/duplicate.test.tsx @@ -144,9 +144,8 @@ describe("duplicating multiple elements", () => { id: "arrow1", startBinding: { elementId: "rectangle1", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, }); @@ -155,9 +154,8 @@ describe("duplicating multiple elements", () => { id: "arrow2", endBinding: { elementId: "rectangle1", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, boundElements: [{ id: "text2", type: "text" }], }); @@ -276,9 +274,8 @@ describe("duplicating multiple elements", () => { id: "arrow1", startBinding: { elementId: "rectangle1", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, }); @@ -293,15 +290,13 @@ describe("duplicating multiple elements", () => { id: "arrow2", startBinding: { elementId: "rectangle1", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, endBinding: { elementId: "rectangle-not-exists", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, }); @@ -310,15 +305,13 @@ describe("duplicating multiple elements", () => { id: "arrow3", startBinding: { elementId: "rectangle-not-exists", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, endBinding: { elementId: "rectangle1", - focus: 0.2, - gap: 7, fixedPoint: [0.5, 1], + mode: "orbit", }, }); @@ -821,7 +814,7 @@ describe("duplication z-order", () => { const arrow = UI.createElement("arrow", { x: -100, y: 50, - width: 95, + width: 115, height: 0, }); diff --git a/packages/element/tests/elbowArrow.test.tsx b/packages/element/tests/elbowArrow.test.tsx index b279e596c2..2993e32158 100644 --- a/packages/element/tests/elbowArrow.test.tsx +++ b/packages/element/tests/elbowArrow.test.tsx @@ -1,13 +1,10 @@ import { ARROW_TYPE } from "@excalidraw/common"; import { pointFrom } from "@excalidraw/math"; import { Excalidraw } from "@excalidraw/excalidraw"; - import { actionSelectAll } from "@excalidraw/excalidraw/actions"; import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection"; - import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui"; - import { act, fireEvent, @@ -15,13 +12,11 @@ import { queryByTestId, render, } from "@excalidraw/excalidraw/tests/test-utils"; - import "@excalidraw/utils/test-utils"; +import { bindBindingElement } from "@excalidraw/element"; import type { LocalPoint } from "@excalidraw/math"; -import { bindLinearElement } from "../src/binding"; - import { Scene } from "../src/Scene"; import type { @@ -136,6 +131,11 @@ describe("elbow arrow segment move", () => { }); describe("elbow arrow routing", () => { + beforeEach(async () => { + localStorage.clear(); + await render(); + }); + it("can properly generate orthogonal arrow points", () => { const scene = new Scene(); const arrow = API.createElement({ @@ -160,8 +160,8 @@ describe("elbow arrow routing", () => { expect(arrow.width).toEqual(90); expect(arrow.height).toEqual(200); }); + it("can generate proper points for bound elbow arrow", () => { - const scene = new Scene(); const rectangle1 = API.createElement({ type: "rectangle", x: -150, @@ -185,25 +185,23 @@ describe("elbow arrow routing", () => { height: 200, points: [pointFrom(0, 0), pointFrom(90, 200)], }) as ExcalidrawElbowArrowElement; - scene.insertElement(rectangle1); - scene.insertElement(rectangle2); - scene.insertElement(arrow); + API.setElements([rectangle1, rectangle2, arrow]); - bindLinearElement(arrow, rectangle1, "start", scene); - bindLinearElement(arrow, rectangle2, "end", scene); + bindBindingElement(arrow, rectangle1, "orbit", "start", h.scene); + bindBindingElement(arrow, rectangle2, "orbit", "end", h.scene); expect(arrow.startBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null); - h.app.scene.mutateElement(arrow, { + h.scene.mutateElement(arrow, { points: [pointFrom(0, 0), pointFrom(90, 200)], }); - expect(arrow.points).toEqual([ + expect(arrow.points).toCloselyEqualPoints([ [0, 0], - [45, 0], - [45, 200], - [90, 200], + [39, 0], + [39, 200], + [78, 200], ]); }); }); @@ -242,9 +240,9 @@ describe("elbow arrow ui", () => { expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow); mouse.reset(); - mouse.moveTo(-43, -99); + mouse.moveTo(-53, -99); mouse.click(); - mouse.moveTo(43, 99); + mouse.moveTo(53, 99); mouse.click(); const arrow = h.scene.getSelectedElements( @@ -253,11 +251,11 @@ describe("elbow arrow ui", () => { expect(arrow.type).toBe("arrow"); expect(arrow.elbowed).toBe(true); - expect(arrow.points).toEqual([ + expect(arrow.points).toCloselyEqualPoints([ [0, 0], - [45, 0], - [45, 200], - [90, 200], + [39, 0], + [39, 200], + [78, 200], ]); }); @@ -279,9 +277,9 @@ describe("elbow arrow ui", () => { UI.clickOnTestId("elbow-arrow"); mouse.reset(); - mouse.moveTo(-43, -99); + mouse.moveTo(-53, -99); mouse.click(); - mouse.moveTo(43, 99); + mouse.moveTo(53, 99); mouse.click(); const arrow = h.scene.getSelectedElements( @@ -297,9 +295,11 @@ describe("elbow arrow ui", () => { expect(arrow.points.map((point) => point.map(Math.round))).toEqual([ [0, 0], - [35, 0], - [35, 165], - [103, 165], + [36, 0], + [36, 90], + [28, 90], + [28, 164], + [101, 164], ]); }); @@ -321,9 +321,9 @@ describe("elbow arrow ui", () => { UI.clickOnTestId("elbow-arrow"); mouse.reset(); - mouse.moveTo(-43, -99); + mouse.moveTo(-53, -99); mouse.click(); - mouse.moveTo(43, 99); + mouse.moveTo(53, 99); mouse.click(); const arrow = h.scene.getSelectedElements( @@ -351,11 +351,11 @@ describe("elbow arrow ui", () => { expect(duplicatedArrow.id).not.toBe(originalArrowId); expect(duplicatedArrow.type).toBe("arrow"); expect(duplicatedArrow.elbowed).toBe(true); - expect(duplicatedArrow.points).toEqual([ + expect(duplicatedArrow.points).toCloselyEqualPoints([ [0, 0], - [45, 0], - [45, 200], - [90, 200], + [39, 0], + [39, 200], + [78, 200], ]); expect(arrow.startBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null); @@ -379,9 +379,9 @@ describe("elbow arrow ui", () => { UI.clickOnTestId("elbow-arrow"); mouse.reset(); - mouse.moveTo(-43, -99); + mouse.moveTo(-53, -99); mouse.click(); - mouse.moveTo(43, 99); + mouse.moveTo(53, 99); mouse.click(); const arrow = h.scene.getSelectedElements( @@ -405,11 +405,11 @@ describe("elbow arrow ui", () => { expect(duplicatedArrow.id).not.toBe(originalArrowId); expect(duplicatedArrow.type).toBe("arrow"); expect(duplicatedArrow.elbowed).toBe(true); - expect(duplicatedArrow.points).toEqual([ + expect(duplicatedArrow.points).toCloselyEqualPoints([ [0, 0], [0, 100], - [90, 100], - [90, 200], + [78, 100], + [78, 200], ]); }); }); diff --git a/packages/element/tests/linearElementEditor.test.tsx b/packages/element/tests/linearElementEditor.test.tsx index f1306b8728..5759c591dd 100644 --- a/packages/element/tests/linearElementEditor.test.tsx +++ b/packages/element/tests/linearElementEditor.test.tsx @@ -217,7 +217,7 @@ describe("Test Linear Elements", () => { // drag line from midpoint drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta)); - expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`9`); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(line.points.length).toEqual(3); expect(line.points).toMatchInlineSnapshot(` @@ -329,7 +329,7 @@ describe("Test Linear Elements", () => { expect(h.state.selectedLinearElement?.isEditing).toBe(false); mouse.doubleClick(); - expect(h.state.selectedLinearElement).toBe(null); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); await getTextEditor(); }); @@ -357,6 +357,7 @@ describe("Test Linear Elements", () => { const originalY = line.y; enterLineEditingMode(line); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); expect(line.points.length).toEqual(2); mouse.clickAt(midpoint[0], midpoint[1]); @@ -379,7 +380,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `11`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(line.points.length).toEqual(3); expect(line.points).toMatchInlineSnapshot(` @@ -549,7 +550,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `14`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(line.points.length).toEqual(5); @@ -600,7 +601,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `11`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates( line, @@ -641,7 +642,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `11`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates( line, @@ -689,7 +690,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `17`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`); const newMidPoints = LinearElementEditor.getEditorMidPoints( line, @@ -747,7 +748,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `14`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(line.points.length).toEqual(5); expect((h.elements[0] as ExcalidrawLinearElement).points) @@ -845,7 +846,7 @@ describe("Test Linear Elements", () => { expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `11`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates( line, @@ -1303,7 +1304,7 @@ describe("Test Linear Elements", () => { const arrow = UI.createElement("arrow", { x: -10, y: 250, - width: 400, + width: 410, height: 1, }); @@ -1316,7 +1317,7 @@ describe("Test Linear Elements", () => { const textElement = h.elements[2] as ExcalidrawTextElementWithContainer; expect(arrow.endBinding?.elementId).toBe(rect.id); - expect(arrow.width).toBe(400); + expect(arrow.width).toBeCloseTo(399); expect(rect.x).toBe(400); expect(rect.y).toBe(0); expect( @@ -1335,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(200, 0); + expect(arrow.width).toBeCloseTo(199); expect(rect.x).toBe(200); expect(rect.y).toBe(0); expect(handleBindTextResizeSpy).toHaveBeenCalledWith( diff --git a/packages/element/tests/resize.test.tsx b/packages/element/tests/resize.test.tsx index 1d0b6ac0b2..ab836d6a0f 100644 --- a/packages/element/tests/resize.test.tsx +++ b/packages/element/tests/resize.test.tsx @@ -174,29 +174,29 @@ describe("generic element", () => { expect(rectangle.angle).toBeCloseTo(0); }); - it("resizes with bound arrow", async () => { - const rectangle = UI.createElement("rectangle", { - width: 200, - height: 100, - }); - const arrow = UI.createElement("arrow", { - x: -30, - y: 50, - width: 28, - height: 5, - }); + // it("resizes with bound arrow", async () => { + // const rectangle = UI.createElement("rectangle", { + // width: 200, + // height: 100, + // }); + // const arrow = UI.createElement("arrow", { + // x: -30, + // y: 50, + // width: 28, + // height: 5, + // }); - expect(arrow.endBinding?.elementId).toEqual(rectangle.id); + // expect(arrow.endBinding?.elementId).toEqual(rectangle.id); - UI.resize(rectangle, "e", [40, 0]); + // UI.resize(rectangle, "e", [40, 0]); - expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0); + // expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0); - UI.resize(rectangle, "w", [50, 0]); + // UI.resize(rectangle, "w", [50, 0]); - expect(arrow.endBinding?.elementId).toEqual(rectangle.id); - expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0); - }); + // expect(arrow.endBinding?.elementId).toEqual(rectangle.id); + // expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0); + // }); it("resizes with a label", async () => { const rectangle = UI.createElement("rectangle", { @@ -510,12 +510,12 @@ describe("arrow element", () => { h.state, )[0] as ExcalidrawElbowArrowElement; - expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05); + expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); UI.resize(rectangle, "se", [-200, -150]); - expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05); + expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); }); @@ -538,11 +538,11 @@ describe("arrow element", () => { h.state, )[0] as ExcalidrawElbowArrowElement; - expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05); + expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); UI.resize([rectangle, arrow], "nw", [300, 350]); - expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05); + expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.06); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25); }); }); @@ -595,31 +595,31 @@ describe("text element", () => { expect(text.fontSize).toBeCloseTo(fontSize * scale); }); - it("resizes with bound arrow", async () => { - const text = UI.createElement("text"); - await UI.editText(text, "hello\nworld"); - const boundArrow = UI.createElement("arrow", { - x: -30, - y: 25, - width: 28, - height: 5, - }); + // it("resizes with bound arrow", async () => { + // const text = UI.createElement("text"); + // await UI.editText(text, "hello\nworld"); + // const boundArrow = UI.createElement("arrow", { + // x: -30, + // y: 25, + // width: 28, + // height: 5, + // }); - expect(boundArrow.endBinding?.elementId).toEqual(text.id); + // expect(boundArrow.endBinding?.elementId).toEqual(text.id); - UI.resize(text, "ne", [40, 0]); + // UI.resize(text, "ne", [40, 0]); - expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(30); + // expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(30); - const textWidth = text.width; - const scale = 20 / text.height; - UI.resize(text, "nw", [50, 20]); + // const textWidth = text.width; + // const scale = 20 / text.height; + // UI.resize(text, "nw", [50, 20]); - expect(boundArrow.endBinding?.elementId).toEqual(text.id); - expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo( - 30 + textWidth * scale, - ); - }); + // expect(boundArrow.endBinding?.elementId).toEqual(text.id); + // expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo( + // 30 + textWidth * scale, + // ); + // }); it("updates font size via keyboard", async () => { const text = UI.createElement("text"); @@ -801,36 +801,36 @@ describe("image element", () => { expect(image.scale).toEqual([1, 1]); }); - it("resizes with bound arrow", async () => { - const image = API.createElement({ - type: "image", - width: 100, - height: 100, - }); - API.setElements([image]); - const arrow = UI.createElement("arrow", { - x: -30, - y: 50, - width: 28, - height: 5, - }); + // it("resizes with bound arrow", async () => { + // const image = API.createElement({ + // type: "image", + // width: 100, + // height: 100, + // }); + // API.setElements([image]); + // const arrow = UI.createElement("arrow", { + // x: -30, + // y: 50, + // width: 28, + // height: 5, + // }); - expect(arrow.endBinding?.elementId).toEqual(image.id); + // expect(arrow.endBinding?.elementId).toEqual(image.id); - UI.resize(image, "ne", [40, 0]); + // UI.resize(image, "ne", [40, 0]); - expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0); + // expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0); - const imageWidth = image.width; - const scale = 20 / image.height; - UI.resize(image, "nw", [50, 20]); + // const imageWidth = image.width; + // const scale = 20 / image.height; + // UI.resize(image, "nw", [50, 20]); - expect(arrow.endBinding?.elementId).toEqual(image.id); - expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo( - 30 + imageWidth * scale, - 0, - ); - }); + // expect(arrow.endBinding?.elementId).toEqual(image.id); + // expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo( + // 30 + imageWidth * scale, + // 0, + // ); + // }); }); describe("multiple selection", () => { @@ -997,68 +997,80 @@ describe("multiple selection", () => { expect(diagLine.angle).toEqual(0); }); - it("resizes with bound arrows", async () => { - const rectangle = UI.createElement("rectangle", { - position: 0, - size: 100, - }); - const leftBoundArrow = UI.createElement("arrow", { - x: -110, - y: 50, - width: 100, - height: 0, - }); + // it("resizes with bound arrows", async () => { + // const rectangle = UI.createElement("rectangle", { + // position: 0, + // size: 100, + // }); + // const leftBoundArrow = UI.createElement("arrow", { + // x: -110, + // y: 50, + // width: 100, + // height: 0, + // }); - const rightBoundArrow = UI.createElement("arrow", { - x: 210, - y: 50, - width: -100, - height: 0, - }); + // const rightBoundArrow = UI.createElement("arrow", { + // x: 210, + // y: 50, + // width: -100, + // height: 0, + // }); - const selectionWidth = 210; - const selectionHeight = 100; - const move = [40, 40] as [number, number]; - const scale = Math.max( - 1 - move[0] / selectionWidth, - 1 - move[1] / selectionHeight, - ); - const leftArrowBinding = { ...leftBoundArrow.endBinding }; - const rightArrowBinding = { ...rightBoundArrow.endBinding }; - delete rightArrowBinding.gap; + // const selectionWidth = 210; + // const selectionHeight = 100; + // const move = [40, 40] as [number, number]; + // const scale = Math.max( + // 1 - move[0] / selectionWidth, + // 1 - move[1] / selectionHeight, + // ); + // const leftArrowBinding: { + // elementId: string; + // gap?: number; + // focus?: number; + // } = { + // ...leftBoundArrow.endBinding, + // } as PointBinding; + // const rightArrowBinding: { + // elementId: string; + // gap?: number; + // focus?: number; + // } = { + // ...rightBoundArrow.endBinding, + // } as PointBinding; + // delete rightArrowBinding.gap; - UI.resize([rectangle, rightBoundArrow], "nw", move, { - shift: true, - }); + // UI.resize([rectangle, rightBoundArrow], "nw", move, { + // shift: true, + // }); - expect(leftBoundArrow.x).toBeCloseTo(-110); - expect(leftBoundArrow.y).toBeCloseTo(50); - expect(leftBoundArrow.width).toBeCloseTo(140, 0); - expect(leftBoundArrow.height).toBeCloseTo(7, 0); - expect(leftBoundArrow.angle).toEqual(0); - expect(leftBoundArrow.startBinding).toBeNull(); - expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10); - expect(leftBoundArrow.endBinding?.elementId).toBe( - leftArrowBinding.elementId, - ); - expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus); + // expect(leftBoundArrow.x).toBeCloseTo(-110); + // expect(leftBoundArrow.y).toBeCloseTo(50); + // expect(leftBoundArrow.width).toBeCloseTo(140, 0); + // expect(leftBoundArrow.height).toBeCloseTo(7, 0); + // expect(leftBoundArrow.angle).toEqual(0); + // expect(leftBoundArrow.startBinding).toBeNull(); + // expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10); + // expect(leftBoundArrow.endBinding?.elementId).toBe( + // leftArrowBinding.elementId, + // ); + // expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus); - expect(rightBoundArrow.x).toBeCloseTo(210); - expect(rightBoundArrow.y).toBeCloseTo( - (selectionHeight - 50) * (1 - scale) + 50, - ); - expect(rightBoundArrow.width).toBeCloseTo(100 * scale); - expect(rightBoundArrow.height).toBeCloseTo(0); - expect(rightBoundArrow.angle).toEqual(0); - expect(rightBoundArrow.startBinding).toBeNull(); - expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952); - expect(rightBoundArrow.endBinding?.elementId).toBe( - rightArrowBinding.elementId, - ); - expect(rightBoundArrow.endBinding?.focus).toBeCloseTo( - rightArrowBinding.focus!, - ); - }); + // expect(rightBoundArrow.x).toBeCloseTo(210); + // expect(rightBoundArrow.y).toBeCloseTo( + // (selectionHeight - 50) * (1 - scale) + 50, + // ); + // expect(rightBoundArrow.width).toBeCloseTo(100 * scale); + // expect(rightBoundArrow.height).toBeCloseTo(0); + // expect(rightBoundArrow.angle).toEqual(0); + // expect(rightBoundArrow.startBinding).toBeNull(); + // expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952); + // expect(rightBoundArrow.endBinding?.elementId).toBe( + // rightArrowBinding.elementId, + // ); + // expect(rightBoundArrow.endBinding?.focus).toBeCloseTo( + // rightArrowBinding.focus!, + // ); + // }); it("resizes with labeled arrows", async () => { const topArrow = UI.createElement("arrow", { @@ -1338,8 +1350,8 @@ describe("multiple selection", () => { expect(boundArrow.x).toBeCloseTo(380 * scaleX); expect(boundArrow.y).toBeCloseTo(240 * scaleY); - expect(boundArrow.points[1][0]).toBeCloseTo(-60 * scaleX); - expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY); + expect(boundArrow.points[1][0]).toBeCloseTo(59.7979); + expect(boundArrow.points[1][1]).toBeCloseTo(-79.7305); expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo( boundArrow.x + boundArrow.points[1][0] / 2, diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index b8f837b402..f9c57a2851 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -51,7 +51,7 @@ import { register } from "./register"; import type { AppState, Offsets } from "../types"; -export const actionChangeViewBackgroundColor = register({ +export const actionChangeViewBackgroundColor = register>({ name: "changeViewBackgroundColor", label: "labels.canvasBackground", trackEvent: false, @@ -64,7 +64,7 @@ export const actionChangeViewBackgroundColor = register({ perform: (_, appState, value) => { return { appState: { ...appState, ...value }, - captureUpdate: !!value.viewBackgroundColor + captureUpdate: !!value?.viewBackgroundColor ? CaptureUpdateAction.IMMEDIATELY : CaptureUpdateAction.EVENTUALLY, }; @@ -466,7 +466,7 @@ export const actionZoomToFit = register({ !event[KEYS.CTRL_OR_CMD], }); -export const actionToggleTheme = register({ +export const actionToggleTheme = register({ name: "toggleTheme", label: (_, appState) => { return appState.theme === THEME.DARK @@ -474,7 +474,8 @@ export const actionToggleTheme = register({ : "buttons.darkMode"; }, keywords: ["toggle", "dark", "light", "mode", "theme"], - icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon), + icon: (appState, elements) => + appState.theme === THEME.LIGHT ? MoonIcon : SunIcon, viewMode: true, trackEvent: { category: "canvas" }, perform: (_, appState, value) => { diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index d9b011d2bc..8d5ed2a30a 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -20,12 +20,12 @@ import { t } from "../i18n"; import { actionDeleteSelected } from "./actionDeleteSelected"; import { register } from "./register"; -export const actionCopy = register({ +export const actionCopy = register({ name: "copy", label: "labels.copy", icon: DuplicateIcon, trackEvent: { category: "element" }, - perform: async (elements, appState, event: ClipboardEvent | null, app) => { + perform: async (elements, appState, event, app) => { const elementsToCopy = app.scene.getSelectedElements({ selectedElementIds: appState.selectedElementIds, includeBoundTextElement: true, @@ -109,12 +109,12 @@ export const actionPaste = register({ keyTest: undefined, }); -export const actionCut = register({ +export const actionCut = register({ name: "cut", label: "labels.cut", icon: cutIcon, trackEvent: { category: "element" }, - perform: (elements, appState, event: ClipboardEvent | null, app) => { + perform: (elements, appState, event, app) => { actionCopy.perform(elements, appState, event, app); return actionDeleteSelected.perform(elements, appState, null, app); }, diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index cfc5e69e21..9821abadc8 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -212,12 +212,8 @@ export const actionDeleteSelected = register({ trackEvent: { category: "element", action: "delete" }, perform: (elements, appState, formData, app) => { if (appState.selectedLinearElement?.isEditing) { - const { - elementId, - selectedPointsIndices, - startBindingElement, - endBindingElement, - } = appState.selectedLinearElement; + const { elementId, selectedPointsIndices } = + appState.selectedLinearElement; const elementsMap = app.scene.getNonDeletedElementsMap(); const linearElement = LinearElementEditor.getElement( elementId, @@ -254,19 +250,6 @@ export const actionDeleteSelected = register({ }; } - // We cannot do this inside `movePoint` because it is also called - // when deleting the uncommitted point (which hasn't caused any binding) - const binding = { - startBindingElement: selectedPointsIndices?.includes(0) - ? null - : startBindingElement, - endBindingElement: selectedPointsIndices?.includes( - linearElement.points.length - 1, - ) - ? null - : endBindingElement, - }; - LinearElementEditor.deletePoints( linearElement, app, @@ -279,7 +262,6 @@ export const actionDeleteSelected = register({ ...appState, selectedLinearElement: { ...appState.selectedLinearElement, - ...binding, selectedPointsIndices: selectedPointsIndices?.[0] > 0 ? [selectedPointsIndices[0] - 1] @@ -308,6 +290,7 @@ export const actionDeleteSelected = register({ type: app.state.preferredSelectionTool.type, }), multiElement: null, + newElement: null, activeEmbeddable: null, selectedLinearElement: null, }, diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx index cf7a58a98a..e47a5bb84c 100644 --- a/packages/excalidraw/actions/actionExport.tsx +++ b/packages/excalidraw/actions/actionExport.tsx @@ -31,7 +31,9 @@ import "../components/ToolIcon.scss"; import { register } from "./register"; -export const actionChangeProjectName = register({ +import type { AppState } from "../types"; + +export const actionChangeProjectName = register({ name: "changeProjectName", label: "labels.fileTitle", trackEvent: false, @@ -51,7 +53,7 @@ export const actionChangeProjectName = register({ ), }); -export const actionChangeExportScale = register({ +export const actionChangeExportScale = register({ name: "changeExportScale", label: "imageExportDialog.scale", trackEvent: { category: "export", action: "scale" }, @@ -101,7 +103,9 @@ export const actionChangeExportScale = register({ }, }); -export const actionChangeExportBackground = register({ +export const actionChangeExportBackground = register< + AppState["exportBackground"] +>({ name: "changeExportBackground", label: "imageExportDialog.label.withBackground", trackEvent: { category: "export", action: "toggleBackground" }, @@ -121,7 +125,9 @@ export const actionChangeExportBackground = register({ ), }); -export const actionChangeExportEmbedScene = register({ +export const actionChangeExportEmbedScene = register< + AppState["exportEmbedScene"] +>({ name: "changeExportEmbedScene", label: "imageExportDialog.tooltip.embedScene", trackEvent: { category: "export", action: "embedScene" }, @@ -288,7 +294,9 @@ export const actionLoadScene = register({ keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O, }); -export const actionExportWithDarkMode = register({ +export const actionExportWithDarkMode = register< + AppState["exportWithDarkMode"] +>({ name: "exportWithDarkMode", label: "imageExportDialog.label.darkMode", trackEvent: { category: "export", action: "toggleTheme" }, diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 4e7ae67919..97d4f5655a 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -1,10 +1,6 @@ import { pointFrom } from "@excalidraw/math"; -import { - maybeBindLinearElement, - bindOrUnbindLinearElement, - isBindingEnabled, -} from "@excalidraw/element/binding"; +import { bindOrUnbindBindingElement } from "@excalidraw/element/binding"; import { isValidPolygon, LinearElementEditor, @@ -21,7 +17,7 @@ import { import { KEYS, arrayToMap, - tupleToCoors, + invariant, updateActiveTool, } from "@excalidraw/common"; import { isPathALoop } from "@excalidraw/element"; @@ -30,11 +26,12 @@ import { isInvisiblySmallElement } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element"; -import type { LocalPoint } from "@excalidraw/math"; +import type { GlobalPoint, LocalPoint } from "@excalidraw/math"; import type { ExcalidrawElement, ExcalidrawLinearElement, NonDeleted, + PointsPositionUpdates, } from "@excalidraw/element/types"; import { t } from "../i18n"; @@ -46,20 +43,37 @@ import { register } from "./register"; import type { AppState } from "../types"; -export const actionFinalize = register({ +type FormData = { + event: PointerEvent; + sceneCoords: { x: number; y: number }; +}; + +export const actionFinalize = register({ name: "finalize", label: "", trackEvent: false, perform: (elements, appState, data, app) => { + let newElements = elements; const { interactiveCanvas, focusContainer, scene } = app; - const { event, sceneCoords } = - (data as { - event?: PointerEvent; - sceneCoords?: { x: number; y: number }; - }) ?? {}; const elementsMap = scene.getNonDeletedElementsMap(); - if (event && appState.selectedLinearElement) { + if (data && appState.selectedLinearElement) { + const { event, sceneCoords } = data; + const element = LinearElementEditor.getElement( + appState.selectedLinearElement.elementId, + elementsMap, + ); + + invariant( + element, + "Arrow element should exist if selectedLinearElement is set", + ); + + invariant( + sceneCoords, + "sceneCoords should be defined if actionFinalize is called with event", + ); + const linearElementEditor = LinearElementEditor.handlePointerUp( event, appState.selectedLinearElement, @@ -67,19 +81,48 @@ export const actionFinalize = register({ app.scene, ); - const { startBindingElement, endBindingElement } = linearElementEditor; - const element = app.scene.getElement(linearElementEditor.elementId); if (isBindingElement(element)) { - bindOrUnbindLinearElement( - element, - startBindingElement, - endBindingElement, - app.scene, - ); + const newArrow = !!appState.newElement; + + const selectedPointsIndices = + newArrow || !appState.selectedLinearElement.selectedPointsIndices + ? [element.points.length - 1] // New arrow creation + : appState.selectedLinearElement.selectedPointsIndices; + + const draggedPoints: PointsPositionUpdates = + selectedPointsIndices.reduce((map, index) => { + map.set(index, { + point: LinearElementEditor.pointFromAbsoluteCoords( + element, + pointFrom(sceneCoords.x, sceneCoords.y), + elementsMap, + ), + }); + + return map; + }, new Map()) ?? new Map(); + + bindOrUnbindBindingElement(element, draggedPoints, scene, appState, { + newArrow, + altKey: event.altKey, + }); + } else if (isLineElement(element)) { + if ( + appState.selectedLinearElement?.isEditing && + !appState.newElement && + !isValidPolygon(element.points) + ) { + scene.mutateElement(element, { + polygon: false, + }); + } } if (linearElementEditor !== appState.selectedLinearElement) { - let newElements = elements; + // `handlePointerUp()` updated the linear element instance, + // so filter out this element if it is too small, + // but do an update to all new elements anyway for undo/redo purposes. + if (element && isInvisiblySmallElement(element)) { // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want newElements = newElements.map((el) => { @@ -91,39 +134,8 @@ export const actionFinalize = register({ return el; }); } - return { - elements: newElements, - appState: { - selectedLinearElement: { - ...linearElementEditor, - selectedPointsIndices: null, - }, - suggestedBindings: [], - }, - captureUpdate: CaptureUpdateAction.IMMEDIATELY, - }; - } - } - if (appState.selectedLinearElement?.isEditing) { - const { elementId, startBindingElement, endBindingElement } = - appState.selectedLinearElement; - const element = LinearElementEditor.getElement(elementId, elementsMap); - - if (element) { - if (isBindingElement(element)) { - bindOrUnbindLinearElement( - element, - startBindingElement, - endBindingElement, - scene, - ); - } - if (isLineElement(element) && !isValidPolygon(element.points)) { - scene.mutateElement(element, { - polygon: false, - }); - } + const activeToolLocked = appState.activeTool?.locked; return { elements: @@ -134,23 +146,31 @@ export const actionFinalize = register({ } return el; }) - : undefined, + : newElements, appState: { ...appState, cursorButton: "up", - selectedLinearElement: new LinearElementEditor( - element, - arrayToMap(elementsMap), - false, // exit editing mode - ), + selectedLinearElement: activeToolLocked + ? null + : { + ...linearElementEditor, + selectedPointsIndices: null, + isEditing: false, + initialState: { + ...linearElementEditor.initialState, + lastClickedPoint: -1, + }, + }, + selectionElement: null, + suggestedBinding: null, + newElement: null, + multiElement: null, }, captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; } } - let newElements = elements; - if (window.document.activeElement instanceof HTMLElement) { focusContainer(); } @@ -174,8 +194,14 @@ export const actionFinalize = register({ if (element) { // pen and mouse have hover - if (appState.multiElement && element.type !== "freedraw") { - const { points, lastCommittedPoint } = element; + if ( + appState.selectedLinearElement && + appState.multiElement && + element.type !== "freedraw" && + appState.lastPointerDownWith !== "touch" + ) { + const { points } = element; + const { lastCommittedPoint } = appState.selectedLinearElement; if ( !lastCommittedPoint || points[points.length - 1] !== lastCommittedPoint @@ -227,25 +253,6 @@ export const actionFinalize = register({ polygon: false, }); } - - if ( - isBindingElement(element) && - !isLoop && - element.points.length > 1 && - isBindingEnabled(appState) - ) { - const coords = - sceneCoords ?? - tupleToCoors( - LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - -1, - arrayToMap(elements), - ), - ); - - maybeBindLinearElement(element, appState, coords, scene); - } } } @@ -271,6 +278,25 @@ export const actionFinalize = register({ }); } + let selectedLinearElement = + element && isLinearElement(element) + ? new LinearElementEditor(element, arrayToMap(newElements)) // To select the linear element when user has finished mutipoint editing + : appState.selectedLinearElement; + + selectedLinearElement = selectedLinearElement + ? { + ...selectedLinearElement, + isEditing: appState.newElement + ? false + : selectedLinearElement.isEditing, + initialState: { + ...selectedLinearElement.initialState, + lastClickedPoint: -1, + origin: null, + }, + } + : selectedLinearElement; + return { elements: newElements, appState: { @@ -288,7 +314,7 @@ export const actionFinalize = register({ multiElement: null, editingTextElement: null, startBoundElement: null, - suggestedBindings: [], + suggestedBinding: null, selectedElementIds: element && !appState.activeTool.locked && @@ -298,11 +324,8 @@ export const actionFinalize = register({ [element.id]: true, } : appState.selectedElementIds, - // To select the linear element when user has finished mutipoint editing - selectedLinearElement: - element && isLinearElement(element) - ? new LinearElementEditor(element, arrayToMap(newElements)) - : appState.selectedLinearElement, + + selectedLinearElement, }, // TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit captureUpdate: CaptureUpdateAction.IMMEDIATELY, diff --git a/packages/excalidraw/actions/actionFlip.test.tsx b/packages/excalidraw/actions/actionFlip.test.tsx index 23e4ffc123..0be29c9800 100644 --- a/packages/excalidraw/actions/actionFlip.test.tsx +++ b/packages/excalidraw/actions/actionFlip.test.tsx @@ -38,15 +38,13 @@ describe("flipping re-centers selection", () => { height: 239.9, startBinding: { elementId: "rec1", - focus: 0, - gap: 5, fixedPoint: [0.49, -0.05], + mode: "orbit", }, endBinding: { elementId: "rec2", - focus: 0, - gap: 5, fixedPoint: [-0.05, 0.49], + mode: "orbit", }, startArrowhead: null, endArrowhead: "arrow", @@ -74,11 +72,11 @@ describe("flipping re-centers selection", () => { const rec1 = h.elements.find((el) => el.id === "rec1")!; expect(rec1.x).toBeCloseTo(100, 0); - expect(rec1.y).toBeCloseTo(100, 0); + expect(rec1.y).toBeCloseTo(101, 0); const rec2 = h.elements.find((el) => el.id === "rec2")!; expect(rec2.x).toBeCloseTo(220, 0); - expect(rec2.y).toBeCloseTo(250, 0); + expect(rec2.y).toBeCloseTo(251, 0); }); }); @@ -99,8 +97,8 @@ describe("flipping arrowheads", () => { endArrowhead: null, endBinding: { elementId: rect.id, - focus: 0.5, - gap: 5, + fixedPoint: [0.5, 0.5], + mode: "orbit", }, }); @@ -139,13 +137,13 @@ describe("flipping arrowheads", () => { endArrowhead: "circle", startBinding: { elementId: rect.id, - focus: 0.5, - gap: 5, + fixedPoint: [0.5, 0.5], + mode: "orbit", }, endBinding: { elementId: rect2.id, - focus: 0.5, - gap: 5, + fixedPoint: [0.5, 0.5], + mode: "orbit", }, }); @@ -195,8 +193,8 @@ describe("flipping arrowheads", () => { endArrowhead: null, endBinding: { elementId: rect.id, - focus: 0.5, - gap: 5, + fixedPoint: [0.5, 0.5], + mode: "orbit", }, }); diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index 6456fca8d5..b7e15275d8 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -1,17 +1,10 @@ import { getNonDeletedElements } from "@excalidraw/element"; -import { - bindOrUnbindLinearElements, - isBindingEnabled, -} from "@excalidraw/element"; +import { bindOrUnbindBindingElements } from "@excalidraw/element"; import { getCommonBoundingBox } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element"; import { deepCopyElement } from "@excalidraw/element"; import { resizeMultipleElements } from "@excalidraw/element"; -import { - isArrowElement, - isElbowArrow, - isLinearElement, -} from "@excalidraw/element"; +import { isArrowElement, isElbowArrow } from "@excalidraw/element"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element"; import { CODES, KEYS, arrayToMap } from "@excalidraw/common"; @@ -103,7 +96,6 @@ const flipSelectedElements = ( const updatedElements = flipElements( selectedElements, elementsMap, - appState, flipDirection, app, ); @@ -118,7 +110,6 @@ const flipSelectedElements = ( const flipElements = ( selectedElements: NonDeleted[], elementsMap: NonDeletedSceneElementsMap, - appState: AppState, flipDirection: "horizontal" | "vertical", app: AppClassProperties, ): ExcalidrawElement[] => { @@ -158,12 +149,10 @@ const flipElements = ( }, ); - bindOrUnbindLinearElements( - selectedElements.filter(isLinearElement), - isBindingEnabled(appState), - [], + bindOrUnbindBindingElements( + selectedElements.filter(isArrowElement), app.scene, - appState.zoom, + app.state, ); // --------------------------------------------------------------------------- diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx index 27f0d6024c..02dcecef50 100644 --- a/packages/excalidraw/actions/actionNavigate.tsx +++ b/packages/excalidraw/actions/actionNavigate.tsx @@ -2,6 +2,8 @@ import clsx from "clsx"; import { CaptureUpdateAction } from "@excalidraw/element"; +import { invariant } from "@excalidraw/common"; + import { getClientColor } from "../clients"; import { Avatar } from "../components/Avatar"; import { @@ -16,12 +18,17 @@ import { register } from "./register"; import type { GoToCollaboratorComponentProps } from "../components/UserList"; import type { Collaborator } from "../types"; -export const actionGoToCollaborator = register({ +export const actionGoToCollaborator = register({ name: "goToCollaborator", label: "Go to a collaborator", viewMode: true, trackEvent: { category: "collab" }, - perform: (_elements, appState, collaborator: Collaborator) => { + perform: (_elements, appState, collaborator) => { + invariant( + collaborator, + "actionGoToCollaborator: collaborator should be defined when actionGoToCollaborator is called", + ); + if ( !collaborator.socketId || appState.userToFollow?.socketId === collaborator.socketId || diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index c356456ac1..ee1a44944e 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1,4 +1,5 @@ import { pointFrom } from "@excalidraw/math"; + import { useEffect, useMemo, useRef, useState } from "react"; import { @@ -20,12 +21,13 @@ import { getLineHeight, isTransparent, reduceToCommonValue, + invariant, } from "@excalidraw/common"; import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element"; import { - bindLinearElement, + bindBindingElement, calculateFixedPointForElbowArrowBinding, updateBoundElements, } from "@excalidraw/element"; @@ -59,7 +61,7 @@ import { import { deriveStylesPanelMode } from "@excalidraw/common"; -import type { LocalPoint } from "@excalidraw/math"; +import type { LocalPoint, Radians } from "@excalidraw/math"; import type { Arrowhead, @@ -306,13 +308,15 @@ const changeFontSize = ( // ----------------------------------------------------------------------------- -export const actionChangeStrokeColor = register({ +export const actionChangeStrokeColor = register< + Pick +>({ name: "changeStrokeColor", label: "labels.stroke", trackEvent: false, perform: (elements, appState, value) => { return { - ...(value.currentItemStrokeColor && { + ...(value?.currentItemStrokeColor && { elements: changeProperty( elements, appState, @@ -330,7 +334,7 @@ export const actionChangeStrokeColor = register({ ...appState, ...value, }, - captureUpdate: !!value.currentItemStrokeColor + captureUpdate: !!value?.currentItemStrokeColor ? CaptureUpdateAction.IMMEDIATELY : CaptureUpdateAction.EVENTUALLY, }; @@ -366,12 +370,14 @@ export const actionChangeStrokeColor = register({ }, }); -export const actionChangeBackgroundColor = register({ +export const actionChangeBackgroundColor = register< + Pick +>({ name: "changeBackgroundColor", label: "labels.changeBackground", trackEvent: false, perform: (elements, appState, value, app) => { - if (!value.currentItemBackgroundColor) { + if (!value?.currentItemBackgroundColor) { return { appState: { ...appState, @@ -451,7 +457,7 @@ export const actionChangeBackgroundColor = register({ }, }); -export const actionChangeFillStyle = register({ +export const actionChangeFillStyle = register({ name: "changeFillStyle", label: "labels.fill", trackEvent: false, @@ -533,7 +539,9 @@ export const actionChangeFillStyle = register({ }, }); -export const actionChangeStrokeWidth = register({ +export const actionChangeStrokeWidth = register< + ExcalidrawElement["strokeWidth"] +>({ name: "changeStrokeWidth", label: "labels.strokeWidth", trackEvent: false, @@ -589,7 +597,7 @@ export const actionChangeStrokeWidth = register({ ), }); -export const actionChangeSloppiness = register({ +export const actionChangeSloppiness = register({ name: "changeSloppiness", label: "labels.sloppiness", trackEvent: false, @@ -643,7 +651,9 @@ export const actionChangeSloppiness = register({ ), }); -export const actionChangeStrokeStyle = register({ +export const actionChangeStrokeStyle = register< + ExcalidrawElement["strokeStyle"] +>({ name: "changeStrokeStyle", label: "labels.strokeStyle", trackEvent: false, @@ -696,7 +706,7 @@ export const actionChangeStrokeStyle = register({ ), }); -export const actionChangeOpacity = register({ +export const actionChangeOpacity = register({ name: "changeOpacity", label: "labels.opacity", trackEvent: false, @@ -720,89 +730,100 @@ export const actionChangeOpacity = register({ ), }); -export const actionChangeFontSize = register({ - name: "changeFontSize", - label: "labels.fontSize", - trackEvent: false, - perform: (elements, appState, value, app) => { - return changeFontSize(elements, appState, app, () => value, value); - }, - PanelComponent: ({ elements, appState, updateData, app, data }) => { - const { isCompact } = getStylesPanelInfo(app); +export const actionChangeFontSize = register( + { + name: "changeFontSize", + label: "labels.fontSize", + trackEvent: false, + perform: (elements, appState, value, app) => { + return changeFontSize( + elements, + appState, + app, + () => { + invariant(value, "actionChangeFontSize: Expected a font size value"); + return value; + }, + value, + ); + }, + PanelComponent: ({ elements, appState, updateData, app, data }) => { + const { isCompact } = getStylesPanelInfo(app); - return ( -
- {t("labels.fontSize")} -
- { - if (isTextElement(element)) { - return element.fontSize; - } - const boundTextElement = getBoundTextElement( - element, - app.scene.getNonDeletedElementsMap(), + return ( +
+ {t("labels.fontSize")} +
+ { + if (isTextElement(element)) { + return element.fontSize; + } + const boundTextElement = getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ); + if (boundTextElement) { + return boundTextElement.fontSize; + } + return null; + }, + (element) => + isTextElement(element) || + getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ) !== null, + (hasSelection) => + hasSelection + ? null + : appState.currentItemFontSize || DEFAULT_FONT_SIZE, + )} + onChange={(value) => { + withCaretPositionPreservation( + () => updateData(value), + isCompact, + !!appState.editingTextElement, + data?.onPreventClose, ); - if (boundTextElement) { - return boundTextElement.fontSize; - } - return null; - }, - (element) => - isTextElement(element) || - getBoundTextElement( - element, - app.scene.getNonDeletedElementsMap(), - ) !== null, - (hasSelection) => - hasSelection - ? null - : appState.currentItemFontSize || DEFAULT_FONT_SIZE, - )} - onChange={(value) => { - withCaretPositionPreservation( - () => updateData(value), - isCompact, - !!appState.editingTextElement, - data?.onPreventClose, - ); - }} - /> -
-
- ); + }} + /> +
+
+ ); + }, }, -}); +); export const actionDecreaseFontSize = register({ name: "decreaseFontSize", @@ -862,7 +883,10 @@ type ChangeFontFamilyData = Partial< resetContainers?: true; }; -export const actionChangeFontFamily = register({ +export const actionChangeFontFamily = register<{ + currentItemFontFamily: any; + currentHoveredFontFamily: any; +}>({ name: "changeFontFamily", label: "labels.fontFamily", trackEvent: false, @@ -899,6 +923,8 @@ export const actionChangeFontFamily = register({ }; } + invariant(value, "actionChangeFontFamily: value must be defined"); + const { currentItemFontFamily, currentHoveredFontFamily } = value; let nextCaptureUpdateAction: CaptureUpdateActionType = @@ -1241,7 +1267,7 @@ export const actionChangeFontFamily = register({ }, }); -export const actionChangeTextAlign = register({ +export const actionChangeTextAlign = register({ name: "changeTextAlign", label: "Change text alignment", trackEvent: false, @@ -1342,7 +1368,7 @@ export const actionChangeTextAlign = register({ }, }); -export const actionChangeVerticalAlign = register({ +export const actionChangeVerticalAlign = register({ name: "changeVerticalAlign", label: "Change vertical alignment", trackEvent: { category: "element" }, @@ -1442,7 +1468,7 @@ export const actionChangeVerticalAlign = register({ }, }); -export const actionChangeRoundness = register({ +export const actionChangeRoundness = register<"sharp" | "round">({ name: "changeRoundness", label: "Change edge roundness", trackEvent: false, @@ -1599,15 +1625,16 @@ const getArrowheadOptions = (flip: boolean) => { ] as const; }; -export const actionChangeArrowhead = register({ +export const actionChangeArrowhead = register<{ + position: "start" | "end"; + type: Arrowhead; +}>({ name: "changeArrowhead", label: "Change arrowheads", trackEvent: false, - perform: ( - elements, - appState, - value: { position: "start" | "end"; type: Arrowhead }, - ) => { + perform: (elements, appState, value) => { + invariant(value, "actionChangeArrowhead: value must be defined"); + return { elements: changeProperty(elements, appState, (el) => { if (isLinearElement(el)) { @@ -1702,7 +1729,7 @@ export const actionChangeArrowProperties = register({ }, }); -export const actionChangeArrowType = register({ +export const actionChangeArrowType = register({ name: "changeArrowType", label: "Change arrow types", trackEvent: false, @@ -1711,7 +1738,20 @@ export const actionChangeArrowType = register({ if (!isArrowElement(el)) { return el; } + const elementsMap = app.scene.getNonDeletedElementsMap(); + const startPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( + el, + 0, + elementsMap, + ); + const endPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( + el, + -1, + elementsMap, + ); let newElement = newElementWith(el, { + x: value === ARROW_TYPE.elbow ? startPoint[0] : el.x, + y: value === ARROW_TYPE.elbow ? startPoint[1] : el.y, roundness: value === ARROW_TYPE.round ? { @@ -1719,9 +1759,31 @@ export const actionChangeArrowType = register({ } : null, elbowed: value === ARROW_TYPE.elbow, + angle: value === ARROW_TYPE.elbow ? (0 as Radians) : el.angle, points: value === ARROW_TYPE.elbow || el.elbowed - ? [el.points[0], el.points[el.points.length - 1]] + ? [ + LinearElementEditor.pointFromAbsoluteCoords( + { + ...el, + x: startPoint[0], + y: startPoint[1], + angle: 0 as Radians, + }, + startPoint, + elementsMap, + ), + LinearElementEditor.pointFromAbsoluteCoords( + { + ...el, + x: startPoint[0], + y: startPoint[1], + angle: 0 as Radians, + }, + endPoint, + elementsMap, + ), + ] : el.points, }); @@ -1803,7 +1865,13 @@ export const actionChangeArrowType = register({ newElement.startBinding.elementId, ) as ExcalidrawBindableElement; if (startElement) { - bindLinearElement(newElement, startElement, "start", app.scene); + bindBindingElement( + newElement, + startElement, + appState.bindMode === "inside" ? "inside" : "orbit", + "start", + app.scene, + ); } } if (newElement.endBinding) { @@ -1811,7 +1879,13 @@ export const actionChangeArrowType = register({ newElement.endBinding.elementId, ) as ExcalidrawBindableElement; if (endElement) { - bindLinearElement(newElement, endElement, "end", app.scene); + bindBindingElement( + newElement, + endElement, + appState.bindMode === "inside" ? "inside" : "orbit", + "end", + app.scene, + ); } } } diff --git a/packages/excalidraw/actions/register.ts b/packages/excalidraw/actions/register.ts index 7c841e3aee..8f22810393 100644 --- a/packages/excalidraw/actions/register.ts +++ b/packages/excalidraw/actions/register.ts @@ -2,7 +2,12 @@ import type { Action } from "./types"; export let actions: readonly Action[] = []; -export const register = (action: T) => { +export const register = < + TData extends any, + T extends Action = Action, +>( + action: T, +) => { actions = actions.concat(action); return action as T & { keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"]; diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index d533294d39..c85b0639ef 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -32,10 +32,10 @@ export type ActionResult = } | false; -type ActionFn = ( +type ActionFn = ( elements: readonly OrderedExcalidrawElement[], appState: Readonly, - formData: any, + formData: TData | undefined, app: AppClassProperties, ) => ActionResult | Promise; @@ -157,7 +157,7 @@ export type PanelComponentProps = { ) => React.JSX.Element | null; }; -export interface Action { +export interface Action { name: ActionName; label: | string @@ -174,7 +174,7 @@ export interface Action { elements: readonly ExcalidrawElement[], ) => React.ReactNode); PanelComponent?: React.FC; - perform: ActionFn; + perform: ActionFn; keyPriority?: number; keyTest?: ( event: React.KeyboardEvent | KeyboardEvent, diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 7ec58fec12..087b1b795e 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -100,7 +100,7 @@ export const getDefaultAppState = (): Omit< panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties, }, startBoundElement: null, - suggestedBindings: [], + suggestedBinding: null, frameRendering: { enabled: true, clip: true, name: true, outline: true }, frameToHighlight: null, editingFrame: null, @@ -127,6 +127,7 @@ export const getDefaultAppState = (): Omit< searchMatches: null, lockedMultiSelections: {}, activeLockedId: null, + bindMode: "orbit", }; }; @@ -229,7 +230,7 @@ const APP_STATE_STORAGE_CONF = (< shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, stats: { browser: true, export: false, server: false }, startBoundElement: { browser: false, export: false, server: false }, - suggestedBindings: { browser: false, export: false, server: false }, + suggestedBinding: { browser: false, export: false, server: false }, frameRendering: { browser: false, export: false, server: false }, frameToHighlight: { browser: false, export: false, server: false }, editingFrame: { browser: false, export: false, server: false }, @@ -252,6 +253,7 @@ const APP_STATE_STORAGE_CONF = (< searchMatches: { browser: false, export: false, server: false }, lockedMultiSelections: { browser: true, export: true, server: true }, activeLockedId: { browser: false, export: false, server: false }, + bindMode: { browser: true, export: false, server: false }, }); const _clearAppStateForStorage = < diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index a0252bf7fb..34d4f6154b 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -95,6 +95,9 @@ import { Emitter, MINIMUM_ARROW_SIZE, DOUBLE_TAP_POSITION_THRESHOLD, + BIND_MODE_TIMEOUT, + invariant, + getFeatureFlag, createUserAgentDescriptor, getFormFactor, deriveStylesPanelMode, @@ -105,20 +108,19 @@ import { type StylesPanelMode, loadDesktopUIModePreference, setDesktopUIMode, + isSelectionLikeTool, } from "@excalidraw/common"; import { getObservedAppState, getCommonBounds, - maybeSuggestBindingsForLinearElementAtCoords, getElementAbsoluteCoords, - bindOrUnbindLinearElements, + bindOrUnbindBindingElements, fixBindingsAfterDeletion, getHoveredElementForBinding, isBindingEnabled, shouldEnableBindingForPointerEvent, updateBoundElements, - getSuggestedBindingsForArrows, LinearElementEditor, newElementWith, newFrameElement, @@ -154,7 +156,6 @@ import { isFlowchartNodeElement, isBindableElement, isTextElement, - getLockedLinearCursorAlignSize, getNormalizedDimensions, isElementCompletelyInViewport, isElementInViewport, @@ -240,9 +241,16 @@ import { StoreDelta, type ApplyToOptions, positionElementsOnGrid, + calculateFixedPointForNonElbowArrowBinding, + bindOrUnbindBindingElement, + mutateElement, + getElementBounds, + doBoundsIntersect, + isPointInElement, + maxBindingDistance_simple, } from "@excalidraw/element"; -import type { LocalPoint, Radians } from "@excalidraw/math"; +import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { ExcalidrawElement, @@ -267,6 +275,7 @@ import type { ExcalidrawArrowElement, ExcalidrawElbowArrowElement, SceneElementsMap, + ExcalidrawBindableElement, } from "@excalidraw/element/types"; import type { Mutable, ValueOf } from "@excalidraw/common/utility-types"; @@ -593,7 +602,6 @@ class App extends React.Component { public renderer: Renderer; public visibleElements: readonly NonDeletedExcalidrawElement[]; private resizeObserver: ResizeObserver | undefined; - private nearestScrollableContainer: HTMLElement | Document | undefined; public library: AppClassProperties["library"]; public libraryItemsFromStorage: LibraryItems | undefined; public id: string; @@ -627,6 +635,8 @@ class App extends React.Component { public flowChartCreator: FlowChartCreator = new FlowChartCreator(); private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator(); + bindModeHandler: ReturnType | null = null; + hitLinkElement?: NonDeletedExcalidrawElement; lastPointerDownEvent: React.PointerEvent | null = null; lastPointerUpEvent: React.PointerEvent | PointerEvent | null = @@ -851,6 +861,320 @@ class App extends React.Component { } } + private handleSkipBindMode() { + if ( + this.state.selectedLinearElement?.initialState && + !this.state.selectedLinearElement.initialState.arrowStartIsInside + ) { + invariant( + this.lastPointerMoveCoords, + "Missing last pointer move coords when changing bind skip mode for arrow start", + ); + const elementsMap = this.scene.getNonDeletedElementsMap(); + const hoveredElement = getHoveredElementForBinding( + pointFrom( + this.lastPointerMoveCoords.x, + this.lastPointerMoveCoords.y, + ), + this.scene.getNonDeletedElements(), + elementsMap, + ); + const element = LinearElementEditor.getElement( + this.state.selectedLinearElement.elementId, + elementsMap, + ); + + if ( + element?.startBinding && + hoveredElement?.id === element.startBinding.elementId + ) { + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + initialState: { + ...this.state.selectedLinearElement.initialState, + arrowStartIsInside: true, + }, + }, + }); + } + } + + if (this.state.bindMode === "orbit") { + if (this.bindModeHandler) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } + + // PERF: It's okay since it's a single trigger from a key handler + // or single call from pointer move handler because the bindMode check + // will not pass the second time + flushSync(() => { + this.setState({ + bindMode: "skip", + }); + }); + + if ( + this.lastPointerMoveCoords && + this.state.selectedLinearElement?.selectedPointsIndices && + this.state.selectedLinearElement?.selectedPointsIndices.length + ) { + const { x, y } = this.lastPointerMoveCoords; + const event = + this.lastPointerMoveEvent ?? this.lastPointerDownEvent?.nativeEvent; + invariant(event, "Last event must exist"); + const deltaX = x - this.state.selectedLinearElement.pointerOffset.x; + const deltaY = y - this.state.selectedLinearElement.pointerOffset.y; + const newState = this.state.multiElement + ? LinearElementEditor.handlePointerMove( + event, + this, + deltaX, + deltaY, + this.state.selectedLinearElement, + ) + : LinearElementEditor.handlePointDragging( + event, + this, + deltaX, + deltaY, + this.state.selectedLinearElement, + ); + if (newState) { + this.setState(newState); + } + } + } + } + + private resetDelayedBindMode() { + if (this.bindModeHandler) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } + + if (this.state.bindMode !== "orbit") { + // We need this iteration to complete binding and change + // back to orbit mode after that + setTimeout(() => + this.setState({ + bindMode: "orbit", + }), + ); + } + } + + private previousHoveredBindableElement: NonDeletedExcalidrawElement | null = + null; + + private handleDelayedBindModeChange( + arrow: ExcalidrawArrowElement, + hoveredElement: NonDeletedExcalidrawElement | null, + ) { + if (arrow.isDeleted || isElbowArrow(arrow)) { + return; + } + + const effector = () => { + this.bindModeHandler = null; + + invariant( + this.lastPointerMoveCoords, + "Expected lastPointerMoveCoords to be set", + ); + + if (!this.state.multiElement) { + if ( + !this.state.selectedLinearElement || + !this.state.selectedLinearElement.selectedPointsIndices || + !this.state.selectedLinearElement.selectedPointsIndices.length + ) { + return; + } + + const startDragged = + this.state.selectedLinearElement.selectedPointsIndices.includes(0); + const endDragged = + this.state.selectedLinearElement.selectedPointsIndices.includes( + arrow.points.length - 1, + ); + + // Check if the whole arrow is dragged by selecting all endpoints + if ((!startDragged && !endDragged) || (startDragged && endDragged)) { + return; + } + } + + const { x, y } = this.lastPointerMoveCoords; + const hoveredElement = getHoveredElementForBinding( + pointFrom(x, y), + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), + ); + + if (hoveredElement && this.state.bindMode !== "skip") { + invariant( + this.state.selectedLinearElement?.elementId === arrow.id, + "The selectedLinearElement is expected to not change while a bind mode timeout is ticking", + ); + + // Once the start is set to inside binding, it remains so + const arrowStartIsInside = + this.state.selectedLinearElement.initialState.arrowStartIsInside || + arrow.startBinding?.elementId === hoveredElement.id; + + // Change the global binding mode + flushSync(() => { + invariant( + this.state.selectedLinearElement, + "this.state.selectedLinearElement must exist", + ); + + this.setState({ + bindMode: "inside", + selectedLinearElement: { + ...this.state.selectedLinearElement, + initialState: { + ...this.state.selectedLinearElement.initialState, + arrowStartIsInside, + }, + }, + }); + }); + + const event = + this.lastPointerMoveEvent ?? this.lastPointerDownEvent?.nativeEvent; + invariant(event, "Last event must exist"); + const deltaX = x - this.state.selectedLinearElement.pointerOffset.x; + const deltaY = y - this.state.selectedLinearElement.pointerOffset.y; + const newState = this.state.multiElement + ? LinearElementEditor.handlePointerMove( + event, + this, + deltaX, + deltaY, + this.state.selectedLinearElement, + ) + : LinearElementEditor.handlePointDragging( + event, + this, + deltaX, + deltaY, + this.state.selectedLinearElement, + ); + if (newState) { + this.setState(newState); + } + } + }; + + let isOverlapping = false; + if (this.state.selectedLinearElement?.selectedPointsIndices) { + const elementsMap = this.scene.getNonDeletedElementsMap(); + const startDragged = + this.state.selectedLinearElement.selectedPointsIndices.includes(0); + const endDragged = + this.state.selectedLinearElement.selectedPointsIndices.includes( + arrow.points.length - 1, + ); + const startElement = startDragged + ? hoveredElement + : arrow.startBinding && elementsMap.get(arrow.startBinding.elementId); + const endElement = endDragged + ? hoveredElement + : arrow.endBinding && elementsMap.get(arrow.endBinding.elementId); + const startBounds = + startElement && getElementBounds(startElement, elementsMap); + const endBounds = endElement && getElementBounds(endElement, elementsMap); + isOverlapping = !!( + startBounds && + endBounds && + startElement.id !== endElement.id && + doBoundsIntersect(startBounds, endBounds) + ); + } + + const startDragged = + this.state.selectedLinearElement?.selectedPointsIndices?.includes(0); + const endDragged = + this.state.selectedLinearElement?.selectedPointsIndices?.includes( + arrow.points.length - 1, + ); + const currentBinding = startDragged + ? "startBinding" + : endDragged + ? "endBinding" + : null; + const otherBinding = startDragged + ? "endBinding" + : endDragged + ? "startBinding" + : null; + const isAlreadyInsideBindingToSameElement = + (otherBinding && + arrow[otherBinding]?.mode === "inside" && + arrow[otherBinding]?.elementId === hoveredElement?.id) || + (currentBinding && + arrow[currentBinding]?.mode === "inside" && + hoveredElement?.id === arrow[currentBinding]?.elementId); + + if ( + currentBinding && + otherBinding && + arrow[currentBinding]?.mode === "inside" && + hoveredElement?.id !== arrow[currentBinding]?.elementId && + arrow[otherBinding]?.elementId !== arrow[currentBinding]?.elementId + ) { + // Update binding out of place to orbit mode + this.scene.mutateElement( + arrow, + { + [currentBinding]: { + ...arrow[currentBinding], + mode: "orbit", + }, + }, + { + informMutation: false, + isDragging: true, + }, + ); + } + + if ( + !hoveredElement || + (this.previousHoveredBindableElement && + hoveredElement.id !== this.previousHoveredBindableElement.id) + ) { + // Clear the timeout if we're not hovering a bindable + if (this.bindModeHandler) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } + + // Clear the inside binding mode too + if (this.state.bindMode === "inside") { + flushSync(() => { + this.setState({ + bindMode: "orbit", + }); + }); + } + + this.previousHoveredBindableElement = null; + } else if ( + !this.bindModeHandler && + (!this.state.newElement || !arrow.startBinding || isOverlapping) && + !isAlreadyInsideBindingToSameElement + ) { + // We are hovering a bindable element + this.bindModeHandler = setTimeout(effector, BIND_MODE_TIMEOUT); + } + + this.previousHoveredBindableElement = hoveredElement; + } + private cacheEmbeddableRef( element: ExcalidrawIframeLikeElement, ref: HTMLIFrameElement | null, @@ -2923,15 +3247,6 @@ class App extends React.Component { this.setState({ editingTextElement: null }); } - if ( - this.state.selectedLinearElement && - !this.state.selectedElementIds[this.state.selectedLinearElement.elementId] - ) { - // To make sure `selectedLinearElement` is in sync with `selectedElementIds`, however this shouldn't be needed once - // we have a single API to update `selectedElementIds` - this.setState({ selectedLinearElement: null }); - } - this.store.commit(elementsMap, this.state); // Do not notify consumers if we're still loading the scene. Among other @@ -4411,6 +4726,11 @@ class App extends React.Component { return; } + // Handle Alt key for bind mode + if (event.key === KEYS.ALT && getFeatureFlag("COMPLEX_BINDINGS")) { + this.handleSkipBindMode(); + } + if (this.actionManager.handleKeyDown(event)) { return; } @@ -4420,6 +4740,10 @@ class App extends React.Component { } if (event[KEYS.CTRL_OR_CMD] && this.state.isBindingEnabled) { + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.resetDelayedBindMode(); + } + this.setState({ isBindingEnabled: false }); } @@ -4430,14 +4754,12 @@ class App extends React.Component { includeElementsInFrames: true, }); - const elbowArrow = selectedElements.find(isElbowArrow) as - | ExcalidrawArrowElement - | undefined; - const arrowIdsToRemove = new Set(); selectedElements - .filter(isElbowArrow) + .filter((el): el is NonDeleted => + isBindingElement(el), + ) .filter((arrow) => { const startElementNotInSelection = arrow.startBinding && @@ -4494,16 +4816,6 @@ class App extends React.Component { }); }); - this.setState({ - suggestedBindings: getSuggestedBindingsForArrows( - selectedElements.filter( - (element) => element.id !== elbowArrow?.id || step !== 0, - ), - this.scene.getNonDeletedElementsMap(), - this.state.zoom, - ), - }); - this.scene.triggerUpdate(); event.preventDefault(); @@ -4692,10 +5004,7 @@ class App extends React.Component { this.state.openDialog?.name === "elementLinkSelector" ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); - } else if ( - this.state.activeTool.type === "selection" || - this.state.activeTool.type === "lasso" - ) { + } else if (isSelectionLikeTool(this.state.activeTool.type)) { resetCursor(this.interactiveCanvas); } else { setCursorForShape(this.interactiveCanvas, this.state); @@ -4708,18 +5017,95 @@ class App extends React.Component { } isHoldingSpace = false; } + if ( + (event.key === KEYS.ALT && this.state.bindMode === "skip") || + (!event[KEYS.CTRL_OR_CMD] && !isBindingEnabled(this.state)) + ) { + // Handle Alt key release for bind mode + this.setState({ + bindMode: "orbit", + }); + + // Restart the timer if we're creating/editing a linear element and hovering over an element + if (this.lastPointerMoveEvent) { + const scenePointer = viewportCoordsToSceneCoords( + { + clientX: this.lastPointerMoveEvent.clientX, + clientY: this.lastPointerMoveEvent.clientY, + }, + this.state, + ); + + const hoveredElement = getHoveredElementForBinding( + pointFrom(scenePointer.x, scenePointer.y), + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), + ); + + if (this.state.selectedLinearElement) { + const element = LinearElementEditor.getElement( + this.state.selectedLinearElement.elementId, + this.scene.getNonDeletedElementsMap(), + ); + + if (isBindingElement(element) && getFeatureFlag("COMPLEX_BINDINGS")) { + this.handleDelayedBindModeChange(element, hoveredElement); + } + } + } + } if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) { this.setState({ isBindingEnabled: true }); } if (isArrowKey(event.key)) { - bindOrUnbindLinearElements( - this.scene.getSelectedElements(this.state).filter(isLinearElement), - isBindingEnabled(this.state), - this.state.selectedLinearElement?.selectedPointsIndices ?? [], + bindOrUnbindBindingElements( + this.scene.getSelectedElements(this.state).filter(isArrowElement), this.scene, - this.state.zoom, + this.state, ); - this.setState({ suggestedBindings: [] }); + + const elementsMap = this.scene.getNonDeletedElementsMap(); + + this.scene + .getSelectedElements(this.state) + .filter(isSimpleArrow) + .forEach((element) => { + // Update the fixed point bindings for non-elbow arrows + // when the pointer is released, so that they are correctly positioned + // after the drag. + if (element.startBinding) { + this.scene.mutateElement(element, { + startBinding: { + ...element.startBinding, + ...calculateFixedPointForNonElbowArrowBinding( + element, + elementsMap.get( + element.startBinding.elementId, + ) as ExcalidrawBindableElement, + "start", + elementsMap, + ), + }, + }); + } + if (element.endBinding) { + this.scene.mutateElement(element, { + endBinding: { + ...element.endBinding, + ...calculateFixedPointForNonElbowArrowBinding( + element, + elementsMap.get( + element.endBinding.elementId, + ) as ExcalidrawBindableElement, + "end", + elementsMap, + ), + }, + }); + } + }); + + this.setState({ suggestedBinding: null }); } if (!event.altKey) { @@ -4818,7 +5204,7 @@ class App extends React.Component { this.focusContainer(); } if (!isLinearElementType(nextActiveTool.type)) { - this.setState({ suggestedBindings: [] }); + this.setState({ suggestedBinding: null }); } if (nextActiveTool.type === "image") { this.onImageToolbarButtonClick(); @@ -4829,6 +5215,9 @@ class App extends React.Component { snapLines: prevState.snapLines.length ? [] : prevState.snapLines, originSnapOffset: null, activeEmbeddable: null, + selectedLinearElement: isSelectionLikeTool(nextActiveTool.type) + ? prevState.selectedLinearElement + : null, } as const; if (nextActiveTool.type === "freedraw") { @@ -4838,6 +5227,7 @@ class App extends React.Component { if (nextActiveTool.type === "lasso") { return { ...prevState, + ...commonResets, activeTool: nextActiveTool, ...(keepSelection ? {} @@ -4847,23 +5237,22 @@ class App extends React.Component { editingGroupId: null, multiElement: null, }), - ...commonResets, }; } else if (nextActiveTool.type !== "selection") { return { ...prevState, + ...commonResets, activeTool: nextActiveTool, selectedElementIds: makeNextSelectedElementIds({}, prevState), selectedGroupIds: makeNextSelectedElementIds({}, prevState), editingGroupId: null, multiElement: null, - ...commonResets, }; } return { ...prevState, - activeTool: nextActiveTool, ...commonResets, + activeTool: nextActiveTool, }; }); }; @@ -5572,8 +5961,8 @@ class App extends React.Component { this.setState({ selectedLinearElement: { ...this.state.selectedLinearElement, - pointerDownState: { - ...this.state.selectedLinearElement.pointerDownState, + initialState: { + ...this.state.selectedLinearElement.initialState, segmentMidpoint: { index: nextIndex, value: hitCoords, @@ -5805,6 +6194,12 @@ class App extends React.Component { ) => { this.savePointer(event.clientX, event.clientY, this.state.cursorButton); this.lastPointerMoveEvent = event.nativeEvent; + const scenePointer = viewportCoordsToSceneCoords(event, this.state); + const { x: scenePointerX, y: scenePointerY } = scenePointer; + this.lastPointerMoveCoords = { + x: scenePointerX, + y: scenePointerY, + }; if (gesture.pointers.has(event.pointerId)) { gesture.pointers.set(event.pointerId, { @@ -5854,6 +6249,8 @@ class App extends React.Component { scrollY: zoomState.scrollY + 2 * (deltaY / nextZoom), shouldCacheIgnoreZoom: true, }); + + return null; }); this.resetShouldCacheIgnoreZoomDebounced(); } else { @@ -5891,9 +6288,6 @@ class App extends React.Component { } } - const scenePointer = viewportCoordsToSceneCoords(event, this.state); - const { x: scenePointerX, y: scenePointerY } = scenePointer; - if ( !this.state.newElement && isActiveToolNonLinearSnappable(this.state.activeTool.type) @@ -5945,15 +6339,14 @@ class App extends React.Component { this.state.selectedLinearElement?.isEditing && !this.state.selectedLinearElement.isDragging ) { - const editingLinearElement = LinearElementEditor.handlePointerMove( - event, - scenePointerX, - scenePointerY, - this, - ); - const linearElement = editingLinearElement - ? this.scene.getElement(editingLinearElement.elementId) - : null; + const editingLinearElement = this.state.newElement + ? null + : LinearElementEditor.handlePointerMoveInEditMode( + event, + scenePointerX, + scenePointerY, + this, + ); if ( editingLinearElement && @@ -5968,52 +6361,79 @@ class App extends React.Component { }); }); } - if ( - editingLinearElement?.lastUncommittedPoint != null && - linearElement && - isBindingElementType(linearElement.type) - ) { - this.maybeSuggestBindingAtCursor( - scenePointer, - editingLinearElement.elbowed, - ); - } else if (this.state.suggestedBindings.length) { - this.setState({ suggestedBindings: [] }); - } } if (isBindingElementType(this.state.activeTool.type)) { // Hovering with a selected tool or creating new linear element via click // and point const { newElement } = this.state; - if (isBindingElement(newElement, false)) { - this.setState({ - suggestedBindings: maybeSuggestBindingsForLinearElementAtCoords( - newElement, - [scenePointer], - this.scene, - this.state.zoom, - this.state.startBoundElement, - ), - }); - } else { - this.maybeSuggestBindingAtCursor(scenePointer, false); + if (!newElement && isBindingEnabled(this.state)) { + const hoveredElement = getHoveredElementForBinding( + pointFrom(scenePointerX, scenePointerY), + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), + (el) => maxBindingDistance_simple(this.state.zoom), + ); + if (hoveredElement) { + this.setState({ + suggestedBinding: hoveredElement, + }); + } else if (this.state.suggestedBinding) { + this.setState({ + suggestedBinding: null, + }); + } } } - if (this.state.multiElement) { - const { multiElement } = this.state; - const { x: rx, y: ry } = multiElement; - - const { points, lastCommittedPoint } = multiElement; + if (this.state.multiElement && this.state.selectedLinearElement) { + const { multiElement, selectedLinearElement } = this.state; + const { x: rx, y: ry, points } = multiElement; const lastPoint = points[points.length - 1]; + const { lastCommittedPoint } = selectedLinearElement; + setCursorForShape(this.interactiveCanvas, this.state); if (lastPoint === lastCommittedPoint) { - // if we haven't yet created a temp point and we're beyond commit-zone - // threshold, add a point - if ( + const hoveredElement = getHoveredElementForBinding( + pointFrom(scenePointerX, scenePointerY), + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), + (el) => maxBindingDistance_simple(this.state.zoom), + ); + if (hoveredElement) { + this.actionManager.executeAction(actionFinalize, "ui", { + event: event.nativeEvent, + sceneCoords: { + x: scenePointerX, + y: scenePointerY, + }, + }); + this.setState({ suggestedBinding: null, startBoundElement: null }); + if (!this.state.activeTool.locked) { + resetCursor(this.interactiveCanvas); + this.setState((prevState) => ({ + newElement: null, + activeTool: updateActiveTool(this.state, { + type: this.state.preferredSelectionTool.type, + }), + selectedElementIds: makeNextSelectedElementIds( + { + ...prevState.selectedElementIds, + [multiElement.id]: true, + }, + prevState, + ), + selectedLinearElement: new LinearElementEditor( + multiElement, + this.scene.getNonDeletedElementsMap(), + ), + })); + } + } else if ( + // if we haven't yet created a temp point and we're beyond commit-zone + // threshold, add a point pointDistance( pointFrom(scenePointerX - rx, scenePointerY - ry), lastPoint, @@ -6029,6 +6449,21 @@ class App extends React.Component { }, { informMutation: false, isDragging: false }, ); + invariant( + this.state.selectedLinearElement?.initialState, + "initialState must be set", + ); + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + lastCommittedPoint: points[points.length - 1], + selectedPointsIndices: [multiElement.points.length - 1], + initialState: { + ...this.state.selectedLinearElement.initialState, + lastClickedPoint: multiElement.points.length - 1, + }, + }, + }); } else { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); // in this branch, we're inside the commit zone, and no uncommitted @@ -6050,64 +6485,91 @@ class App extends React.Component { }, { informMutation: false, isDragging: false }, ); + this.setState({ + selectedLinearElement: { + ...selectedLinearElement, + selectedPointsIndices: + selectedLinearElement.selectedPointsIndices?.includes( + multiElement.points.length, + ) + ? [ + ...selectedLinearElement.selectedPointsIndices.filter( + (idx) => + idx !== multiElement.points.length && + idx !== multiElement.points.length - 1, + ), + multiElement.points.length - 1, + ] + : selectedLinearElement.selectedPointsIndices, + lastCommittedPoint: + multiElement.points[multiElement.points.length - 1], + initialState: { + ...selectedLinearElement.initialState, + lastClickedPoint: multiElement.points.length - 1, + }, + }, + }); } else { - const [gridX, gridY] = getGridPoint( - scenePointerX, - scenePointerY, - event[KEYS.CTRL_OR_CMD] || isElbowArrow(multiElement) - ? null - : this.getEffectiveGridSize(), - ); - - const [lastCommittedX, lastCommittedY] = - multiElement?.lastCommittedPoint ?? [0, 0]; - - let dxFromLastCommitted = gridX - rx - lastCommittedX; - let dyFromLastCommitted = gridY - ry - lastCommittedY; - - if (shouldRotateWithDiscreteAngle(event)) { - ({ width: dxFromLastCommitted, height: dyFromLastCommitted } = - getLockedLinearCursorAlignSize( - // actual coordinate of the last committed point - lastCommittedX + rx, - lastCommittedY + ry, - // cursor-grid coordinate - gridX, - gridY, - )); - } - if (isPathALoop(points, this.state.zoom.value)) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); } - // update last uncommitted point - this.scene.mutateElement( - multiElement, - { - points: [ - ...points.slice(0, -1), - pointFrom( - lastCommittedX + dxFromLastCommitted, - lastCommittedY + dyFromLastCommitted, - ), - ], - }, - { - isDragging: true, - informMutation: false, - }, + // Update arrow points + const elementsMap = this.scene.getNonDeletedElementsMap(); + + if (isSimpleArrow(multiElement)) { + const hoveredElement = getHoveredElementForBinding( + pointFrom(scenePointerX, scenePointerY), + this.scene.getNonDeletedElements(), + elementsMap, + ); + + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.handleDelayedBindModeChange(multiElement, hoveredElement); + } + } + + invariant( + this.state.selectedLinearElement, + "Expected selectedLinearElement to be set to operate on a linear element", ); - // in this path, we're mutating multiElement to reflect - // how it will be after adding pointer position as the next point - // trigger update here so that new element canvas renders again to reflect this - this.triggerRender(false); + const newState = LinearElementEditor.handlePointerMove( + event.nativeEvent, + this, + scenePointerX, + scenePointerY, + this.state.selectedLinearElement, + ); + if (newState) { + this.setState(newState); + } } return; } + if (this.state.activeTool.type === "arrow") { + const hit = getHoveredElementForBinding( + pointFrom(scenePointerX, scenePointerY), + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), + (el) => maxBindingDistance_simple(this.state.zoom), + ); + if ( + hit && + !isPointInElement( + pointFrom(scenePointerX, scenePointerY), + hit, + this.scene.getNonDeletedElementsMap(), + ) + ) { + this.setState({ + suggestedBinding: hit, + }); + } + } + const hasDeselectedButton = Boolean(event.buttons); if ( hasDeselectedButton || @@ -6278,7 +6740,7 @@ class App extends React.Component { }); } else if ( !hitElement || - // Ebow arrows can only be moved when unconnected + // Elbow arrows can only be moved when unconnected !isElbowArrow(hitElement) || !(hitElement.startBinding || hitElement.endBinding) ) { @@ -6400,7 +6862,7 @@ class App extends React.Component { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); } else if (this.hitElement(scenePointerX, scenePointerY, element)) { if ( - // Ebow arrows can only be moved when unconnected + // Elbow arrows can only be moved when unconnected !isElbowArrow(element) || !(element.startBinding || element.endBinding) ) { @@ -6414,7 +6876,7 @@ class App extends React.Component { } } else if (this.hitElement(scenePointerX, scenePointerY, element)) { if ( - // Ebow arrows can only be moved when unconnected + // Elbow arrow can only be moved when unconnected !isElbowArrow(element) || !(element.startBinding || element.endBinding) ) { @@ -6459,6 +6921,13 @@ class App extends React.Component { private handleCanvasPointerDown = ( event: React.PointerEvent, ) => { + const scenePointer = viewportCoordsToSceneCoords(event, this.state); + const { x: scenePointerX, y: scenePointerY } = scenePointer; + this.lastPointerMoveCoords = { + x: scenePointerX, + y: scenePointerY, + }; + const target = event.target as HTMLElement; // capture subsequent pointer events to the canvas // this makes other elements non-interactive until pointer up @@ -6529,7 +6998,7 @@ class App extends React.Component { newElement: null, editingTextElement: null, startBoundElement: null, - suggestedBindings: [], + suggestedBinding: null, selectedElementIds: makeNextSelectedElementIds( Object.keys(this.state.selectedElementIds) .filter((key) => key !== element.id) @@ -6888,6 +7357,10 @@ class App extends React.Component { private handleCanvasPointerUp = ( event: React.PointerEvent, ) => { + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.resetDelayedBindMode(); + } + this.removePointer(event); this.lastPointerUpEvent = event; @@ -6895,6 +7368,11 @@ class App extends React.Component { { clientX: event.clientX, clientY: event.clientY }, this.state, ); + const { x: scenePointerX, y: scenePointerY } = scenePointer; + this.lastPointerMoveCoords = { + x: scenePointerX, + y: scenePointerY, + }; const clicklength = event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0); @@ -7238,10 +7716,7 @@ class App extends React.Component { } private clearSelectionIfNotUsingSelection = (): void => { - if ( - this.state.activeTool.type !== "selection" && - this.state.activeTool.type !== "lasso" - ) { + if (!isSelectionLikeTool(this.state.activeTool.type)) { this.setState({ selectedElementIds: makeNextSelectedElementIds({}, this.state), selectedGroupIds: {}, @@ -7258,10 +7733,7 @@ class App extends React.Component { event: React.PointerEvent, pointerDownState: PointerDownState, ): boolean => { - if ( - this.state.activeTool.type === "selection" || - this.state.activeTool.type === "lasso" - ) { + if (isSelectionLikeTool(this.state.activeTool.type)) { const elements = this.scene.getNonDeletedElements(); const elementsMap = this.scene.getNonDeletedElementsMap(); const selectedElements = this.scene.getSelectedElements(this.state); @@ -7453,20 +7925,34 @@ class App extends React.Component { if ( (hitElement === null || !someHitElementIsSelected) && !event.shiftKey && - !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements + !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements && + (!this.state.selectedLinearElement?.isEditing || + (hitElement && + hitElement?.id !== this.state.selectedLinearElement?.elementId)) ) { this.clearSelection(hitElement); } if (this.state.selectedLinearElement?.isEditing) { - this.setState({ - selectedElementIds: makeNextSelectedElementIds( - { - [this.state.selectedLinearElement.elementId]: true, - }, - this.state, - ), - }); + this.setState((prevState) => ({ + selectedLinearElement: prevState.selectedLinearElement + ? { + ...prevState.selectedLinearElement, + isEditing: + !!hitElement && + hitElement.id === + this.state.selectedLinearElement?.elementId, + } + : null, + selectedElementIds: prevState.selectedLinearElement + ? makeNextSelectedElementIds( + { + [prevState.selectedLinearElement.elementId]: true, + }, + this.state, + ) + : makeNextSelectedElementIds({}, prevState), + })); // If we click on something } else if (hitElement != null) { // == deep selection == @@ -7766,16 +8252,18 @@ class App extends React.Component { }); const boundElement = getHoveredElementForBinding( - pointerDownState.origin, + pointFrom( + pointerDownState.origin.x, + pointerDownState.origin.y, + ), this.scene.getNonDeletedElements(), this.scene.getNonDeletedElementsMap(), - this.state.zoom, ); this.setState({ newElement: element, startBoundElement: boundElement, - suggestedBindings: [], + suggestedBinding: null, }); }; @@ -7924,17 +8412,37 @@ class App extends React.Component { elementType: ExcalidrawLinearElement["type"], pointerDownState: PointerDownState, ): void => { + if (event.ctrlKey) { + flushSync(() => { + this.setState({ isBindingEnabled: false }); + }); + } + if (this.state.multiElement) { - const { multiElement } = this.state; + const { multiElement, selectedLinearElement } = this.state; + + invariant( + selectedLinearElement, + "selectedLinearElement is expected to be set", + ); // finalize if completing a loop if ( multiElement.type === "line" && isPathALoop(multiElement.points, this.state.zoom.value) ) { - this.scene.mutateElement(multiElement, { - lastCommittedPoint: - multiElement.points[multiElement.points.length - 1], + flushSync(() => { + this.setState({ + selectedLinearElement: { + ...selectedLinearElement, + lastCommittedPoint: + multiElement.points[multiElement.points.length - 1], + initialState: { + ...selectedLinearElement.initialState, + lastClickedPoint: -1, // Disable dragging + }, + }, + }); }); this.actionManager.executeAction(actionFinalize); return; @@ -7943,29 +8451,50 @@ class App extends React.Component { // Elbow arrows cannot be created by putting down points // only the start and end points can be defined if (isElbowArrow(multiElement) && multiElement.points.length > 1) { - this.scene.mutateElement(multiElement, { - lastCommittedPoint: - multiElement.points[multiElement.points.length - 1], + this.actionManager.executeAction(actionFinalize, "ui", { + event: event.nativeEvent, + sceneCoords: { + x: pointerDownState.origin.x, + y: pointerDownState.origin.y, + }, }); - this.actionManager.executeAction(actionFinalize); return; } - const { x: rx, y: ry, lastCommittedPoint } = multiElement; + const { x: rx, y: ry } = multiElement; + const { lastCommittedPoint } = selectedLinearElement; + + const hoveredElementForBinding = getHoveredElementForBinding( + pointFrom( + this.lastPointerMoveCoords?.x ?? + rx + multiElement.points[multiElement.points.length - 1][0], + this.lastPointerMoveCoords?.y ?? + ry + multiElement.points[multiElement.points.length - 1][1], + ), + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), + ); // clicking inside commit zone → finalize arrow if ( - multiElement.points.length > 1 && - lastCommittedPoint && - pointDistance( - pointFrom( - pointerDownState.origin.x - rx, - pointerDownState.origin.y - ry, - ), - lastCommittedPoint, - ) < LINE_CONFIRM_THRESHOLD + (isBindingElement(multiElement) && hoveredElementForBinding) || + (multiElement.points.length > 1 && + lastCommittedPoint && + pointDistance( + pointFrom( + pointerDownState.origin.x - rx, + pointerDownState.origin.y - ry, + ), + lastCommittedPoint, + ) < LINE_CONFIRM_THRESHOLD) ) { - this.actionManager.executeAction(actionFinalize); + this.actionManager.executeAction(actionFinalize, "ui", { + event: event.nativeEvent, + sceneCoords: { + x: pointerDownState.origin.x, + y: pointerDownState.origin.y, + }, + }); return; } @@ -7978,11 +8507,7 @@ class App extends React.Component { prevState, ), })); - // clicking outside commit zone → update reference for last committed - // point - this.scene.mutateElement(multiElement, { - lastCommittedPoint: multiElement.points[multiElement.points.length - 1], - }); + setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); } else { const [gridX, gridY] = getGridPoint( @@ -8054,36 +8579,92 @@ class App extends React.Component { locked: false, frameId: topLayerFrame ? topLayerFrame.id : null, }); - this.setState((prevState) => { - const nextSelectedElementIds = { - ...prevState.selectedElementIds, - }; - delete nextSelectedElementIds[element.id]; - return { - selectedElementIds: makeNextSelectedElementIds( - nextSelectedElementIds, - prevState, - ), - }; - }); - this.scene.mutateElement(element, { - points: [...element.points, pointFrom(0, 0)], - }); - const boundElement = getHoveredElementForBinding( - pointerDownState.origin, - this.scene.getNonDeletedElements(), - this.scene.getNonDeletedElementsMap(), - this.state.zoom, - isElbowArrow(element), - isElbowArrow(element), + + const point = pointFrom( + pointerDownState.origin.x, + pointerDownState.origin.y, ); + const elementsMap = this.scene.getNonDeletedElementsMap(); + const boundElement = isBindingEnabled(this.state) + ? getHoveredElementForBinding( + point, + this.scene.getNonDeletedElements(), + elementsMap, + ) + : null; + + this.scene.mutateElement(element, { + points: [pointFrom(0, 0), pointFrom(0, 0)], + }); this.scene.insertElement(element); - this.setState({ - newElement: element, - startBoundElement: boundElement, - suggestedBindings: [], + + if (isBindingElement(element)) { + // Do the initial binding so the binding strategy has the initial state + bindOrUnbindBindingElement( + element, + new Map([ + [ + 0, + { + point: pointFrom(0, 0), + isDragging: false, + }, + ], + ]), + this.scene, + this.state, + { newArrow: true, altKey: event.altKey, initialBinding: true }, + ); + } + + // NOTE: We need the flushSync here for the + // delayed bind mode change to see the right state + // (specifically the `newElement`) + flushSync(() => { + this.setState((prevState) => { + let linearElementEditor = null; + let nextSelectedElementIds = prevState.selectedElementIds; + if (isLinearElement(element)) { + linearElementEditor = new LinearElementEditor( + element, + this.scene.getNonDeletedElementsMap(), + ); + + const endIdx = element.points.length - 1; + linearElementEditor = { + ...linearElementEditor, + selectedPointsIndices: [endIdx], + initialState: { + ...linearElementEditor.initialState, + lastClickedPoint: endIdx, + origin: pointFrom( + pointerDownState.origin.x, + pointerDownState.origin.y, + ), + }, + }; + } + + nextSelectedElementIds = !this.state.activeTool.locked + ? makeNextSelectedElementIds({ [element.id]: true }, prevState) + : prevState.selectedElementIds; + + return { + ...prevState, + bindMode: "orbit", + newElement: element, + startBoundElement: boundElement, + suggestedBinding: boundElement || null, + selectedElementIds: nextSelectedElementIds, + selectedLinearElement: linearElementEditor, + }; + }); }); + + if (isBindingElement(element) && getFeatureFlag("COMPLEX_BINDINGS")) { + this.handleDelayedBindModeChange(element, boundElement); + } } }; @@ -8286,7 +8867,7 @@ class App extends React.Component { if ( this.state.selectedLinearElement && this.state.selectedLinearElement.elbowed && - this.state.selectedLinearElement.pointerDownState.segmentMidpoint.index + this.state.selectedLinearElement.initialState.segmentMidpoint.index ) { const [gridX, gridY] = getGridPoint( pointerCoords.x, @@ -8295,8 +8876,7 @@ class App extends React.Component { ); let index = - this.state.selectedLinearElement.pointerDownState.segmentMidpoint - .index; + this.state.selectedLinearElement.initialState.segmentMidpoint.index; if (index < 0) { const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords( { @@ -8328,8 +8908,9 @@ class App extends React.Component { this.setState({ selectedLinearElement: { ...this.state.selectedLinearElement, + isDragging: true, segmentMidPointHoveredCoords: ret.segmentMidPointHoveredCoords, - pointerDownState: ret.pointerDownState, + initialState: ret.initialState, }, }); return; @@ -8376,26 +8957,6 @@ class App extends React.Component { event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(), ); - // for arrows/lines, don't start dragging until a given threshold - // to ensure we don't create a 2-point arrow by mistake when - // user clicks mouse in a way that it moves a tiny bit (thus - // triggering pointermove) - if ( - !pointerDownState.drag.hasOccurred && - (this.state.activeTool.type === "arrow" || - this.state.activeTool.type === "line") - ) { - if ( - pointDistance( - pointFrom(pointerCoords.x, pointerCoords.y), - pointFrom(pointerDownState.origin.x, pointerDownState.origin.y), - ) * - this.state.zoom.value < - MINIMUM_ARROW_SIZE - ) { - return; - } - } if (pointerDownState.resize.isResizing) { pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.y = pointerCoords.y; @@ -8439,7 +9000,7 @@ class App extends React.Component { this.setState({ selectedLinearElement: { ...this.state.selectedLinearElement, - pointerDownState: ret.pointerDownState, + initialState: ret.pointerDownState, selectedPointsIndices: ret.selectedPointsIndices, segmentMidPointHoveredCoords: null, }, @@ -8449,27 +9010,81 @@ class App extends React.Component { return; } else if ( - linearElementEditor.pointerDownState.segmentMidpoint.value !== null && - !linearElementEditor.pointerDownState.segmentMidpoint.added + linearElementEditor.initialState.segmentMidpoint.value !== null && + !linearElementEditor.initialState.segmentMidpoint.added ) { return; - } + } else if (linearElementEditor.initialState.lastClickedPoint > -1) { + const element = LinearElementEditor.getElement( + linearElementEditor.elementId, + elementsMap, + ); - const newState = LinearElementEditor.handlePointDragging( - event, - this, - pointerCoords.x, - pointerCoords.y, - linearElementEditor, - ); - if (newState) { - pointerDownState.lastCoords.x = pointerCoords.x; - pointerDownState.lastCoords.y = pointerCoords.y; - pointerDownState.drag.hasOccurred = true; + if (element?.isDeleted) { + return; + } - this.setState(newState); + if (isBindingElement(element)) { + const hoveredElement = getHoveredElementForBinding( + pointFrom(pointerCoords.x, pointerCoords.y), + this.scene.getNonDeletedElements(), + elementsMap, + ); - return; + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.handleDelayedBindModeChange(element, hoveredElement); + } + } + + if ( + event.altKey && + !this.state.selectedLinearElement?.initialState + ?.arrowStartIsInside && + getFeatureFlag("COMPLEX_BINDINGS") + ) { + this.handleSkipBindMode(); + } + + // Ignore drag requests if the arrow modification already happened + if (linearElementEditor.initialState.lastClickedPoint === -1) { + return; + } + + const newState = LinearElementEditor.handlePointDragging( + event, + this, + pointerCoords.x, + pointerCoords.y, + linearElementEditor, + ); + + if (newState) { + pointerDownState.lastCoords.x = pointerCoords.x; + pointerDownState.lastCoords.y = pointerCoords.y; + pointerDownState.drag.hasOccurred = true; + + // NOTE: Optimize setState calls because it + // affects history and performance + if ( + newState.suggestedBinding !== this.state.suggestedBinding || + !isShallowEqual( + newState.selectedLinearElement?.selectedPointsIndices ?? [], + this.state.selectedLinearElement?.selectedPointsIndices ?? [], + ) || + newState.selectedLinearElement?.hoverPointIndex !== + this.state.selectedLinearElement?.hoverPointIndex || + newState.selectedLinearElement?.customLineAngle !== + this.state.selectedLinearElement?.customLineAngle || + this.state.selectedLinearElement.isDragging !== + newState.selectedLinearElement?.isDragging || + this.state.selectedLinearElement?.initialState?.altFocusPoint !== + newState.selectedLinearElement?.initialState?.altFocusPoint + ) { + this.setState(newState); + } + + return; + } } } @@ -8645,13 +9260,13 @@ class App extends React.Component { 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, @@ -8703,19 +9318,6 @@ class App extends React.Component { selectionElement: null, }); - if ( - selectedElements.length !== 1 || - !isElbowArrow(selectedElements[0]) - ) { - this.setState({ - suggestedBindings: getSuggestedBindingsForArrows( - selectedElements, - this.scene.getNonDeletedElementsMap(), - this.state.zoom, - ), - }); - } - // We duplicate the selected element if alt is pressed on pointer move if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) { // Move the currently selected elements to the top of the z index stack, and @@ -8940,58 +9542,40 @@ class App extends React.Component { newElement, }); } - } else if (isLinearElement(newElement)) { + } else if (isLinearElement(newElement) && !newElement.isDeleted) { pointerDownState.drag.hasOccurred = true; const points = newElement.points; - let dx = gridX - newElement.x; - let dy = gridY - newElement.y; - if (shouldRotateWithDiscreteAngle(event) && points.length === 2) { - ({ width: dx, height: dy } = getLockedLinearCursorAlignSize( - newElement.x, - newElement.y, - pointerCoords.x, - pointerCoords.y, - )); - } + invariant( + points.length > 1, + "Do not create linear elements with less than 2 points", + ); - if (points.length === 1) { - this.scene.mutateElement( + let linearElementEditor = this.state.selectedLinearElement; + if (!linearElementEditor) { + linearElementEditor = new LinearElementEditor( newElement, - { - points: [...points, pointFrom(dx, dy)], - }, - { informMutation: false, isDragging: false }, + this.scene.getNonDeletedElementsMap(), ); - } else if ( - points.length === 2 || - (points.length > 1 && isElbowArrow(newElement)) - ) { - this.scene.mutateElement( - newElement, - { - points: [...points.slice(0, -1), pointFrom(dx, dy)], + linearElementEditor = { + ...linearElementEditor, + selectedPointsIndices: [1], + initialState: { + ...linearElementEditor.initialState, + lastClickedPoint: 1, }, - { isDragging: true, informMutation: false }, - ); + }; } - this.setState({ newElement, + ...LinearElementEditor.handlePointDragging( + event, + this, + gridX, + gridY, + linearElementEditor, + )!, }); - - if (isBindingElement(newElement, false)) { - // When creating a linear element by dragging - this.setState({ - suggestedBindings: maybeSuggestBindingsForLinearElementAtCoords( - newElement, - [pointerCoords], - this.scene, - this.state.zoom, - this.state.startBoundElement, - ), - }); - } } else { pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.y = pointerCoords.y; @@ -9142,6 +9726,8 @@ class App extends React.Component { pointerDownState: PointerDownState, ): (event: PointerEvent) => void { return withBatchedUpdates((childEvent: PointerEvent) => { + const elementsMap = this.scene.getNonDeletedElementsMap(); + this.removePointer(childEvent); pointerDownState.drag.blockDragging = false; if (pointerDownState.eventListeners.onMove) { @@ -9173,7 +9759,6 @@ class App extends React.Component { // just in case, tool changes mid drag, always clean up this.lassoTrail.endPath(); - this.lastPointerMoveCoords = null; SnapCache.setReferenceSnapPoints(null); SnapCache.setVisibleGaps(null); @@ -9225,10 +9810,14 @@ class App extends React.Component { }); } + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.resetDelayedBindMode(); + } + this.setState({ selectedElementsAreBeingDragged: false, + bindMode: "orbit", }); - const elementsMap = this.scene.getNonDeletedElementsMap(); if ( pointerDownState.drag.hasOccurred && @@ -9249,7 +9838,10 @@ class App extends React.Component { // Handle end of dragging a point of a linear element, might close a loop // and sets binding element - if (this.state.selectedLinearElement?.isEditing) { + if ( + this.state.selectedLinearElement?.isEditing && + !this.state.newElement + ) { if ( !pointerDownState.boxSelection.hasOccurred && pointerDownState.hit?.element?.id !== @@ -9263,10 +9855,14 @@ class App extends React.Component { this.state, this.scene, ); + this.actionManager.executeAction(actionFinalize, "ui", { + event: childEvent, + sceneCoords, + }); if (editingLinearElement !== this.state.selectedLinearElement) { this.setState({ selectedLinearElement: editingLinearElement, - suggestedBindings: [], + suggestedBinding: null, }); } } @@ -9300,6 +9896,23 @@ class App extends React.Component { sceneCoords, }); } + + if ( + this.state.newElement && + this.state.multiElement && + isLinearElement(this.state.newElement) && + this.state.selectedLinearElement + ) { + const { multiElement } = this.state; + + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + lastCommittedPoint: + multiElement.points[multiElement.points.length - 1], + }, + }); + } } this.missingPointerEventCleanupEmitter.clear(); @@ -9351,7 +9964,6 @@ class App extends React.Component { this.scene.mutateElement(newElement, { points: [...points, pointFrom(dx, dy)], pressures, - lastCommittedPoint: pointFrom(dx, dy), }); this.actionManager.executeAction(actionFinalize); @@ -9360,7 +9972,11 @@ class App extends React.Component { } if (isLinearElement(newElement)) { - if (newElement!.points.length > 1) { + if ( + newElement!.points.length > 1 && + newElement.points[1][0] !== 0 && + newElement.points[1][1] !== 0 + ) { this.store.scheduleCapture(); } const pointerCoords = viewportCoordsToSceneCoords( @@ -9406,7 +10022,7 @@ class App extends React.Component { this.scene.mutateElement( newElement, { - points: [...newElement.points, pointFrom(dx, dy)], + points: [newElement.points[0], pointFrom(dx, dy)], }, { informMutation: false, isDragging: false }, ); @@ -9417,16 +10033,13 @@ class App extends React.Component { }); } } else if (pointerDownState.drag.hasOccurred && !multiElement) { - if ( - isBindingEnabled(this.state) && - isBindingElement(newElement, false) - ) { + if (isBindingElement(newElement, false)) { this.actionManager.executeAction(actionFinalize, "ui", { event: childEvent, sceneCoords, }); } - this.setState({ suggestedBindings: [], startBoundElement: null }); + this.setState({ suggestedBinding: null, startBoundElement: null }); if (!activeTool.locked) { resetCursor(this.interactiveCanvas); this.setState((prevState) => ({ @@ -10021,15 +10634,9 @@ class App extends React.Component { // the endpoints ("start" or "end"). const linearElements = this.scene .getSelectedElements(this.state) - .filter(isLinearElement); + .filter(isArrowElement); - bindOrUnbindLinearElements( - linearElements, - isBindingEnabled(this.state), - this.state.selectedLinearElement?.selectedPointsIndices ?? [], - this.scene, - this.state.zoom, - ); + bindOrUnbindBindingElements(linearElements, this.scene, this.state); } if (activeTool.type === "laser") { @@ -10047,7 +10654,7 @@ class App extends React.Component { resetCursor(this.interactiveCanvas); this.setState({ newElement: null, - suggestedBindings: [], + suggestedBinding: null, activeTool: updateActiveTool(this.state, { type: this.state.preferredSelectionTool.type, }), @@ -10055,7 +10662,7 @@ class App extends React.Component { } else { this.setState({ newElement: null, - suggestedBindings: [], + suggestedBinding: null, }); } @@ -10087,6 +10694,67 @@ class App extends React.Component { private eraseElements = () => { let didChange = false; + + // Binding is double accounted on both elements and if one of them is + // deleted, the binding should be removed + this.elementsPendingErasure.forEach((id) => { + const element = this.scene.getElement(id); + if (isBindingElement(element)) { + if (element.startBinding) { + const bindable = this.scene.getElement( + element.startBinding.elementId, + )!; + // NOTE: We use the raw mutateElement() because we don't want history + // entries or multiplayer updates + mutateElement(bindable, this.scene.getElementsMapIncludingDeleted(), { + boundElements: bindable.boundElements!.filter( + (e) => e.id !== element.id, + ), + }); + } + if (element.endBinding) { + const bindable = this.scene.getElement(element.endBinding.elementId)!; + // NOTE: We use the raw mutateElement() because we don't want history + // entries or multiplayer updates + mutateElement(bindable, this.scene.getElementsMapIncludingDeleted(), { + boundElements: bindable.boundElements!.filter( + (e) => e.id !== element.id, + ), + }); + } + } else if (isBindableElement(element)) { + element.boundElements?.forEach((boundElement) => { + if (boundElement.type === "arrow") { + const arrow = this.scene.getElement( + boundElement.id, + ) as ExcalidrawArrowElement; + if (arrow?.startBinding?.elementId === element.id) { + // NOTE: We use the raw mutateElement() because we don't want history + // entries or multiplayer updates + mutateElement( + arrow, + this.scene.getElementsMapIncludingDeleted(), + { + startBinding: null, + }, + ); + } + if (arrow?.endBinding?.elementId === element.id) { + // NOTE: We use the raw mutateElement() because we don't want history + // entries or multiplayer updates + mutateElement( + arrow, + this.scene.getElementsMapIncludingDeleted(), + { + endBinding: null, + }, + ); + } + } + }); + } + }); + const elements = this.scene.getElementsIncludingDeleted().map((ele) => { if ( this.elementsPendingErasure.has(ele.id) || @@ -10395,27 +11063,6 @@ class App extends React.Component { } }; - private maybeSuggestBindingAtCursor = ( - pointerCoords: { - x: number; - y: number; - }, - considerAll: boolean, - ): void => { - const hoveredBindableElement = getHoveredElementForBinding( - pointerCoords, - this.scene.getNonDeletedElements(), - this.scene.getNonDeletedElementsMap(), - this.state.zoom, - false, - considerAll, - ); - this.setState({ - suggestedBindings: - hoveredBindableElement != null ? [hoveredBindableElement] : [], - }); - }; - private clearSelection(hitElement: ExcalidrawElement | null): void { this.setState((prevState) => ({ selectedElementIds: makeNextSelectedElementIds({}, prevState), @@ -10434,6 +11081,7 @@ class App extends React.Component { selectedElementIds: makeNextSelectedElementIds({}, this.state), activeEmbeddable: null, previousSelectedElementIds: this.state.selectedElementIds, + selectedLinearElement: null, }); } @@ -10992,12 +11640,7 @@ class App extends React.Component { ), ); - updateBoundElements(croppingElement, this.scene, { - newSize: { - width: croppingElement.width, - height: croppingElement.height, - }, - }); + updateBoundElements(croppingElement, this.scene); this.setState({ isCropping: transformHandleType && transformHandleType !== "rotation", @@ -11123,12 +11766,6 @@ class App extends React.Component { pointerDownState.resize.center.y, ) ) { - const suggestedBindings = getSuggestedBindingsForArrows( - selectedElements, - this.scene.getNonDeletedElementsMap(), - this.state.zoom, - ); - const elementsToHighlight = new Set(); selectedFrames.forEach((frame) => { getElementsInResizingFrame( @@ -11141,7 +11778,6 @@ class App extends React.Component { this.setState({ elementsToHighlight: [...elementsToHighlight], - suggestedBindings, }); return true; @@ -11455,6 +12091,8 @@ class App extends React.Component { }; } + watchState = () => {}; + private async updateLanguage() { const currentLang = languages.find((lang) => lang.code === this.props.langCode) || @@ -11474,6 +12112,7 @@ declare global { elements: readonly ExcalidrawElement[]; state: AppState; setState: React.Component["setState"]; + watchState: (prev: any, next: any) => void | undefined; app: InstanceType; history: History; store: Store; diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 3e922328b8..0840dd803d 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -1034,7 +1034,7 @@ const CommandItem = ({ size="var(--icon-size, 1rem)" icon={ typeof command.icon === "function" - ? command.icon(appState) + ? command.icon(appState, []) : command.icon } /> diff --git a/packages/excalidraw/components/CommandPalette/types.ts b/packages/excalidraw/components/CommandPalette/types.ts index 957d699273..3eed838ce8 100644 --- a/packages/excalidraw/components/CommandPalette/types.ts +++ b/packages/excalidraw/components/CommandPalette/types.ts @@ -1,6 +1,5 @@ import type { ActionManager } from "../../actions/manager"; import type { Action } from "../../actions/types"; -import type { UIAppState } from "../../types"; export type CommandPaletteItem = { label: string; @@ -12,7 +11,7 @@ export type CommandPaletteItem = { * (deburred name + keywords) */ haystack?: string; - icon?: React.ReactNode | ((appState: UIAppState) => React.ReactNode); + icon?: Action["icon"]; category: string; order?: number; predicate?: boolean | Action["predicate"]; diff --git a/packages/excalidraw/components/ConvertElementTypePopup.tsx b/packages/excalidraw/components/ConvertElementTypePopup.tsx index 8e527d5498..596456671c 100644 --- a/packages/excalidraw/components/ConvertElementTypePopup.tsx +++ b/packages/excalidraw/components/ConvertElementTypePopup.tsx @@ -844,7 +844,7 @@ const convertElementType = < }), ) as typeof element; - updateBindings(nextElement, app.scene); + updateBindings(nextElement, app.scene, app.state); return nextElement; } diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 2af653b6b6..457069e7d5 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -647,7 +647,7 @@ const LayerUI = ({ const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => { const { - suggestedBindings, + suggestedBinding, startBoundElement, cursorButton, scrollX, diff --git a/packages/excalidraw/components/Stats/Angle.tsx b/packages/excalidraw/components/Stats/Angle.tsx index 773f868880..c79e9bb3b1 100644 --- a/packages/excalidraw/components/Stats/Angle.tsx +++ b/packages/excalidraw/components/Stats/Angle.tsx @@ -34,6 +34,7 @@ const handleDegreeChange: DragInputCallbackType = ({ shouldChangeByStepSize, nextValue, scene, + app, }) => { const elementsMap = scene.getNonDeletedElementsMap(); const origElement = originalElements[0]; @@ -48,7 +49,7 @@ const handleDegreeChange: DragInputCallbackType = ({ scene.mutateElement(latestElement, { angle: nextAngle, }); - updateBindings(latestElement, scene); + updateBindings(latestElement, scene, app.state); const boundTextElement = getBoundTextElement(latestElement, elementsMap); if (boundTextElement && !isArrowElement(latestElement)) { @@ -74,7 +75,7 @@ const handleDegreeChange: DragInputCallbackType = ({ scene.mutateElement(latestElement, { angle: nextAngle, }); - updateBindings(latestElement, scene); + updateBindings(latestElement, scene, app.state); const boundTextElement = getBoundTextElement(latestElement, elementsMap); if (boundTextElement && !isArrowElement(latestElement)) { diff --git a/packages/excalidraw/components/Stats/MultiDimension.tsx b/packages/excalidraw/components/Stats/MultiDimension.tsx index 539a2ad59e..4680858dcd 100644 --- a/packages/excalidraw/components/Stats/MultiDimension.tsx +++ b/packages/excalidraw/components/Stats/MultiDimension.tsx @@ -94,9 +94,7 @@ const resizeElementInGroup = ( ); if (boundTextElement) { const newFontSize = boundTextElement.fontSize * scale; - updateBoundElements(latestElement, scene, { - newSize: { width: updates.width, height: updates.height }, - }); + updateBoundElements(latestElement, scene); const latestBoundTextElement = elementsMap.get(boundTextElement.id); if (latestBoundTextElement && isTextElement(latestBoundTextElement)) { scene.mutateElement(latestBoundTextElement, { diff --git a/packages/excalidraw/components/Stats/MultiPosition.tsx b/packages/excalidraw/components/Stats/MultiPosition.tsx index 19b52e2f49..35f6cfb897 100644 --- a/packages/excalidraw/components/Stats/MultiPosition.tsx +++ b/packages/excalidraw/components/Stats/MultiPosition.tsx @@ -38,6 +38,7 @@ const moveElements = ( originalElements: readonly ExcalidrawElement[], originalElementsMap: ElementsMap, scene: Scene, + appState: AppState, ) => { for (let i = 0; i < originalElements.length; i++) { const origElement = originalElements[i]; @@ -63,6 +64,7 @@ const moveElements = ( newTopLeftY, origElement, scene, + appState, originalElementsMap, false, ); @@ -75,6 +77,7 @@ const moveGroupTo = ( originalElements: ExcalidrawElement[], originalElementsMap: ElementsMap, scene: Scene, + appState: AppState, ) => { const elementsMap = scene.getNonDeletedElementsMap(); const [x1, y1, ,] = getCommonBounds(originalElements); @@ -107,6 +110,7 @@ const moveGroupTo = ( topLeftY + offsetY, origElement, scene, + appState, originalElementsMap, false, ); @@ -125,6 +129,7 @@ const handlePositionChange: DragInputCallbackType< property, scene, originalAppState, + app, }) => { const elementsMap = scene.getNonDeletedElementsMap(); @@ -152,6 +157,7 @@ const handlePositionChange: DragInputCallbackType< elementsInUnit.map((el) => el.original), originalElementsMap, scene, + app.state, ); } else { const origElement = elementsInUnit[0]?.original; @@ -178,6 +184,7 @@ const handlePositionChange: DragInputCallbackType< newTopLeftY, origElement, scene, + app.state, originalElementsMap, false, ); @@ -203,6 +210,7 @@ const handlePositionChange: DragInputCallbackType< originalElements, originalElementsMap, scene, + app.state, ); scene.triggerUpdate(); diff --git a/packages/excalidraw/components/Stats/Position.tsx b/packages/excalidraw/components/Stats/Position.tsx index f89ce26151..8b57183308 100644 --- a/packages/excalidraw/components/Stats/Position.tsx +++ b/packages/excalidraw/components/Stats/Position.tsx @@ -34,6 +34,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ property, scene, originalAppState, + app, }) => { const elementsMap = scene.getNonDeletedElementsMap(); const origElement = originalElements[0]; @@ -131,6 +132,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ newTopLeftY, origElement, scene, + app.state, originalElementsMap, ); return; @@ -162,6 +164,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ newTopLeftY, origElement, scene, + app.state, originalElementsMap, ); }; diff --git a/packages/excalidraw/components/Stats/index.tsx b/packages/excalidraw/components/Stats/index.tsx index bcfab85206..08c1f64970 100644 --- a/packages/excalidraw/components/Stats/index.tsx +++ b/packages/excalidraw/components/Stats/index.tsx @@ -6,7 +6,7 @@ import { useEffect, useMemo, useState, memo } from "react"; import { STATS_PANELS } from "@excalidraw/common"; import { getCommonBounds } from "@excalidraw/element"; import { getUncroppedWidthAndHeight } from "@excalidraw/element"; -import { isElbowArrow, isImageElement } from "@excalidraw/element"; +import { isImageElement } from "@excalidraw/element"; import { frameAndChildrenSelectedTogether } from "@excalidraw/element"; @@ -333,16 +333,14 @@ export const StatsInner = memo( appState={appState} /> - {!isElbowArrow(singleElement) && ( - - - - )} + + + { mouse.up(200, 100); UI.clickTool("arrow"); - mouse.down(5, 0); + mouse.down(-5, 0); mouse.up(300, 50); elementStats = stats?.querySelector("#elementStats"); @@ -135,18 +135,7 @@ describe("binding with linear elements", () => { ) as HTMLInputElement; expect(linear.startBinding).not.toBe(null); expect(inputX).not.toBeNull(); - UI.updateInput(inputX, String("204")); - expect(linear.startBinding).not.toBe(null); - }); - - it("should remain bound to linear element on small angle change", async () => { - const linear = h.elements[1] as ExcalidrawLinearElement; - const inputAngle = UI.queryStatsProperty("A")?.querySelector( - ".drag-input", - ) as HTMLInputElement; - - expect(linear.startBinding).not.toBe(null); - UI.updateInput(inputAngle, String("1")); + UI.updateInput(inputX, String("186")); expect(linear.startBinding).not.toBe(null); }); @@ -161,17 +150,6 @@ describe("binding with linear elements", () => { UI.updateInput(inputX, String("254")); expect(linear.startBinding).toBe(null); }); - - it("should remain bound to linear element on small angle change", async () => { - const linear = h.elements[1] as ExcalidrawLinearElement; - const inputAngle = UI.queryStatsProperty("A")?.querySelector( - ".drag-input", - ) as HTMLInputElement; - - expect(linear.startBinding).not.toBe(null); - UI.updateInput(inputAngle, String("45")); - expect(linear.startBinding).toBe(null); - }); }); // single element diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts index 68d2020987..7628261840 100644 --- a/packages/excalidraw/components/Stats/utils.ts +++ b/packages/excalidraw/components/Stats/utils.ts @@ -1,6 +1,10 @@ import { pointFrom, pointRotateRads } from "@excalidraw/math"; -import { getBoundTextElement } from "@excalidraw/element"; +import { + getBoundTextElement, + isBindingElement, + unbindBindingElement, +} from "@excalidraw/element"; import { isFrameLikeElement } from "@excalidraw/element"; import { @@ -12,6 +16,7 @@ import { import { getFrameChildren } from "@excalidraw/element"; import { updateBindings } from "@excalidraw/element"; +import { DRAGGING_THRESHOLD } from "@excalidraw/common"; import type { Radians } from "@excalidraw/math"; @@ -110,9 +115,25 @@ export const moveElement = ( newTopLeftY: number, originalElement: ExcalidrawElement, scene: Scene, + appState: AppState, originalElementsMap: ElementsMap, shouldInformMutation = true, ) => { + if ( + isBindingElement(originalElement) && + (originalElement.startBinding || originalElement.endBinding) + ) { + if ( + Math.abs(newTopLeftX - originalElement.x) < DRAGGING_THRESHOLD && + Math.abs(newTopLeftY - originalElement.y) < DRAGGING_THRESHOLD + ) { + return; + } + + unbindBindingElement(originalElement, "start", scene); + unbindBindingElement(originalElement, "end", scene); + } + const elementsMap = scene.getNonDeletedElementsMap(); const latestElement = elementsMap.get(originalElement.id); if (!latestElement) { @@ -145,7 +166,7 @@ export const moveElement = ( }, { informMutation: shouldInformMutation, isDragging: false }, ); - updateBindings(latestElement, scene); + updateBindings(latestElement, scene, appState); const boundTextElement = getBoundTextElement( originalElement, @@ -203,7 +224,7 @@ export const moveElement = ( }, { informMutation: shouldInformMutation, isDragging: false }, ); - updateBindings(latestChildElement, scene, { + updateBindings(latestChildElement, scene, appState, { simultaneouslyUpdated: originalChildren, }); }); diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index 80896faa21..32c118431e 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -157,6 +157,8 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { remotePointerUserStates, selectionColor, renderScrollbars: props.renderScrollbars, + // NOTE not memoized on so we don't rerender on cursor move + lastViewportPosition: props.app.lastViewportPosition, }, editorInterface: props.editorInterface, callback: props.renderInteractiveSceneCallback, @@ -242,8 +244,9 @@ const getRelevantAppStateProps = ( selectedGroupIds: appState.selectedGroupIds, selectedLinearElement: appState.selectedLinearElement, multiElement: appState.multiElement, + newElement: appState.newElement, isBindingEnabled: appState.isBindingEnabled, - suggestedBindings: appState.suggestedBindings, + suggestedBinding: appState.suggestedBinding, isRotating: appState.isRotating, elementsToHighlight: appState.elementsToHighlight, collaborators: appState.collaborators, // Necessary for collab. sessions @@ -255,6 +258,10 @@ const getRelevantAppStateProps = ( croppingElementId: appState.croppingElementId, searchMatches: appState.searchMatches, activeLockedId: appState.activeLockedId, + hoveredElementIds: appState.hoveredElementIds, + frameRendering: appState.frameRendering, + shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom, + exportScale: appState.exportScale, }); const areEqual = ( diff --git a/packages/excalidraw/components/canvases/StaticCanvas.tsx b/packages/excalidraw/components/canvases/StaticCanvas.tsx index 9e23fa500b..9e6a3324a4 100644 --- a/packages/excalidraw/components/canvases/StaticCanvas.tsx +++ b/packages/excalidraw/components/canvases/StaticCanvas.tsx @@ -99,6 +99,7 @@ const getRelevantAppStateProps = (appState: AppState): StaticCanvasAppState => { editingGroupId: appState.editingGroupId, currentHoveredFontFamily: appState.currentHoveredFontFamily, croppingElementId: appState.croppingElementId, + suggestedBinding: appState.suggestedBinding, }; return relevantAppStateProps; diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index f00a51817d..afef25eeff 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -88,8 +88,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "endArrowhead": "arrow", "endBinding": { "elementId": "ellipse-1", - "focus": -0.007519379844961235, - "gap": 11.562288374879595, + "fixedPoint": [ + 0.04, + 0.4633333333333333, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -98,7 +101,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "id": Any, "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -118,8 +120,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "startArrowhead": null, "startBinding": { "elementId": "id49", - "focus": -0.0813953488372095, - "gap": 1, + "fixedPoint": [ + 1, + 0.5001, + ], + "mode": "orbit", }, "strokeColor": "#1864ab", "strokeStyle": "solid", @@ -144,8 +149,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "endArrowhead": "arrow", "endBinding": { "elementId": "ellipse-1", - "focus": 0.10666666666666667, - "gap": 3.8343264684446097, + "fixedPoint": [ + -0.01, + 0.44666666666666666, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -154,7 +162,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "id": Any, "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -174,8 +181,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "startArrowhead": null, "startBinding": { "elementId": "diamond-1", - "focus": 0, - "gap": 4.535423522449215, + "fixedPoint": [ + 0.9357142857142857, + 0.5001, + ], + "mode": "orbit", }, "strokeColor": "#e67700", "strokeStyle": "solid", @@ -334,8 +344,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "endArrowhead": "arrow", "endBinding": { "elementId": "text-2", - "focus": 0, - "gap": 16, + "fixedPoint": [ + -2.05, + 0.5001, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -344,7 +357,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "id": Any, "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -364,8 +376,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "startArrowhead": null, "startBinding": { "elementId": "text-1", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + 0.5001, + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -436,8 +451,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "endArrowhead": "arrow", "endBinding": { "elementId": "id42", - "focus": -0, - "gap": 1, + "fixedPoint": [ + 0, + 0.5001, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -446,7 +464,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "id": Any, "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -466,8 +483,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "startArrowhead": null, "startBinding": { "elementId": "id41", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + 0.5001, + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -612,8 +632,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "endArrowhead": "arrow", "endBinding": { "elementId": "id46", - "focus": -0, - "gap": 1, + "fixedPoint": [ + 0, + 0.5001, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -622,7 +645,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "id": Any, "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -642,8 +664,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "startArrowhead": null, "startBinding": { "elementId": "id45", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1, + 0.5001, + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -839,7 +864,6 @@ exports[`Test Transform > should transform linear elements 1`] = ` "id": Any, "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -887,7 +911,6 @@ exports[`Test Transform > should transform linear elements 2`] = ` "id": Any, "index": "a1", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -934,7 +957,6 @@ exports[`Test Transform > should transform linear elements 3`] = ` "id": Any, "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -982,7 +1004,6 @@ exports[`Test Transform > should transform linear elements 4`] = ` "id": Any, "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -1476,8 +1497,11 @@ exports[`Test Transform > should transform the elements correctly when linear el "endArrowhead": "arrow", "endBinding": { "elementId": "Alice", - "focus": -0, - "gap": 5.299874999999986, + "fixedPoint": [ + -0.07542628418945944, + 0.5001, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -1486,7 +1510,6 @@ exports[`Test Transform > should transform the elements correctly when linear el "id": Any, "index": "a4", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -1508,8 +1531,11 @@ exports[`Test Transform > should transform the elements correctly when linear el "startArrowhead": null, "startBinding": { "elementId": "Bob", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 1.000004978564514, + 0.5001, + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -1539,8 +1565,11 @@ exports[`Test Transform > should transform the elements correctly when linear el "endArrowhead": "arrow", "endBinding": { "elementId": "B", - "focus": 0, - "gap": 32, + "fixedPoint": [ + 0.46387050630528887, + 0.48466257668711654, + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, @@ -1549,7 +1578,6 @@ exports[`Test Transform > should transform the elements correctly when linear el "id": Any, "index": "a5", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -1567,8 +1595,11 @@ exports[`Test Transform > should transform the elements correctly when linear el "startArrowhead": null, "startBinding": { "elementId": "Bob", - "focus": 0, - "gap": 1, + "fixedPoint": [ + 0.39381496335223337, + 1, + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -1858,7 +1889,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide "id": Any, "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -1911,7 +1941,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide "id": Any, "index": "a1", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -1964,7 +1993,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide "id": Any, "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -2017,7 +2045,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide "id": Any, "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index e8a5401a7a..4de63d645f 100644 --- a/packages/excalidraw/data/blob.ts +++ b/packages/excalidraw/data/blob.ts @@ -7,8 +7,6 @@ import { isPromiseLike, } from "@excalidraw/common"; -import { clearElementsForExport } from "@excalidraw/element"; - import type { ValueOf } from "@excalidraw/common/utility-types"; import type { ExcalidrawElement, FileId } from "@excalidraw/element/types"; @@ -159,7 +157,7 @@ export const loadSceneOrLibraryFromBlob = async ( type: MIME_TYPES.excalidraw, data: restore( { - elements: clearElementsForExport(data.elements || []), + elements: data.elements || [], appState: { theme: localAppState?.theme, fileHandle: fileHandle || blob.handle || null, diff --git a/packages/excalidraw/data/json.ts b/packages/excalidraw/data/json.ts index b8fb0f62cc..047a2ccdec 100644 --- a/packages/excalidraw/data/json.ts +++ b/packages/excalidraw/data/json.ts @@ -6,11 +6,6 @@ import { VERSIONS, } from "@excalidraw/common"; -import { - clearElementsForDatabase, - clearElementsForExport, -} from "@excalidraw/element"; - import type { ExcalidrawElement } from "@excalidraw/element/types"; import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState"; @@ -57,10 +52,7 @@ export const serializeAsJSON = ( type: EXPORT_DATA_TYPES.excalidraw, version: VERSIONS.excalidraw, source: getExportSource(), - elements: - type === "local" - ? clearElementsForExport(elements) - : clearElementsForDatabase(elements), + elements, appState: type === "local" ? cleanAppStateForExport(appState) diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 34bdc8f57f..23c4d04003 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -18,7 +18,13 @@ import { normalizeLink, getLineHeight, } from "@excalidraw/common"; -import { getNonDeletedElements, isValidPolygon } from "@excalidraw/element"; +import { + calculateFixedPointForNonElbowArrowBinding, + getNonDeletedElements, + isPointInElement, + isValidPolygon, + projectFixedPointOntoDiagonal, +} from "@excalidraw/element"; import { normalizeFixedPoint } from "@excalidraw/element"; import { updateElbowArrowPoints, @@ -32,7 +38,6 @@ import { isArrowBoundToElement, isArrowElement, isElbowArrow, - isFixedPointBinding, isLinearElement, isLineElement, isTextElement, @@ -50,10 +55,11 @@ import { isInvisiblySmallElement } from "@excalidraw/element"; import type { LocalPoint, Radians } from "@excalidraw/math"; import type { + ElementsMap, ExcalidrawArrowElement, + ExcalidrawBindableElement, ExcalidrawElbowArrowElement, ExcalidrawElement, - ExcalidrawElementType, ExcalidrawLinearElement, ExcalidrawSelectionElement, ExcalidrawTextElement, @@ -61,7 +67,6 @@ import type { FontFamilyValues, NonDeletedSceneElementsMap, OrderedExcalidrawElement, - PointBinding, StrokeRoundness, } from "@excalidraw/element/types"; @@ -121,38 +126,74 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => { return DEFAULT_FONT_FAMILY; }; -const repairBinding = ( +const repairBinding = ( element: T, - binding: PointBinding | FixedPointBinding | null, -): T extends ExcalidrawElbowArrowElement - ? FixedPointBinding | null - : PointBinding | FixedPointBinding | null => { + binding: FixedPointBinding | null, + elementsMap: Readonly, + startOrEnd: "start" | "end", +): FixedPointBinding | null => { if (!binding) { return null; } - const focus = binding.focus || 0; - if (isElbowArrow(element)) { const fixedPointBinding: | ExcalidrawElbowArrowElement["startBinding"] - | ExcalidrawElbowArrowElement["endBinding"] = isFixedPointBinding(binding) - ? { - ...binding, - focus, - fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]), - } - : null; + | ExcalidrawElbowArrowElement["endBinding"] = { + ...binding, + fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]), + mode: binding.mode || "orbit", + }; return fixedPointBinding; } - return { - ...binding, - focus, - } as T extends ExcalidrawElbowArrowElement - ? FixedPointBinding | null - : PointBinding | FixedPointBinding | null; + const boundElement = + (elementsMap.get(binding.elementId) as ExcalidrawBindableElement) || + undefined; + if (boundElement) { + if (binding.mode) { + return { + elementId: binding.elementId, + mode: binding.mode || "orbit", + fixedPoint: normalizeFixedPoint(binding.fixedPoint || [0.5, 0.5]), + } as FixedPointBinding | null; + } + + const p = LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + startOrEnd === "start" ? 0 : element.points.length - 1, + elementsMap, + ); + const mode = isPointInElement(p, boundElement, elementsMap) + ? "inside" + : "orbit"; + const focusPoint = + mode === "inside" + ? p + : projectFixedPointOntoDiagonal( + element, + p, + boundElement, + startOrEnd, + elementsMap, + ) || p; + const { fixedPoint } = calculateFixedPointForNonElbowArrowBinding( + element, + boundElement, + startOrEnd, + elementsMap, + focusPoint, + ); + + return { + mode, + elementId: binding.elementId, + fixedPoint, + }; + } + + return null; }; const restoreElementWithProperties = < @@ -243,7 +284,10 @@ const restoreElementWithProperties = < export const restoreElement = ( element: Exclude, - opts?: { deleteInvisibleElements?: boolean }, + elementsMap: Readonly, + opts?: { + deleteInvisibleElements?: boolean; + }, ): typeof element | null => { element = { ...element }; @@ -301,7 +345,6 @@ export const restoreElement = ( case "freedraw": { return restoreElementWithProperties(element, { points: element.points, - lastCommittedPoint: null, simulatePressure: element.simulatePressure, pressures: element.pressures, }); @@ -331,13 +374,9 @@ export const restoreElement = ( } return restoreElementWithProperties(element, { - type: - (element.type as ExcalidrawElementType | "draw") === "draw" - ? "line" - : element.type, - startBinding: repairBinding(element, element.startBinding), - endBinding: repairBinding(element, element.endBinding), - lastCommittedPoint: null, + type: "line", + startBinding: null, + endBinding: null, startArrowhead, endArrowhead, points, @@ -354,23 +393,27 @@ export const restoreElement = ( }); case "arrow": { const { startArrowhead = null, endArrowhead = "arrow" } = element; - let x: number | undefined = element.x; - let y: number | undefined = element.y; - let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one + const x: number | undefined = element.x; + const y: number | undefined = element.y; + const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one !Array.isArray(element.points) || element.points.length < 2 ? [pointFrom(0, 0), pointFrom(element.width, element.height)] : element.points; - if (points[0][0] !== 0 || points[0][1] !== 0) { - ({ points, x, y } = - LinearElementEditor.getNormalizeElementPointsAndCoords(element)); - } - const base = { type: element.type, - startBinding: repairBinding(element, element.startBinding), - endBinding: repairBinding(element, element.endBinding), - lastCommittedPoint: null, + startBinding: repairBinding( + element as ExcalidrawArrowElement, + element.startBinding, + elementsMap, + "start", + ), + endBinding: repairBinding( + element as ExcalidrawArrowElement, + element.endBinding, + elementsMap, + "end", + ), startArrowhead, endArrowhead, points, @@ -378,15 +421,13 @@ export const restoreElement = ( y, elbowed: (element as ExcalidrawArrowElement).elbowed, ...getSizeFromPoints(points), - } as const; + }; // TODO: Separate arrow from linear element - return isElbowArrow(element) + const restoredElement = isElbowArrow(element) ? restoreElementWithProperties(element as ExcalidrawElbowArrowElement, { ...base, elbowed: true, - startBinding: repairBinding(element, element.startBinding), - endBinding: repairBinding(element, element.endBinding), fixedSegments: element.fixedSegments?.length && base.points.length >= 4 ? element.fixedSegments @@ -395,6 +436,13 @@ export const restoreElement = ( endIsSpecial: element.endIsSpecial, }) : restoreElementWithProperties(element as ExcalidrawArrowElement, base); + + return { + ...restoredElement, + ...LinearElementEditor.getNormalizeElementPointsAndCoords( + restoredElement, + ), + }; } // generic elements @@ -525,7 +573,7 @@ const repairFrameMembership = ( }; export const restoreElements = ( - elements: ImportedDataState["elements"], + targetElements: ImportedDataState["elements"], /** NOTE doesn't serve for reconciliation */ localElements: readonly ExcalidrawElement[] | null | undefined, opts?: @@ -538,18 +586,23 @@ export const restoreElements = ( ): OrderedExcalidrawElement[] => { // used to detect duplicate top-level element ids const existingIds = new Set(); + const elementsMap = arrayToMap(targetElements || []); const localElementsMap = localElements ? arrayToMap(localElements) : null; const restoredElements = syncInvalidIndices( - (elements || []).reduce((elements, element) => { + (targetElements || []).reduce((elements, element) => { // filtering out selection, which is legacy, no longer kept in elements, // and causing issues if retained if (element.type === "selection") { return elements; } - let migratedElement: ExcalidrawElement | null = restoreElement(element, { - deleteInvisibleElements: opts?.deleteInvisibleElements, - }); + let migratedElement: ExcalidrawElement | null = restoreElement( + element, + elementsMap, + { + deleteInvisibleElements: opts?.deleteInvisibleElements, + }, + ); if (migratedElement) { const localElement = localElementsMap?.get(element.id); diff --git a/packages/excalidraw/data/transform.test.ts b/packages/excalidraw/data/transform.test.ts index 0d9fcf3161..b620abfe55 100644 --- a/packages/excalidraw/data/transform.test.ts +++ b/packages/excalidraw/data/transform.test.ts @@ -432,12 +432,9 @@ describe("Test Transform", () => { boundElements: [{ id: text.id, type: "text" }], startBinding: { elementId: rectangle.id, - focus: 0, - gap: 1, }, endBinding: { elementId: ellipse.id, - focus: -0, }, }); @@ -517,12 +514,9 @@ describe("Test Transform", () => { boundElements: [{ id: text1.id, type: "text" }], startBinding: { elementId: text2.id, - focus: 0, - gap: 1, }, endBinding: { elementId: text3.id, - focus: -0, }, }); @@ -780,8 +774,8 @@ describe("Test Transform", () => { const [arrow, rect] = excalidrawElements; expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({ elementId: "rect-1", - focus: -0, - gap: 25, + fixedPoint: [-2.05, 0.5001], + mode: "orbit", }); expect(rect.boundElements).toStrictEqual([ { diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts index fd0d3388ff..5b9f67e652 100644 --- a/packages/excalidraw/data/transform.ts +++ b/packages/excalidraw/data/transform.ts @@ -16,7 +16,7 @@ import { getLineHeight, } from "@excalidraw/common"; -import { bindLinearElement } from "@excalidraw/element"; +import { bindBindingElement } from "@excalidraw/element"; import { newArrowElement, newElement, @@ -330,9 +330,10 @@ const bindLinearElementToElement = ( } } - bindLinearElement( + bindBindingElement( linearElement, startBoundElement as ExcalidrawBindableElement, + "orbit", "start", scene, ); @@ -405,9 +406,10 @@ const bindLinearElementToElement = ( } } - bindLinearElement( + bindBindingElement( linearElement, endBoundElement as ExcalidrawBindableElement, + "orbit", "end", scene, ); diff --git a/packages/excalidraw/global.d.ts b/packages/excalidraw/global.d.ts index e9b6c3f96c..4d6bbbb6c6 100644 --- a/packages/excalidraw/global.d.ts +++ b/packages/excalidraw/global.d.ts @@ -101,7 +101,10 @@ declare module "image-blob-reduce" { interface CustomMatchers { toBeNonNaNNumber(): void; - toCloselyEqualPoints(points: readonly [number, number][]): void; + toCloselyEqualPoints( + points: readonly [number, number][], + precision?: number, + ): void; } declare namespace jest { diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 21b3f84d18..d6fd2654bf 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -341,6 +341,7 @@ "canvasPanning": "To move canvas, hold {{shortcut_1}} or {{shortcut_2}} while dragging, or use the hand tool", "linearElement": "Click to start multiple points, drag for single line", "arrowTool": "Click to start multiple points, drag for single line. Press {{shortcut}} again to change arrow type.", + "arrowBindModifiers": "Hold {{shortcut_1}} to bind inside, or {{shortcut_2}} to disable binding", "freeDraw": "Click and drag, release when you're finished", "text": "Tip: you can also add text by double-clicking anywhere with the selection tool", "embeddable": "Click-drag to create a website embed", diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 845efc15c8..a5da2c3a31 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -81,8 +81,8 @@ "@braintree/sanitize-url": "6.0.2", "@excalidraw/common": "0.18.0", "@excalidraw/element": "0.18.0", - "@excalidraw/math": "0.18.0", "@excalidraw/laser-pointer": "1.3.1", + "@excalidraw/math": "0.18.0", "@excalidraw/mermaid-to-excalidraw": "1.1.3", "@excalidraw/random-username": "1.1.0", "@radix-ui/react-popover": "1.1.6", @@ -97,8 +97,8 @@ "image-blob-reduce": "3.0.1", "jotai": "2.11.0", "jotai-scope": "0.7.2", - "lodash.throttle": "4.1.1", "lodash.debounce": "4.0.8", + "lodash.throttle": "4.1.1", "nanoid": "3.3.3", "open-color": "1.9.1", "pako": "2.0.3", diff --git a/packages/excalidraw/renderer/helpers.ts b/packages/excalidraw/renderer/helpers.ts index d357822ec6..cfa502dfab 100644 --- a/packages/excalidraw/renderer/helpers.ts +++ b/packages/excalidraw/renderer/helpers.ts @@ -1,26 +1,5 @@ import { THEME, THEME_FILTER } from "@excalidraw/common"; -import { FIXED_BINDING_DISTANCE } from "@excalidraw/element"; -import { getDiamondPoints } from "@excalidraw/element"; -import { elementCenterPoint, getCornerRadius } from "@excalidraw/element"; - -import { - curve, - curveCatmullRomCubicApproxPoints, - curveCatmullRomQuadraticApproxPoints, - curveOffsetPoints, - type GlobalPoint, - offsetPointsForQuadraticBezier, - pointFrom, - pointRotateRads, -} from "@excalidraw/math"; - -import type { - ElementsMap, - ExcalidrawDiamondElement, - ExcalidrawRectanguloidElement, -} from "@excalidraw/element/types"; - import type { StaticCanvasRenderConfig } from "../scene/types"; import type { AppState, StaticCanvasAppState } from "../types"; @@ -97,164 +76,7 @@ export const bootstrapCanvas = ({ return context; }; -function drawCatmullRomQuadraticApprox( - ctx: CanvasRenderingContext2D, - points: GlobalPoint[], - tension = 0.5, -) { - const pointSets = curveCatmullRomQuadraticApproxPoints(points, tension); - if (pointSets) { - for (let i = 0; i < pointSets.length - 1; i++) { - const [[cpX, cpY], [p2X, p2Y]] = pointSets[i]; - - ctx.quadraticCurveTo(cpX, cpY, p2X, p2Y); - } - } -} - -function drawCatmullRomCubicApprox( - ctx: CanvasRenderingContext2D, - points: GlobalPoint[], - tension = 0.5, -) { - const pointSets = curveCatmullRomCubicApproxPoints(points, tension); - if (pointSets) { - for (let i = 0; i < pointSets.length; i++) { - const [[cp1x, cp1y], [cp2x, cp2y], [x, y]] = pointSets[i]; - ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y); - } - } -} - -export const drawHighlightForRectWithRotation = ( - context: CanvasRenderingContext2D, - element: ExcalidrawRectanguloidElement, - elementsMap: ElementsMap, - padding: number, -) => { - const [x, y] = pointRotateRads( - pointFrom(element.x, element.y), - elementCenterPoint(element, elementsMap), - element.angle, - ); - - context.save(); - context.translate(x, y); - context.rotate(element.angle); - - let radius = getCornerRadius( - Math.min(element.width, element.height), - element, - ); - if (radius === 0) { - radius = 0.01; - } - - context.beginPath(); - - { - const topLeftApprox = offsetPointsForQuadraticBezier( - pointFrom(0, 0 + radius), - pointFrom(0, 0), - pointFrom(0 + radius, 0), - padding, - ); - const topRightApprox = offsetPointsForQuadraticBezier( - pointFrom(element.width - radius, 0), - pointFrom(element.width, 0), - pointFrom(element.width, radius), - padding, - ); - const bottomRightApprox = offsetPointsForQuadraticBezier( - pointFrom(element.width, element.height - radius), - pointFrom(element.width, element.height), - pointFrom(element.width - radius, element.height), - padding, - ); - const bottomLeftApprox = offsetPointsForQuadraticBezier( - pointFrom(radius, element.height), - pointFrom(0, element.height), - pointFrom(0, element.height - radius), - padding, - ); - - context.moveTo( - topLeftApprox[topLeftApprox.length - 1][0], - topLeftApprox[topLeftApprox.length - 1][1], - ); - context.lineTo(topRightApprox[0][0], topRightApprox[0][1]); - drawCatmullRomQuadraticApprox(context, topRightApprox); - context.lineTo(bottomRightApprox[0][0], bottomRightApprox[0][1]); - drawCatmullRomQuadraticApprox(context, bottomRightApprox); - context.lineTo(bottomLeftApprox[0][0], bottomLeftApprox[0][1]); - drawCatmullRomQuadraticApprox(context, bottomLeftApprox); - context.lineTo(topLeftApprox[0][0], topLeftApprox[0][1]); - drawCatmullRomQuadraticApprox(context, topLeftApprox); - } - - // Counter-clockwise for the cutout in the middle. We need to have an "inverse - // mask" on a filled shape for the diamond highlight, because stroking creates - // sharp inset edges on line joins < 90 degrees. - { - const topLeftApprox = offsetPointsForQuadraticBezier( - pointFrom(0 + radius, 0), - pointFrom(0, 0), - pointFrom(0, 0 + radius), - -FIXED_BINDING_DISTANCE, - ); - const topRightApprox = offsetPointsForQuadraticBezier( - pointFrom(element.width, radius), - pointFrom(element.width, 0), - pointFrom(element.width - radius, 0), - -FIXED_BINDING_DISTANCE, - ); - const bottomRightApprox = offsetPointsForQuadraticBezier( - pointFrom(element.width - radius, element.height), - pointFrom(element.width, element.height), - pointFrom(element.width, element.height - radius), - -FIXED_BINDING_DISTANCE, - ); - const bottomLeftApprox = offsetPointsForQuadraticBezier( - pointFrom(0, element.height - radius), - pointFrom(0, element.height), - pointFrom(radius, element.height), - -FIXED_BINDING_DISTANCE, - ); - - context.moveTo( - topLeftApprox[topLeftApprox.length - 1][0], - topLeftApprox[topLeftApprox.length - 1][1], - ); - context.lineTo(bottomLeftApprox[0][0], bottomLeftApprox[0][1]); - drawCatmullRomQuadraticApprox(context, bottomLeftApprox); - context.lineTo(bottomRightApprox[0][0], bottomRightApprox[0][1]); - drawCatmullRomQuadraticApprox(context, bottomRightApprox); - context.lineTo(topRightApprox[0][0], topRightApprox[0][1]); - drawCatmullRomQuadraticApprox(context, topRightApprox); - context.lineTo(topLeftApprox[0][0], topLeftApprox[0][1]); - drawCatmullRomQuadraticApprox(context, topLeftApprox); - } - - context.closePath(); - context.fill(); - - context.restore(); -}; - -export const strokeEllipseWithRotation = ( - context: CanvasRenderingContext2D, - width: number, - height: number, - cx: number, - cy: number, - angle: number, -) => { - context.beginPath(); - context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2); - context.stroke(); -}; - -export const strokeRectWithRotation = ( +export const strokeRectWithRotation_simple = ( context: CanvasRenderingContext2D, x: number, y: number, @@ -283,147 +105,3 @@ export const strokeRectWithRotation = ( } context.restore(); }; - -export const drawHighlightForDiamondWithRotation = ( - context: CanvasRenderingContext2D, - padding: number, - element: ExcalidrawDiamondElement, - elementsMap: ElementsMap, -) => { - const [x, y] = pointRotateRads( - pointFrom(element.x, element.y), - elementCenterPoint(element, elementsMap), - element.angle, - ); - context.save(); - context.translate(x, y); - context.rotate(element.angle); - - { - context.beginPath(); - - const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = - getDiamondPoints(element); - const verticalRadius = element.roundness - ? getCornerRadius(Math.abs(topX - leftX), element) - : (topX - leftX) * 0.01; - const horizontalRadius = element.roundness - ? getCornerRadius(Math.abs(rightY - topY), element) - : (rightY - topY) * 0.01; - const topApprox = curveOffsetPoints( - curve( - pointFrom(topX - verticalRadius, topY + horizontalRadius), - pointFrom(topX, topY), - pointFrom(topX, topY), - pointFrom(topX + verticalRadius, topY + horizontalRadius), - ), - padding, - ); - const rightApprox = curveOffsetPoints( - curve( - pointFrom(rightX - verticalRadius, rightY - horizontalRadius), - pointFrom(rightX, rightY), - pointFrom(rightX, rightY), - pointFrom(rightX - verticalRadius, rightY + horizontalRadius), - ), - padding, - ); - const bottomApprox = curveOffsetPoints( - curve( - pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius), - pointFrom(bottomX, bottomY), - pointFrom(bottomX, bottomY), - pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius), - ), - padding, - ); - const leftApprox = curveOffsetPoints( - curve( - pointFrom(leftX + verticalRadius, leftY + horizontalRadius), - pointFrom(leftX, leftY), - pointFrom(leftX, leftY), - pointFrom(leftX + verticalRadius, leftY - horizontalRadius), - ), - padding, - ); - - context.moveTo( - topApprox[topApprox.length - 1][0], - topApprox[topApprox.length - 1][1], - ); - context.lineTo(rightApprox[1][0], rightApprox[1][1]); - drawCatmullRomCubicApprox(context, rightApprox); - context.lineTo(bottomApprox[1][0], bottomApprox[1][1]); - drawCatmullRomCubicApprox(context, bottomApprox); - context.lineTo(leftApprox[1][0], leftApprox[1][1]); - drawCatmullRomCubicApprox(context, leftApprox); - context.lineTo(topApprox[1][0], topApprox[1][1]); - drawCatmullRomCubicApprox(context, topApprox); - } - - // Counter-clockwise for the cutout in the middle. We need to have an "inverse - // mask" on a filled shape for the diamond highlight, because stroking creates - // sharp inset edges on line joins < 90 degrees. - { - const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = - getDiamondPoints(element); - const verticalRadius = element.roundness - ? getCornerRadius(Math.abs(topX - leftX), element) - : (topX - leftX) * 0.01; - const horizontalRadius = element.roundness - ? getCornerRadius(Math.abs(rightY - topY), element) - : (rightY - topY) * 0.01; - const topApprox = curveOffsetPoints( - curve( - pointFrom(topX + verticalRadius, topY + horizontalRadius), - pointFrom(topX, topY), - pointFrom(topX, topY), - pointFrom(topX - verticalRadius, topY + horizontalRadius), - ), - -FIXED_BINDING_DISTANCE, - ); - const rightApprox = curveOffsetPoints( - curve( - pointFrom(rightX - verticalRadius, rightY + horizontalRadius), - pointFrom(rightX, rightY), - pointFrom(rightX, rightY), - pointFrom(rightX - verticalRadius, rightY - horizontalRadius), - ), - -FIXED_BINDING_DISTANCE, - ); - const bottomApprox = curveOffsetPoints( - curve( - pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius), - pointFrom(bottomX, bottomY), - pointFrom(bottomX, bottomY), - pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius), - ), - -FIXED_BINDING_DISTANCE, - ); - const leftApprox = curveOffsetPoints( - curve( - pointFrom(leftX + verticalRadius, leftY - horizontalRadius), - pointFrom(leftX, leftY), - pointFrom(leftX, leftY), - pointFrom(leftX + verticalRadius, leftY + horizontalRadius), - ), - -FIXED_BINDING_DISTANCE, - ); - - context.moveTo( - topApprox[topApprox.length - 1][0], - topApprox[topApprox.length - 1][1], - ); - context.lineTo(leftApprox[1][0], leftApprox[1][1]); - drawCatmullRomCubicApprox(context, leftApprox); - context.lineTo(bottomApprox[1][0], bottomApprox[1][1]); - drawCatmullRomCubicApprox(context, bottomApprox); - context.lineTo(rightApprox[1][0], rightApprox[1][1]); - drawCatmullRomCubicApprox(context, rightApprox); - context.lineTo(topApprox[1][0], topApprox[1][1]); - drawCatmullRomCubicApprox(context, topApprox); - } - context.closePath(); - context.fill(); - context.restore(); -}; diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 72caa6892d..c4c606cbec 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -1,4 +1,5 @@ import { + clamp, pointFrom, pointsEqual, type GlobalPoint, @@ -9,27 +10,29 @@ import oc from "open-color"; import { arrayToMap, + BIND_MODE_TIMEOUT, DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE, + getFeatureFlag, invariant, THEME, } from "@excalidraw/common"; -import { FIXED_BINDING_DISTANCE, maxBindingGap } from "@excalidraw/element"; -import { LinearElementEditor } from "@excalidraw/element"; import { + deconstructDiamondElement, + deconstructRectanguloidElement, + elementCenterPoint, getOmitSidesForEditorInterface, getTransformHandles, getTransformHandlesFromCoords, hasBoundingBox, -} from "@excalidraw/element"; -import { isElbowArrow, isFrameLikeElement, isImageElement, isLinearElement, isLineElement, isTextElement, + LinearElementEditor, } from "@excalidraw/element"; import { renderSelectionElement } from "@excalidraw/element"; @@ -43,11 +46,6 @@ import { import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element"; -import type { - SuggestedBinding, - SuggestedPointBinding, -} from "@excalidraw/element"; - import type { TransformHandles, TransformHandleType, @@ -63,6 +61,7 @@ import type { ExcalidrawTextElement, GroupId, NonDeleted, + NonDeletedSceneElementsMap, } from "@excalidraw/element/types"; import { renderSnaps } from "../renderer/renderSnaps"; @@ -72,18 +71,19 @@ import { SCROLLBAR_COLOR, SCROLLBAR_WIDTH, } from "../scene/scrollbars"; -import { type InteractiveCanvasAppState } from "../types"; + +import { + type AppClassProperties, + type InteractiveCanvasAppState, +} from "../types"; import { getClientColor, renderRemoteCursors } from "../clients"; import { bootstrapCanvas, - drawHighlightForDiamondWithRotation, - drawHighlightForRectWithRotation, fillCircle, getNormalizedCanvasDimensions, - strokeEllipseWithRotation, - strokeRectWithRotation, + strokeRectWithRotation_simple, } from "./helpers"; import type { @@ -187,83 +187,485 @@ const renderSingleLinearPoint = ( ); }; -const renderBindingHighlightForBindableElement = ( +const renderBindingHighlightForBindableElement_simple = ( context: CanvasRenderingContext2D, element: ExcalidrawBindableElement, elementsMap: ElementsMap, - zoom: InteractiveCanvasAppState["zoom"], + appState: InteractiveCanvasAppState, ) => { - const padding = maxBindingGap(element, element.width, element.height, zoom); + const enclosingFrame = element.frameId && elementsMap.get(element.frameId); + if (enclosingFrame && isFrameLikeElement(enclosingFrame)) { + context.translate( + enclosingFrame.x + appState.scrollX, + enclosingFrame.y + appState.scrollY, + ); - context.fillStyle = "rgba(0,0,0,.05)"; + context.beginPath(); + + if (FRAME_STYLE.radius && context.roundRect) { + context.roundRect( + -1, + -1, + enclosingFrame.width + 1, + enclosingFrame.height + 1, + FRAME_STYLE.radius / appState.zoom.value, + ); + } else { + context.rect(-1, -1, enclosingFrame.width + 1, enclosingFrame.height + 1); + } + + context.clip(); + + context.translate( + -(enclosingFrame.x + appState.scrollX), + -(enclosingFrame.y + appState.scrollY), + ); + } switch (element.type) { - case "rectangle": - case "text": - case "image": - case "iframe": - case "embeddable": - case "frame": case "magicframe": - drawHighlightForRectWithRotation(context, element, elementsMap, padding); - break; - case "diamond": - drawHighlightForDiamondWithRotation( - context, - padding, - element, - elementsMap, - ); - break; - case "ellipse": { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const width = x2 - x1; - const height = y2 - y1; + case "frame": + context.save(); - context.strokeStyle = "rgba(0,0,0,.05)"; - context.lineWidth = padding - FIXED_BINDING_DISTANCE; - - strokeEllipseWithRotation( - context, - width + padding + FIXED_BINDING_DISTANCE, - height + padding + FIXED_BINDING_DISTANCE, - x1 + width / 2, - y1 + height / 2, - element.angle, + context.translate( + element.x + appState.scrollX, + element.y + appState.scrollY, ); + + context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; + context.strokeStyle = + appState.theme === THEME.DARK + ? `rgba(3, 93, 161, 1)` + : `rgba(106, 189, 252, 1)`; + + if (FRAME_STYLE.radius && context.roundRect) { + context.beginPath(); + context.roundRect( + 0, + 0, + element.width, + element.height, + FRAME_STYLE.radius / appState.zoom.value, + ); + context.stroke(); + context.closePath(); + } else { + context.strokeRect(0, 0, element.width, element.height); + } + + context.restore(); + break; + default: + context.save(); + + const center = elementCenterPoint(element, elementsMap); + + context.translate(center[0], center[1]); + context.rotate(element.angle as Radians); + context.translate(-center[0], -center[1]); + + context.translate(element.x, element.y); + + context.lineWidth = + clamp(1.75, element.strokeWidth, 4) / + Math.max(0.25, appState.zoom.value); + context.strokeStyle = + appState.theme === THEME.DARK + ? `rgba(3, 93, 161, 1)` + : `rgba(106, 189, 252, 1)`; + + switch (element.type) { + case "ellipse": + context.beginPath(); + context.ellipse( + element.width / 2, + element.height / 2, + element.width / 2, + element.height / 2, + 0, + 0, + 2 * Math.PI, + ); + context.closePath(); + context.stroke(); + break; + case "diamond": + { + const [segments, curves] = deconstructDiamondElement(element); + + // Draw each line segment individually + segments.forEach((segment) => { + context.beginPath(); + context.moveTo( + segment[0][0] - element.x, + segment[0][1] - element.y, + ); + context.lineTo( + segment[1][0] - element.x, + segment[1][1] - element.y, + ); + context.stroke(); + }); + + // Draw each curve individually (for rounded corners) + curves.forEach((curve) => { + const [start, control1, control2, end] = curve; + context.beginPath(); + context.moveTo(start[0] - element.x, start[1] - element.y); + context.bezierCurveTo( + control1[0] - element.x, + control1[1] - element.y, + control2[0] - element.x, + control2[1] - element.y, + end[0] - element.x, + end[1] - element.y, + ); + context.stroke(); + }); + } + + break; + default: + { + const [segments, curves] = deconstructRectanguloidElement(element); + + // Draw each line segment individually + segments.forEach((segment) => { + context.beginPath(); + context.moveTo( + segment[0][0] - element.x, + segment[0][1] - element.y, + ); + context.lineTo( + segment[1][0] - element.x, + segment[1][1] - element.y, + ); + context.stroke(); + }); + + // Draw each curve individually (for rounded corners) + curves.forEach((curve) => { + const [start, control1, control2, end] = curve; + context.beginPath(); + context.moveTo(start[0] - element.x, start[1] - element.y); + context.bezierCurveTo( + control1[0] - element.x, + control1[1] - element.y, + control2[0] - element.x, + control2[1] - element.y, + end[0] - element.x, + end[1] - element.y, + ); + context.stroke(); + }); + } + + break; + } + + context.restore(); + break; - } } }; -const renderBindingHighlightForSuggestedPointBinding = ( +const renderBindingHighlightForBindableElement_complex = ( + app: AppClassProperties, context: CanvasRenderingContext2D, - suggestedBinding: SuggestedPointBinding, - elementsMap: ElementsMap, - zoom: InteractiveCanvasAppState["zoom"], + element: ExcalidrawBindableElement, + allElementsMap: NonDeletedSceneElementsMap, + appState: InteractiveCanvasAppState, + deltaTime: number, + state?: { runtime: number }, ) => { - const [element, startOrEnd, bindableElement] = suggestedBinding; + const countdownInProgress = + app.state.bindMode === "orbit" && app.bindModeHandler !== null; - const threshold = maxBindingGap( - bindableElement, - bindableElement.width, - bindableElement.height, - zoom, - ); + const remainingTime = + BIND_MODE_TIMEOUT - + (state?.runtime ?? (countdownInProgress ? 0 : BIND_MODE_TIMEOUT)); + const opacity = clamp((1 / BIND_MODE_TIMEOUT) * remainingTime, 0.0001, 1); + const offset = element.strokeWidth / 2; - context.strokeStyle = "rgba(0,0,0,0)"; - context.fillStyle = "rgba(0,0,0,.05)"; - - const pointIndices = - startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1]; - pointIndices.forEach((index) => { - const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - index, - elementsMap, + const enclosingFrame = element.frameId && allElementsMap.get(element.frameId); + if (enclosingFrame && isFrameLikeElement(enclosingFrame)) { + context.translate( + enclosingFrame.x + appState.scrollX, + enclosingFrame.y + appState.scrollY, ); - fillCircle(context, x, y, threshold, true); - }); + + context.beginPath(); + + if (FRAME_STYLE.radius && context.roundRect) { + context.roundRect( + -1, + -1, + enclosingFrame.width + 1, + enclosingFrame.height + 1, + FRAME_STYLE.radius / appState.zoom.value, + ); + } else { + context.rect(-1, -1, enclosingFrame.width + 1, enclosingFrame.height + 1); + } + + context.clip(); + + context.translate( + -(enclosingFrame.x + appState.scrollX), + -(enclosingFrame.y + appState.scrollY), + ); + } + + switch (element.type) { + case "magicframe": + case "frame": + context.save(); + + context.translate( + element.x + appState.scrollX, + element.y + appState.scrollY, + ); + + context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; + context.strokeStyle = + appState.theme === THEME.DARK + ? `rgba(3, 93, 161, ${opacity})` + : `rgba(106, 189, 252, ${opacity})`; + + if (FRAME_STYLE.radius && context.roundRect) { + context.beginPath(); + context.roundRect( + 0, + 0, + element.width, + element.height, + FRAME_STYLE.radius / appState.zoom.value, + ); + context.stroke(); + context.closePath(); + } else { + context.strokeRect(0, 0, element.width, element.height); + } + + context.restore(); + break; + default: + context.save(); + + const center = elementCenterPoint(element, allElementsMap); + const cx = center[0] + appState.scrollX; + const cy = center[1] + appState.scrollY; + + context.translate(cx, cy); + context.rotate(element.angle as Radians); + context.translate(-cx, -cy); + + context.translate( + element.x + appState.scrollX - offset, + element.y + appState.scrollY - offset, + ); + + context.lineWidth = + clamp(2.5, element.strokeWidth * 1.75, 4) / + Math.max(0.25, appState.zoom.value); + context.strokeStyle = + appState.theme === THEME.DARK + ? `rgba(3, 93, 161, ${opacity / 2})` + : `rgba(106, 189, 252, ${opacity / 2})`; + + switch (element.type) { + case "ellipse": + context.beginPath(); + context.ellipse( + (element.width + offset * 2) / 2, + (element.height + offset * 2) / 2, + (element.width + offset * 2) / 2, + (element.height + offset * 2) / 2, + 0, + 0, + 2 * Math.PI, + ); + context.closePath(); + context.stroke(); + break; + case "diamond": + { + const [segments, curves] = deconstructDiamondElement( + element, + offset, + ); + + // Draw each line segment individually + segments.forEach((segment) => { + context.beginPath(); + context.moveTo( + segment[0][0] - element.x + offset, + segment[0][1] - element.y + offset, + ); + context.lineTo( + segment[1][0] - element.x + offset, + segment[1][1] - element.y + offset, + ); + context.stroke(); + }); + + // Draw each curve individually (for rounded corners) + curves.forEach((curve) => { + const [start, control1, control2, end] = curve; + context.beginPath(); + context.moveTo( + start[0] - element.x + offset, + start[1] - element.y + offset, + ); + context.bezierCurveTo( + control1[0] - element.x + offset, + control1[1] - element.y + offset, + control2[0] - element.x + offset, + control2[1] - element.y + offset, + end[0] - element.x + offset, + end[1] - element.y + offset, + ); + context.stroke(); + }); + } + + break; + default: + { + const [segments, curves] = deconstructRectanguloidElement( + element, + offset, + ); + + // Draw each line segment individually + segments.forEach((segment) => { + context.beginPath(); + context.moveTo( + segment[0][0] - element.x + offset, + segment[0][1] - element.y + offset, + ); + context.lineTo( + segment[1][0] - element.x + offset, + segment[1][1] - element.y + offset, + ); + context.stroke(); + }); + + // Draw each curve individually (for rounded corners) + curves.forEach((curve) => { + const [start, control1, control2, end] = curve; + context.beginPath(); + context.moveTo( + start[0] - element.x + offset, + start[1] - element.y + offset, + ); + context.bezierCurveTo( + control1[0] - element.x + offset, + control1[1] - element.y + offset, + control2[0] - element.x + offset, + control2[1] - element.y + offset, + end[0] - element.x + offset, + end[1] - element.y + offset, + ); + context.stroke(); + }); + } + + break; + } + + context.restore(); + + break; + } + + // Middle indicator is not rendered after it expired + if (!countdownInProgress || (state?.runtime ?? 0) > BIND_MODE_TIMEOUT) { + return; + } + + const radius = 0.5 * (Math.min(element.width, element.height) / 2); + + // Draw center snap area + if (!isFrameLikeElement(element)) { + context.save(); + context.translate( + element.x + appState.scrollX, + element.y + appState.scrollY, + ); + + const PROGRESS_RATIO = (1 / BIND_MODE_TIMEOUT) * remainingTime; + + context.strokeStyle = "rgba(0, 0, 0, 0.2)"; + context.lineWidth = 1 / appState.zoom.value; + context.setLineDash([4 / appState.zoom.value, 4 / appState.zoom.value]); + context.lineDashOffset = (-PROGRESS_RATIO * 10) / appState.zoom.value; + + context.beginPath(); + context.ellipse( + element.width / 2, + element.height / 2, + radius, + radius, + 0, + 0, + 2 * Math.PI, + ); + context.stroke(); + + // context.strokeStyle = "transparent"; + context.fillStyle = "rgba(0, 0, 0, 0.04)"; + context.beginPath(); + context.ellipse( + element.width / 2, + element.height / 2, + radius * (1 - opacity), + radius * (1 - opacity), + 0, + 0, + 2 * Math.PI, + ); + + context.fill(); + + context.restore(); + } + + return { + runtime: (state?.runtime ?? 0) + deltaTime, + }; +}; + +const renderBindingHighlightForBindableElement = ( + app: AppClassProperties, + context: CanvasRenderingContext2D, + element: ExcalidrawBindableElement, + allElementsMap: NonDeletedSceneElementsMap, + appState: InteractiveCanvasAppState, + deltaTime: number, + state?: { runtime: number }, +) => { + if (getFeatureFlag("COMPLEX_BINDINGS")) { + return renderBindingHighlightForBindableElement_complex( + app, + context, + element, + allElementsMap, + appState, + deltaTime, + state, + ); + } + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + renderBindingHighlightForBindableElement_simple( + context, + element, + allElementsMap, + appState, + ); + context.restore(); }; type ElementSelectionBorder = { @@ -321,7 +723,7 @@ const renderSelectionBorder = ( ]); } context.lineDashOffset = (lineWidth + spaceWidth) * index; - strokeRectWithRotation( + strokeRectWithRotation_simple( context, x1 - linePadding, y1 - linePadding, @@ -335,23 +737,6 @@ const renderSelectionBorder = ( context.restore(); }; -const renderBindingHighlight = ( - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, - suggestedBinding: SuggestedBinding, - elementsMap: ElementsMap, -) => { - const renderHighlight = Array.isArray(suggestedBinding) - ? renderBindingHighlightForSuggestedPointBinding - : renderBindingHighlightForBindableElement; - - context.save(); - context.translate(appState.scrollX, appState.scrollY); - renderHighlight(context, suggestedBinding as any, elementsMap, appState.zoom); - - context.restore(); -}; - const renderFrameHighlight = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, @@ -367,7 +752,7 @@ const renderFrameHighlight = ( context.save(); context.translate(appState.scrollX, appState.scrollY); - strokeRectWithRotation( + strokeRectWithRotation_simple( context, x1, y1, @@ -580,7 +965,7 @@ const renderTransformHandles = ( context.fill(); context.stroke(); } else { - strokeRectWithRotation( + strokeRectWithRotation_simple( context, x, y, @@ -725,6 +1110,7 @@ const renderTextBox = ( }; const _renderInteractiveScene = ({ + app, canvas, elementsMap, visibleElements, @@ -750,8 +1136,7 @@ const _renderInteractiveScene = ({ canvas, scale, ); - - const nextAnimationState = animationState; + let nextAnimationState = animationState; const context = bootstrapCanvas({ canvas, @@ -821,17 +1206,24 @@ const _renderInteractiveScene = ({ } } - if (appState.isBindingEnabled) { - appState.suggestedBindings - .filter((binding) => binding != null) - .forEach((suggestedBinding) => { - renderBindingHighlight( - context, - appState, - suggestedBinding!, - elementsMap, - ); - }); + if (appState.isBindingEnabled && appState.suggestedBinding) { + nextAnimationState = { + ...animationState, + bindingHighlight: renderBindingHighlightForBindableElement( + app, + context, + appState.suggestedBinding, + allElementsMap, + appState, + deltaTime, + animationState?.bindingHighlight, + ), + }; + } else { + nextAnimationState = { + ...animationState, + bindingHighlight: undefined, + }; } if (appState.frameToHighlight) { @@ -886,20 +1278,26 @@ const _renderInteractiveScene = ({ (el) => el.id === editor.elementId, // Don't forget bound text elements! ); - if (editor.segmentMidPointHoveredCoords) { - renderElbowArrowMidPointHighlight(context, appState); - } else if ( - isElbowArrow(firstSelectedLinear) - ? editor.hoverPointIndex === 0 || - editor.hoverPointIndex === firstSelectedLinear.points.length - 1 - : editor.hoverPointIndex >= 0 - ) { - renderLinearElementPointHighlight(context, appState, elementsMap); + if (!appState.selectedLinearElement.isDragging) { + if (editor.segmentMidPointHoveredCoords) { + renderElbowArrowMidPointHighlight(context, appState); + } else if ( + isElbowArrow(firstSelectedLinear) + ? editor.hoverPointIndex === 0 || + editor.hoverPointIndex === firstSelectedLinear.points.length - 1 + : editor.hoverPointIndex >= 0 + ) { + renderLinearElementPointHighlight(context, appState, elementsMap); + } } } // Paint selected elements - if (!appState.multiElement && !appState.selectedLinearElement?.isEditing) { + if ( + !appState.multiElement && + !appState.newElement && + !appState.selectedLinearElement?.isEditing + ) { const showBoundingBox = hasBoundingBox( selectedElements, appState, @@ -1082,7 +1480,7 @@ const _renderInteractiveScene = ({ const lineWidth = context.lineWidth; context.lineWidth = 1 / appState.zoom.value; context.strokeStyle = selectionColor; - strokeRectWithRotation( + strokeRectWithRotation_simple( context, x1 - dashedLinePadding, y1 - dashedLinePadding, diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 16afa1e804..c127a9de35 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -65,6 +65,7 @@ export type InteractiveCanvasRenderConfig = { remotePointerUsernames: Map; remotePointerButton: Map; selectionColor: string; + lastViewportPosition: { x: number; y: number }; // extra options passed to the renderer // --------------------------------------------------------------------------- renderScrollbars?: boolean; diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index e4ef367a8a..7ae3b5775f 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -11,6 +11,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": { "items": [ @@ -985,7 +986,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -1086,6 +1087,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1180,7 +1182,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": { "message": "Added to library", @@ -1302,6 +1304,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1396,7 +1399,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -1635,6 +1638,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1729,7 +1733,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -1968,6 +1972,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2062,7 +2067,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": { "message": "Copied styles.", @@ -2184,6 +2189,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2276,7 +2282,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -2427,6 +2433,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2521,7 +2528,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -2727,6 +2734,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2826,7 +2834,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -3101,6 +3109,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3195,7 +3204,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": { "message": "Copied styles.", @@ -3596,6 +3605,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3690,7 +3700,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -3921,6 +3931,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4015,7 +4026,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -4246,6 +4257,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4343,7 +4355,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -4659,6 +4671,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": { "items": [ @@ -5630,7 +5643,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -5878,6 +5891,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": { "items": [ @@ -6851,7 +6865,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -7148,6 +7162,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": { "items": [ @@ -7784,7 +7799,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -7817,6 +7832,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": { "items": [ @@ -8785,7 +8801,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -8810,6 +8826,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": { "items": [ @@ -9781,7 +9798,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, diff --git a/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap index c25b269f4b..a538500c25 100644 --- a/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap @@ -18,7 +18,6 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e "id": "id0", "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -135,7 +134,6 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e "id": "id0", "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 37037e7822..7c0c8b500c 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -11,6 +11,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -104,7 +105,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -189,22 +190,21 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "endBinding": { "elementId": "id15", "fixedPoint": [ - "0.50000", + "0.50010", 1, ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "99.19972", + "height": "106.79573", "id": "id4", "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -212,8 +212,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "98.40611", - "99.19972", + "89.00000", + "106.79573", ], ], "roughness": 1, @@ -227,9 +227,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 35, - "width": "98.40611", - "x": 1, + "version": 33, + "width": "89.00000", + "x": 0, "y": 0, } `; @@ -271,7 +271,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and the arrow got bound to a different element in the meantime > [end of test] number of elements 1`] = `4`; -exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and the arrow got bound to a different element in the meantime > [end of test] number of renders 1`] = `21`; +exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and the arrow got bound to a different element in the meantime > [end of test] number of renders 1`] = `22`; exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and the arrow got bound to a different element in the meantime > [end of test] redo stack 1`] = ` [ @@ -329,53 +329,63 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "endBinding": { "elementId": "id15", "fixedPoint": [ - "0.50000", + "0.50010", 1, ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, - "height": "68.58402", + "height": "65.91078", "points": [ [ 0, 0, ], [ - 98, - "68.58402", + 78, + "65.91078", ], ], "startBinding": { "elementId": "id0", - "focus": "0.02970", - "gap": 1, + "fixedPoint": [ + "0.63636", + "0.63636", + ], + "mode": "orbit", }, - "version": 33, + "version": 32, + "width": 78, }, "inserted": { "endBinding": { "elementId": "id1", - "focus": "-0.02000", - "gap": 1, + "fixedPoint": [ + "0.39512", + "0.60488", + ], + "mode": "orbit", }, - "height": "0.00656", + "height": "1.30876", "points": [ [ 0, 0, ], [ - 98, - "-0.00656", + 78, + "-1.30876", ], ], "startBinding": { "elementId": "id0", - "focus": "0.02000", - "gap": 1, + "fixedPoint": [ + "0.63636", + "0.63636", + ], + "mode": "orbit", }, - "version": 30, + "version": 29, + "width": 78, }, }, }, @@ -418,40 +428,47 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "id4": { "deleted": { - "height": "99.19972", + "height": "106.79573", "points": [ [ 0, 0, ], [ - "98.40611", - "99.19972", + "89.00000", + "106.79573", ], ], "startBinding": null, - "version": 35, + "version": 33, + "width": "89.00000", + "x": 0, "y": 0, }, "inserted": { - "height": "68.58402", + "height": "65.91078", "points": [ [ 0, 0, ], [ - 98, - "68.58402", + 78, + "65.91078", ], ], "startBinding": { "elementId": "id0", - "focus": "0.02970", - "gap": 1, + "fixedPoint": [ + "0.63636", + "0.63636", + ], + "mode": "orbit", }, - "version": 33, - "y": "35.82151", + "version": 32, + "width": 78, + "x": 11, + "y": "49.49137", }, }, }, @@ -576,7 +593,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "height": 0, "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -629,6 +645,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -722,7 +739,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -812,9 +829,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "id": "id4", "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -822,7 +839,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - 0, + 100, 0, ], ], @@ -837,16 +854,16 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 31, - "width": 0, - "x": 149, + "version": 28, + "width": 100, + "x": 150, "y": 0, } `; exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and there are no conflicting updates in the meantime > [end of test] number of elements 1`] = `3`; -exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and there are no conflicting updates in the meantime > [end of test] number of renders 1`] = `23`; +exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and there are no conflicting updates in the meantime > [end of test] number of renders 1`] = `24`; exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should rebind bindings when both are updated through the history and there are no conflicting updates in the meantime > [end of test] redo stack 1`] = ` [ @@ -887,15 +904,60 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "id4": { "deleted": { "endBinding": null, - "version": 30, + "height": "4.68000", + "points": [ + [ + 0, + 0, + ], + [ + -39, + "-4.68000", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "0.63636", + "0.63636", + ], + "mode": "orbit", + }, + "version": 27, + "width": 39, + "y": "4.68000", }, "inserted": { "endBinding": { "elementId": "id1", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.41019", + "0.58981", + ], + "mode": "orbit", }, - "version": 28, + "height": "10.70742", + "points": [ + [ + 0, + 0, + ], + [ + "52.01909", + "10.70742", + ], + ], + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "0.63636", + "0.63636", + ], + "mode": "orbit", + }, + "version": 26, + "width": "52.01909", + "y": "-1.72651", }, }, }, @@ -930,16 +992,47 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "id4": { "deleted": { + "height": 0, + "points": [ + [ + 0, + 0, + ], + [ + 100, + 0, + ], + ], "startBinding": null, - "version": 31, + "version": 28, + "width": 100, + "x": 150, + "y": 0, }, "inserted": { + "height": "4.68000", + "points": [ + [ + 0, + 0, + ], + [ + -39, + "-4.68000", + ], + ], "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + "0.63636", + "0.63636", + ], + "mode": "orbit", }, - "version": 30, + "version": 27, + "width": 39, + "x": 139, + "y": "4.68000", }, }, }, @@ -1064,7 +1157,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "height": 0, "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -1117,6 +1209,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1209,7 +1302,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -1237,19 +1330,18 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "0.50000", 1, ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "1.36342", + "height": "26.16768", "id": "id4", "index": "Zz", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -1257,8 +1349,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - 98, - "1.36342", + "78.00000", + "26.16768", ], ], "roughness": 1, @@ -1270,8 +1362,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 1, "0.50000", ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -1279,9 +1370,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 10, - "width": 98, - "x": 1, - "y": 0, + "width": "78.00000", + "x": 11, + "y": "3.67566", } `; @@ -1445,8 +1536,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "0.50000", 1, ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "startBinding": { "elementId": "id0", @@ -1454,8 +1544,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 1, "0.50000", ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "version": 10, }, @@ -1483,6 +1572,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1575,7 +1665,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -1603,19 +1693,18 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 1, "0.50000", ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "1.36342", + "height": "10.76674", "id": "id5", "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -1623,8 +1712,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - 98, - "1.36342", + "78.00000", + "-10.76674", ], ], "roughness": 1, @@ -1636,8 +1725,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "0.50000", 1, ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", @@ -1645,9 +1733,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 11, - "width": 98, - "x": 1, - "y": 0, + "width": "78.00000", + "x": 11, + "y": "35.29320", } `; @@ -1753,16 +1841,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 1, "0.50000", ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "1.36342", + "height": "10.76674", "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -1772,8 +1858,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - 98, - "1.36342", + "78.00000", + "-10.76674", ], ], "roughness": 1, @@ -1785,17 +1871,16 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "0.50000", 1, ], - "focus": 0, - "gap": 1, + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "version": 11, - "width": 98, - "x": 1, - "y": 0, + "width": "78.00000", + "x": 11, + "y": "35.29320", }, "inserted": { "isDeleted": true, @@ -1852,6 +1937,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1944,7 +2030,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -2117,6 +2203,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2208,7 +2295,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -2302,19 +2389,22 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "endArrowhead": "arrow", "endBinding": { "elementId": "id1", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "370.26975", + "height": "361.17383", "id": "id4", "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -2322,8 +2412,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "498.00000", - "-370.26975", + "478.06757", + "-361.17383", ], ], "roughness": 1, @@ -2333,24 +2423,27 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + "0.54844", + "0.54844", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": "498.00000", - "x": 1, - "y": "-37.92697", + "version": 14, + "width": "478.06757", + "x": 11, + "y": "-37.61782", } `; exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should update bound element points when rectangle was remotely moved and arrow is added back through the history > [end of test] number of elements 1`] = `3`; -exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should update bound element points when rectangle was remotely moved and arrow is added back through the history > [end of test] number of renders 1`] = `9`; +exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should update bound element points when rectangle was remotely moved and arrow is added back through the history > [end of test] number of renders 1`] = `11`; exports[`history > multiplayer undo/redo > conflicts in arrows and their bindable elements > should update bound element points when rectangle was remotely moved and arrow is added back through the history > [end of test] redo stack 1`] = `[]`; @@ -2464,16 +2557,18 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "endArrowhead": "arrow", "endBinding": { "elementId": "id1", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "370.26975", + "height": "361.17383", "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -2483,8 +2578,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "498.00000", - "-370.26975", + "478.06757", + "-361.17383", ], ], "roughness": 1, @@ -2494,21 +2589,24 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + "0.54844", + "0.54844", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 10, - "width": "498.00000", - "x": 1, - "y": "-37.92697", + "version": 14, + "width": "478.06757", + "x": 11, + "y": "-37.61782", }, "inserted": { "isDeleted": true, - "version": 7, + "version": 11, }, }, }, @@ -2561,6 +2659,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2653,7 +2752,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -2866,6 +2965,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2958,7 +3058,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -3187,6 +3287,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3279,7 +3380,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -3483,6 +3584,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3575,7 +3677,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -3771,6 +3873,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3863,7 +3966,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -4008,6 +4111,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4100,7 +4204,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -4267,6 +4371,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4359,7 +4464,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -4540,6 +4645,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4632,7 +4738,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -4771,6 +4877,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4863,7 +4970,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -5002,6 +5109,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5094,7 +5202,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -5251,6 +5359,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5343,7 +5452,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -5509,6 +5618,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5601,7 +5711,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -5769,6 +5879,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5860,7 +5971,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -6100,6 +6211,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6191,7 +6303,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -6529,6 +6641,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6623,7 +6736,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -6905,6 +7018,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7005,7 +7119,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -7219,6 +7333,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7308,7 +7423,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -7338,10 +7453,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "id": "id0", "index": "a0", "isDeleted": true, - "lastCommittedPoint": [ - 10, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -7366,7 +7477,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 9, + "version": 7, "width": 10, "x": 0, "y": 0, @@ -7375,7 +7486,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of elements 1`] = `1`; -exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of renders 1`] = `9`; +exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of renders 1`] = `11`; exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] redo stack 1`] = `[]`; @@ -7388,9 +7499,14 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "selectedElementIds": { "id0": true, }, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": {}, + "selectedLinearElement": null, }, }, }, @@ -7413,10 +7529,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "height": 10, "index": "a0", "isDeleted": true, - "lastCommittedPoint": [ - 10, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -7440,40 +7552,19 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 9, + "version": 7, "width": 10, "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 8, + "version": 6, }, }, }, }, - "id": "id13", - }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedLinearElement": { - "elementId": "id0", - "isEditing": false, - }, - }, - "inserted": { - "selectedLinearElement": null, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id14", + "id": "id10", }, { "appState": AppStateDelta { @@ -7497,7 +7588,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "removed": {}, "updated": {}, }, - "id": "id15", + "id": "id11", }, { "appState": AppStateDelta { @@ -7521,7 +7612,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "removed": {}, "updated": {}, }, - "id": "id16", + "id": "id12", }, ] `; @@ -7537,6 +7628,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7626,7 +7718,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -7769,6 +7861,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7858,7 +7951,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -8123,6 +8216,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8212,7 +8306,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -8477,6 +8571,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8572,7 +8667,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -8885,6 +8980,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "locked": false, "type": "freedraw", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8974,7 +9070,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -9001,10 +9097,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "id": "id0", "index": "a0", "isDeleted": false, - "lastCommittedPoint": [ - 50, - 50, - ], "link": null, "locked": false, "opacity": 100, @@ -9107,10 +9199,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "height": 50, "index": "a0", "isDeleted": false, - "lastCommittedPoint": [ - 50, - 50, - ], "link": null, "locked": false, "opacity": 100, @@ -9174,6 +9262,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9265,7 +9354,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -9440,6 +9529,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9531,7 +9621,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -9707,6 +9797,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9798,7 +9889,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -9941,6 +10032,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10033,7 +10125,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -10240,6 +10332,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10331,7 +10424,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -10361,10 +10454,6 @@ exports[`history > multiplayer undo/redo > should override remotely added points "id": "id0", "index": "a0", "isDeleted": false, - "lastCommittedPoint": [ - 30, - 30, - ], "link": null, "locked": false, "opacity": 100, @@ -10401,7 +10490,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 13, + "version": 10, "width": 30, "x": 0, "y": 0, @@ -10410,7 +10499,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of elements 1`] = `1`; -exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of renders 1`] = `14`; +exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of renders 1`] = `13`; exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] redo stack 1`] = `[]`; @@ -10423,9 +10512,14 @@ exports[`history > multiplayer undo/redo > should override remotely added points "selectedElementIds": { "id0": true, }, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": {}, + "selectedLinearElement": null, }, }, }, @@ -10447,10 +10541,6 @@ exports[`history > multiplayer undo/redo > should override remotely added points "height": 10, "index": "a0", "isDeleted": false, - "lastCommittedPoint": [ - 10, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -10474,20 +10564,20 @@ exports[`history > multiplayer undo/redo > should override remotely added points "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 12, + "version": 9, "width": 10, "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 11, + "version": 8, }, }, }, "updated": {}, }, - "id": "id10", + "id": "id7", }, { "appState": AppStateDelta { @@ -10503,10 +10593,6 @@ exports[`history > multiplayer undo/redo > should override remotely added points "id0": { "deleted": { "height": 30, - "lastCommittedPoint": [ - 30, - 30, - ], "points": [ [ 0, @@ -10529,15 +10615,11 @@ exports[`history > multiplayer undo/redo > should override remotely added points 20, ], ], - "version": 13, + "version": 10, "width": 30, }, "inserted": { "height": 10, - "lastCommittedPoint": [ - 10, - 10, - ], "points": [ [ 0, @@ -10548,34 +10630,13 @@ exports[`history > multiplayer undo/redo > should override remotely added points 10, ], ], - "version": 12, + "version": 9, "width": 10, }, }, }, }, - "id": "id11", - }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedLinearElement": { - "elementId": "id0", - "isEditing": false, - }, - }, - "inserted": { - "selectedLinearElement": null, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id12", + "id": "id8", }, ] `; @@ -10591,6 +10652,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10680,7 +10742,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -10829,6 +10891,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10921,7 +10984,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -11009,8 +11072,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "0.49919", "-0.03875", ], - "focus": "-0.00161", - "gap": "3.53708", + "mode": "orbit", }, "endIsSpecial": false, "fillStyle": "solid", @@ -11021,7 +11083,6 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "id": "6Rm4g567UQM4WjLwej2Vc", "index": "a2", "isDeleted": true, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -11048,8 +11109,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "1.03185", "0.49921", ], - "focus": "-0.00159", - "gap": 5, + "mode": "orbit", }, "startIsSpecial": false, "strokeColor": "#1e1e1e", @@ -11097,8 +11157,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "0.49919", "-0.03875", ], - "focus": "-0.00161", - "gap": "3.53708", + "mode": "orbit", }, "endIsSpecial": false, "fillStyle": "solid", @@ -11108,7 +11167,6 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "height": "236.10000", "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -11135,8 +11193,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "1.03185", "0.49921", ], - "focus": "-0.00159", - "gap": 5, + "mode": "orbit", }, "startIsSpecial": false, "strokeColor": "#1e1e1e", @@ -11279,6 +11336,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11370,7 +11428,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -11541,6 +11599,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11630,7 +11689,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -11778,6 +11837,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11869,7 +11929,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -12017,6 +12077,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "locked": false, "type": "freedraw", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12106,7 +12167,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -12163,10 +12224,6 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "id": "id5", "index": "a1", "isDeleted": true, - "lastCommittedPoint": [ - 50, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -12217,10 +12274,6 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "id": "id9", "index": "a2", "isDeleted": false, - "lastCommittedPoint": [ - 50, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -12360,10 +12413,6 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "height": 10, "index": "a2", "isDeleted": false, - "lastCommittedPoint": [ - 50, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -12422,6 +12471,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12516,7 +12566,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -12634,6 +12684,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12725,7 +12776,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -12843,6 +12894,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12938,7 +12990,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -13146,6 +13198,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13238,7 +13291,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -13446,6 +13499,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13538,7 +13592,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -13693,6 +13747,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13784,7 +13839,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -13932,6 +13987,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14023,7 +14079,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -14171,6 +14227,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14262,7 +14319,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -14420,6 +14477,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14511,7 +14569,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -14753,6 +14811,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14845,7 +14904,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -14925,6 +14984,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15019,7 +15079,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -15211,6 +15271,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15303,7 +15364,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -15476,6 +15537,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15568,7 +15630,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -15631,6 +15693,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15724,7 +15787,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -15915,6 +15978,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -16007,7 +16071,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -16079,6 +16143,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -16172,7 +16237,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -16309,19 +16374,22 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "1.22525", "id": "id13", "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -16329,8 +16397,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, - 0, + 78, + "-1.22525", ], ], "roughness": 1, @@ -16340,24 +16408,27 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + "0.53796", + "0.53796", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": 98, - "x": 1, - "y": 0, + "version": 14, + "width": 78, + "x": 11, + "y": "2.19362", } `; exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on deletion and rebind on undo > [end of test] number of elements 1`] = `4`; -exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on deletion and rebind on undo > [end of test] number of renders 1`] = `12`; +exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on deletion and rebind on undo > [end of test] number of renders 1`] = `15`; exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on deletion and rebind on undo > [end of test] redo stack 1`] = ` [ @@ -16384,12 +16455,44 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "removed": { "id13": { "deleted": { + "endBinding": { + "elementId": "id2", + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", + }, "isDeleted": false, - "version": 10, + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "0.53796", + "0.53796", + ], + "mode": "orbit", + }, + "version": 14, }, "inserted": { + "endBinding": { + "elementId": "id2", + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", + }, "isDeleted": true, - "version": 7, + "startBinding": { + "elementId": "id0", + "fixedPoint": [ + "0.53796", + "0.53796", + ], + "mode": "orbit", + }, + "version": 11, }, }, }, @@ -16689,16 +16792,18 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "0.00096", "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -16708,8 +16813,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 100, - 0, + "78.00000", + "0.00096", ], ], "roughness": 1, @@ -16719,21 +16824,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + "0.53796", + "0.53796", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 6, - "width": 100, - "x": 0, - "y": 0, + "version": 10, + "width": "78.00000", + "x": 11, + "y": "0.00830", }, "inserted": { "isDeleted": true, - "version": 5, + "version": 9, }, }, }, @@ -16786,6 +16894,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -16879,7 +16988,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -17016,19 +17125,22 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "1.22525", "id": "id13", "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -17036,8 +17148,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, - 0, + 78, + "-1.22525", ], ], "roughness": 1, @@ -17047,24 +17159,27 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + "0.53796", + "0.53796", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": 98, - "x": 1, - "y": 0, + "version": 14, + "width": 78, + "x": 11, + "y": "2.19362", } `; exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on undo and rebind on redo > [end of test] number of elements 1`] = `4`; -exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on undo and rebind on redo > [end of test] number of renders 1`] = `12`; +exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on undo and rebind on redo > [end of test] number of renders 1`] = `15`; exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on undo and rebind on redo > [end of test] redo stack 1`] = `[]`; @@ -17318,16 +17433,18 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "1.22525", "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -17337,8 +17454,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, - 0, + 78, + "-1.22525", ], ], "roughness": 1, @@ -17348,21 +17465,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + "0.53796", + "0.53796", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 10, - "width": 98, - "x": 1, - "y": 0, + "version": 14, + "width": 78, + "x": 11, + "y": "2.19362", }, "inserted": { "isDeleted": true, - "version": 7, + "version": 11, }, }, }, @@ -17423,6 +17543,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -17516,7 +17637,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -17653,19 +17774,22 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "1.22525", "id": "id13", "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -17673,8 +17797,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, - 0, + 78, + "-1.22525", ], ], "roughness": 1, @@ -17684,24 +17808,27 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + "0.53796", + "0.53796", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": 98, - "x": 1, - "y": 0, + "version": 14, + "width": 78, + "x": 11, + "y": "2.19362", } `; exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind everything from non deleted elements when iterating through the whole undo stack and vice versa rebind everything on redo > [end of test] number of elements 1`] = `4`; -exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind everything from non deleted elements when iterating through the whole undo stack and vice versa rebind everything on redo > [end of test] number of renders 1`] = `20`; +exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind everything from non deleted elements when iterating through the whole undo stack and vice versa rebind everything on redo > [end of test] number of renders 1`] = `23`; exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind everything from non deleted elements when iterating through the whole undo stack and vice versa rebind everything on redo > [end of test] redo stack 1`] = `[]`; @@ -17955,16 +18082,18 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "1.22525", "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -17974,8 +18103,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, - 0, + 78, + "-1.22525", ], ], "roughness": 1, @@ -17985,21 +18114,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + "0.53796", + "0.53796", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 10, - "width": 98, - "x": 1, - "y": 0, + "version": 14, + "width": 78, + "x": 11, + "y": "2.19362", }, "inserted": { "isDeleted": true, - "version": 7, + "version": 11, }, }, }, @@ -18060,6 +18192,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -18151,7 +18284,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -18288,19 +18421,22 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "1.22525", "id": "id13", "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -18308,8 +18444,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, - 0, + 78, + "-1.22525", ], ], "roughness": 1, @@ -18319,24 +18455,27 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + "0.53796", + "0.53796", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, - "width": 98, - "x": 1, - "y": 0, + "version": 14, + "width": 78, + "x": 11, + "y": "2.19362", } `; exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangle from arrow on deletion and rebind on undo > [end of test] number of elements 1`] = `4`; -exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangle from arrow on deletion and rebind on undo > [end of test] number of renders 1`] = `14`; +exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangle from arrow on deletion and rebind on undo > [end of test] number of renders 1`] = `17`; exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangle from arrow on deletion and rebind on undo > [end of test] redo stack 1`] = ` [ @@ -18380,16 +18519,35 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "updated": { "id13": { "deleted": { + "endBinding": { + "elementId": "id2", + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", + }, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + "0.53796", + "0.53796", + ], + "mode": "orbit", }, - "version": 10, + "version": 14, }, "inserted": { + "endBinding": { + "elementId": "id2", + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", + }, "startBinding": null, - "version": 7, + "version": 11, }, }, "id2": { @@ -18657,16 +18815,18 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "0.00096", "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -18676,8 +18836,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 100, - 0, + "78.00000", + "0.00096", ], ], "roughness": 1, @@ -18687,21 +18847,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + "0.53796", + "0.53796", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 6, - "width": 100, - "x": 0, - "y": 0, + "version": 10, + "width": "78.00000", + "x": 11, + "y": "0.00830", }, "inserted": { "isDeleted": true, - "version": 5, + "version": 9, }, }, }, @@ -18781,6 +18944,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -18875,7 +19039,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -19012,19 +19176,22 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "1.22525", "id": "id13", "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -19032,8 +19199,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 98, - 0, + 78, + "-1.22525", ], ], "roughness": 1, @@ -19043,24 +19210,27 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + "0.53796", + "0.53796", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 11, - "width": 98, - "x": 1, - "y": 0, + "version": 15, + "width": 78, + "x": 11, + "y": "2.19362", } `; exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangles from arrow on deletion and rebind on undo > [end of test] number of elements 1`] = `4`; -exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangles from arrow on deletion and rebind on undo > [end of test] number of renders 1`] = `15`; +exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangles from arrow on deletion and rebind on undo > [end of test] number of renders 1`] = `18`; exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangles from arrow on deletion and rebind on undo > [end of test] redo stack 1`] = ` [ @@ -19117,20 +19287,26 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "deleted": { "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + "0.53796", + "0.53796", + ], + "mode": "orbit", }, - "version": 11, + "version": 15, }, "inserted": { "endBinding": null, "startBinding": null, - "version": 8, + "version": 12, }, }, }, @@ -19390,16 +19566,18 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id2", - "focus": -0, - "gap": 1, + "fixedPoint": [ + "0.50010", + "0.50010", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 0, + "height": "0.00096", "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -19409,8 +19587,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 100, - 0, + "78.00000", + "0.00096", ], ], "roughness": 1, @@ -19420,21 +19598,24 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": 0, - "gap": 1, + "fixedPoint": [ + "0.53796", + "0.53796", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 6, - "width": 100, - "x": 0, - "y": 0, + "version": 10, + "width": "78.00000", + "x": 11, + "y": "0.00830", }, "inserted": { "isDeleted": true, - "version": 5, + "version": 9, }, }, }, @@ -19534,6 +19715,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements' "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -19628,7 +19810,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements' "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -20016,6 +20198,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -20112,7 +20295,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -20529,6 +20712,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -20620,7 +20804,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -20990,6 +21174,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -21083,7 +21268,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -21113,10 +21298,6 @@ exports[`history > singleplayer undo/redo > should support linear element creati "id": "id0", "index": "a0", "isDeleted": false, - "lastCommittedPoint": [ - 20, - 0, - ], "link": null, "locked": false, "opacity": 100, @@ -21145,7 +21326,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 15, + "version": 12, "width": 20, "x": 0, "y": 0, @@ -21154,7 +21335,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of elements 1`] = `1`; -exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of renders 1`] = `20`; +exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of renders 1`] = `24`; exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] redo stack 1`] = `[]`; @@ -21167,9 +21348,14 @@ exports[`history > singleplayer undo/redo > should support linear element creati "selectedElementIds": { "id0": true, }, + "selectedLinearElement": { + "elementId": "id0", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": {}, + "selectedLinearElement": null, }, }, }, @@ -21191,10 +21377,6 @@ exports[`history > singleplayer undo/redo > should support linear element creati "height": 10, "index": "a0", "isDeleted": false, - "lastCommittedPoint": [ - 10, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -21218,20 +21400,20 @@ exports[`history > singleplayer undo/redo > should support linear element creati "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 13, + "version": 10, "width": 10, "x": 0, "y": 0, }, "inserted": { "isDeleted": true, - "version": 12, + "version": 9, }, }, }, "updated": {}, }, - "id": "id23", + "id": "id20", }, { "appState": AppStateDelta { @@ -21246,10 +21428,6 @@ exports[`history > singleplayer undo/redo > should support linear element creati "updated": { "id0": { "deleted": { - "lastCommittedPoint": [ - 20, - 0, - ], "points": [ [ 0, @@ -21264,14 +21442,10 @@ exports[`history > singleplayer undo/redo > should support linear element creati 0, ], ], - "version": 14, + "version": 11, "width": 20, }, "inserted": { - "lastCommittedPoint": [ - 10, - 10, - ], "points": [ [ 0, @@ -21282,34 +21456,13 @@ exports[`history > singleplayer undo/redo > should support linear element creati 10, ], ], - "version": 13, + "version": 10, "width": 10, }, }, }, }, - "id": "id24", - }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedLinearElement": { - "elementId": "id0", - "isEditing": false, - }, - }, - "inserted": { - "selectedLinearElement": null, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id25", + "id": "id21", }, { "appState": AppStateDelta { @@ -21333,7 +21486,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "removed": {}, "updated": {}, }, - "id": "id26", + "id": "id22", }, { "appState": AppStateDelta { @@ -21363,7 +21516,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati 20, ], ], - "version": 15, + "version": 12, }, "inserted": { "height": 10, @@ -21381,12 +21534,12 @@ exports[`history > singleplayer undo/redo > should support linear element creati 0, ], ], - "version": 14, + "version": 11, }, }, }, }, - "id": "id27", + "id": "id23", }, { "appState": AppStateDelta { @@ -21410,7 +21563,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "removed": {}, "updated": {}, }, - "id": "id28", + "id": "id24", }, ] `; diff --git a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap index 52614ed5f4..8be177988e 100644 --- a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap @@ -126,7 +126,7 @@ exports[`move element > rectangles with binding arrow 5`] = ` "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 1006504105, + "versionNonce": 760410951, "width": 100, "x": 0, "y": 0, @@ -163,7 +163,7 @@ exports[`move element > rectangles with binding arrow 6`] = ` "type": "rectangle", "updated": 1, "version": 7, - "versionNonce": 1984422985, + "versionNonce": 888958951, "width": 300, "x": 201, "y": 2, @@ -180,19 +180,22 @@ exports[`move element > rectangles with binding arrow 7`] = ` "endArrowhead": "arrow", "endBinding": { "elementId": "id3", - "focus": "-0.46667", - "gap": 10, + "fixedPoint": [ + "-0.03667", + "0.42773", + ], + "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "81.40630", + "height": "124.16785", "id": "id6", "index": "a2", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, + "moveMidPointsWithElement": false, "opacity": 100, "points": [ [ @@ -200,8 +203,8 @@ exports[`move element > rectangles with binding arrow 7`] = ` 0, ], [ - "81.00000", - "81.40630", + 79, + "124.16785", ], ], "roughness": 1, @@ -212,18 +215,21 @@ exports[`move element > rectangles with binding arrow 7`] = ` "startArrowhead": null, "startBinding": { "elementId": "id0", - "focus": "-0.60000", - "gap": 10, + "fixedPoint": [ + "1.10000", + "-0.04577", + ], + "mode": "orbit", }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 11, - "versionNonce": 1573789895, - "width": "81.00000", - "x": "110.00000", - "y": 50, + "version": 12, + "versionNonce": 2066753033, + "width": 79, + "x": 111, + "y": "6.14995", } `; diff --git a/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap index ee3f024903..03bec27275 100644 --- a/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap @@ -16,10 +16,6 @@ exports[`multi point mode in linear elements > arrow 3`] = ` "id": "id0", "index": "a0", "isDeleted": false, - "lastCommittedPoint": [ - 70, - 110, - ], "link": null, "locked": false, "opacity": 100, @@ -49,8 +45,8 @@ exports[`multi point mode in linear elements > arrow 3`] = ` "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 8, - "versionNonce": 1604849351, + "version": 5, + "versionNonce": 1014066025, "width": 70, "x": 30, "y": 30, @@ -72,10 +68,6 @@ exports[`multi point mode in linear elements > line 3`] = ` "id": "id0", "index": "a0", "isDeleted": false, - "lastCommittedPoint": [ - 70, - 110, - ], "link": null, "locked": false, "opacity": 100, @@ -104,8 +96,8 @@ exports[`multi point mode in linear elements > line 3`] = ` "strokeWidth": 2, "type": "line", "updated": 1, - "version": 8, - "versionNonce": 1604849351, + "version": 5, + "versionNonce": 1014066025, "width": 70, "x": 30, "y": 30, diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index 43ca509d84..ecea596167 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -11,6 +11,7 @@ exports[`given element A and group of elements B and given both are selected whe "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -112,7 +113,7 @@ exports[`given element A and group of elements B and given both are selected whe "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -439,6 +440,7 @@ exports[`given element A and group of elements B and given both are selected whe "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -542,7 +544,7 @@ exports[`given element A and group of elements B and given both are selected whe "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -857,6 +859,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -951,7 +954,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -1425,6 +1428,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1519,7 +1523,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -1634,6 +1638,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1733,7 +1738,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -2020,6 +2025,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2116,7 +2122,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -2267,6 +2273,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2361,7 +2368,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -2449,6 +2456,7 @@ exports[`regression tests > can drag element that covers another element, while "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2545,7 +2553,7 @@ exports[`regression tests > can drag element that covers another element, while "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -2776,6 +2784,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2870,7 +2879,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -3033,6 +3042,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3129,7 +3139,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -3276,6 +3286,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3372,7 +3383,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -3514,6 +3525,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3610,7 +3622,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -3774,6 +3786,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3871,7 +3884,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -4090,6 +4103,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4186,7 +4200,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -4528,6 +4542,7 @@ exports[`regression tests > deselects group of selected elements on pointer down "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4651,7 +4666,7 @@ exports[`regression tests > deselects group of selected elements on pointer down "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -4813,6 +4828,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4908,7 +4924,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -5091,6 +5107,7 @@ exports[`regression tests > deselects selected element on pointer down when poin "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5213,7 +5230,7 @@ exports[`regression tests > deselects selected element on pointer down when poin "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -5301,6 +5318,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5395,7 +5413,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -5503,6 +5521,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5597,7 +5616,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -5898,6 +5917,7 @@ exports[`regression tests > drags selected elements from point inside common bou "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5996,7 +6016,7 @@ exports[`regression tests > drags selected elements from point inside common bou "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -6197,6 +6217,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` "locked": false, "type": "freedraw", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6289,7 +6310,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -6305,7 +6326,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` exports[`regression tests > draw every type of shape > [end of test] number of elements 1`] = `0`; -exports[`regression tests > draw every type of shape > [end of test] number of renders 1`] = `31`; +exports[`regression tests > draw every type of shape > [end of test] number of renders 1`] = `35`; exports[`regression tests > draw every type of shape > [end of test] redo stack 1`] = `[]`; @@ -6509,7 +6530,6 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "height": 10, "index": "a3", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -6588,7 +6608,6 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "height": 10, "index": "a4", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -6633,7 +6652,10 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "selectedElementIds": { "id15": true, }, - "selectedLinearElement": null, + "selectedLinearElement": { + "elementId": "id15", + "isEditing": false, + }, }, "inserted": { "selectedElementIds": { @@ -6664,10 +6686,6 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "height": 10, "index": "a5", "isDeleted": false, - "lastCommittedPoint": [ - 50, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -6691,14 +6709,14 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 6, + "version": 4, "width": 50, "x": 310, "y": -10, }, "inserted": { "isDeleted": true, - "version": 5, + "version": 3, }, }, }, @@ -6720,10 +6738,6 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "id15": { "deleted": { "height": 20, - "lastCommittedPoint": [ - 80, - 20, - ], "points": [ [ 0, @@ -6738,15 +6752,11 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack 20, ], ], - "version": 8, + "version": 5, "width": 80, }, "inserted": { "height": 10, - "lastCommittedPoint": [ - 50, - 10, - ], "points": [ [ 0, @@ -6757,7 +6767,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack 10, ], ], - "version": 6, + "version": 4, "width": 50, }, }, @@ -6769,32 +6779,14 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "appState": AppStateDelta { "delta": Delta { "deleted": { + "selectedElementIds": { + "id20": true, + }, "selectedLinearElement": { - "elementId": "id15", + "elementId": "id20", "isEditing": false, }, }, - "inserted": { - "selectedLinearElement": null, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id21", - }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedElementIds": { - "id22": true, - }, - "selectedLinearElement": null, - }, "inserted": { "selectedElementIds": { "id15": true, @@ -6809,7 +6801,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "elements": { "added": {}, "removed": { - "id22": { + "id20": { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -6823,10 +6815,6 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "height": 10, "index": "a6", "isDeleted": false, - "lastCommittedPoint": [ - 50, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -6849,20 +6837,20 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "strokeStyle": "solid", "strokeWidth": 2, "type": "line", - "version": 6, + "version": 4, "width": 50, "x": 430, "y": -10, }, "inserted": { "isDeleted": true, - "version": 5, + "version": 3, }, }, }, "updated": {}, }, - "id": "id24", + "id": "id22", }, { "appState": AppStateDelta { @@ -6875,13 +6863,9 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "added": {}, "removed": {}, "updated": { - "id22": { + "id20": { "deleted": { "height": 20, - "lastCommittedPoint": [ - 80, - 20, - ], "points": [ [ 0, @@ -6896,15 +6880,11 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack 20, ], ], - "version": 8, + "version": 5, "width": 80, }, "inserted": { "height": 10, - "lastCommittedPoint": [ - 50, - 10, - ], "points": [ [ 0, @@ -6915,44 +6895,28 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack 10, ], ], - "version": 6, + "version": 4, "width": 50, }, }, }, }, - "id": "id26", - }, - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedLinearElement": { - "elementId": "id22", - "isEditing": false, - }, - }, - "inserted": { - "selectedLinearElement": null, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id28", + "id": "id24", }, { "appState": AppStateDelta { "delta": Delta { "deleted": { "selectedElementIds": {}, + "selectedLinearElement": null, }, "inserted": { "selectedElementIds": { - "id22": true, + "id20": true, + }, + "selectedLinearElement": { + "elementId": "id20", + "isEditing": false, }, }, }, @@ -6962,26 +6926,19 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "removed": {}, "updated": {}, }, - "id": "id30", + "id": "id26", }, { "appState": AppStateDelta { "delta": Delta { - "deleted": { - "selectedLinearElement": null, - }, - "inserted": { - "selectedLinearElement": { - "elementId": "id22", - "isEditing": false, - }, - }, + "deleted": {}, + "inserted": {}, }, }, "elements": { "added": {}, "removed": { - "id31": { + "id27": { "deleted": { "angle": 0, "backgroundColor": "transparent", @@ -6993,10 +6950,6 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack "height": 10, "index": "a7", "isDeleted": false, - "lastCommittedPoint": [ - 50, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -7039,7 +6992,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack }, "updated": {}, }, - "id": "id33", + "id": "id29", }, ] `; @@ -7055,6 +7008,7 @@ exports[`regression tests > given a group of selected elements with an element t "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7152,7 +7106,7 @@ exports[`regression tests > given a group of selected elements with an element t "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -7391,6 +7345,7 @@ exports[`regression tests > given a selected element A and a not selected elemen "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7488,7 +7443,7 @@ exports[`regression tests > given a selected element A and a not selected elemen "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -7672,6 +7627,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7768,7 +7724,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -7909,6 +7865,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8005,7 +7962,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -8151,6 +8108,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8245,7 +8203,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -8333,6 +8291,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8427,7 +8386,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -8515,6 +8474,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8609,7 +8569,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -8697,6 +8657,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8784,13 +8745,10 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "customLineAngle": null, "elbowed": false, "elementId": "id0", - "endBindingElement": "keep", "hoverPointIndex": -1, - "isDragging": false, - "isEditing": false, - "lastUncommittedPoint": null, - "pointerDownState": { - "lastClickedIsEndPoint": false, + "initialState": { + "altFocusPoint": null, + "arrowStartIsInside": false, "lastClickedPoint": -1, "origin": null, "prevSelectedPointsIndices": null, @@ -8800,13 +8758,17 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "value": null, }, }, + "isDragging": false, + "isEditing": false, + "lastCommittedPoint": null, + "lastUncommittedPoint": null, + "pointerDownState": undefined, "pointerOffset": { "x": 0, "y": 0, }, "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, - "startBindingElement": "keep", }, "selectionElement": null, "shouldCacheIgnoreZoom": false, @@ -8818,7 +8780,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -8834,7 +8796,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` exports[`regression tests > key 5 selects arrow tool > [end of test] number of elements 1`] = `0`; -exports[`regression tests > key 5 selects arrow tool > [end of test] number of renders 1`] = `6`; +exports[`regression tests > key 5 selects arrow tool > [end of test] number of renders 1`] = `7`; exports[`regression tests > key 5 selects arrow tool > [end of test] redo stack 1`] = `[]`; @@ -8876,7 +8838,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack "height": 30, "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -8929,6 +8890,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9016,13 +8978,10 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "customLineAngle": null, "elbowed": false, "elementId": "id0", - "endBindingElement": "keep", "hoverPointIndex": -1, - "isDragging": false, - "isEditing": false, - "lastUncommittedPoint": null, - "pointerDownState": { - "lastClickedIsEndPoint": false, + "initialState": { + "altFocusPoint": null, + "arrowStartIsInside": false, "lastClickedPoint": -1, "origin": null, "prevSelectedPointsIndices": null, @@ -9032,13 +8991,17 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "value": null, }, }, + "isDragging": false, + "isEditing": false, + "lastCommittedPoint": null, + "lastUncommittedPoint": null, + "pointerDownState": undefined, "pointerOffset": { "x": 0, "y": 0, }, "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, - "startBindingElement": "keep", }, "selectionElement": null, "shouldCacheIgnoreZoom": false, @@ -9050,7 +9013,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -9066,7 +9029,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] exports[`regression tests > key 6 selects line tool > [end of test] number of elements 1`] = `0`; -exports[`regression tests > key 6 selects line tool > [end of test] number of renders 1`] = `6`; +exports[`regression tests > key 6 selects line tool > [end of test] number of renders 1`] = `7`; exports[`regression tests > key 6 selects line tool > [end of test] redo stack 1`] = `[]`; @@ -9107,7 +9070,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1 "height": 30, "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -9159,6 +9121,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState "locked": false, "type": "freedraw", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9251,7 +9214,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -9295,10 +9258,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta "height": 30, "index": "a0", "isDeleted": false, - "lastCommittedPoint": [ - 30, - 30, - ], "link": null, "locked": false, "opacity": 100, @@ -9357,6 +9316,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9444,13 +9404,10 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "customLineAngle": null, "elbowed": false, "elementId": "id0", - "endBindingElement": "keep", "hoverPointIndex": -1, - "isDragging": false, - "isEditing": false, - "lastUncommittedPoint": null, - "pointerDownState": { - "lastClickedIsEndPoint": false, + "initialState": { + "altFocusPoint": null, + "arrowStartIsInside": false, "lastClickedPoint": -1, "origin": null, "prevSelectedPointsIndices": null, @@ -9460,13 +9417,17 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "value": null, }, }, + "isDragging": false, + "isEditing": false, + "lastCommittedPoint": null, + "lastUncommittedPoint": null, + "pointerDownState": undefined, "pointerOffset": { "x": 0, "y": 0, }, "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, - "startBindingElement": "keep", }, "selectionElement": null, "shouldCacheIgnoreZoom": false, @@ -9478,7 +9439,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -9494,7 +9455,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` exports[`regression tests > key a selects arrow tool > [end of test] number of elements 1`] = `0`; -exports[`regression tests > key a selects arrow tool > [end of test] number of renders 1`] = `6`; +exports[`regression tests > key a selects arrow tool > [end of test] number of renders 1`] = `7`; exports[`regression tests > key a selects arrow tool > [end of test] redo stack 1`] = `[]`; @@ -9536,7 +9497,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack "height": 30, "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -9589,6 +9549,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9683,7 +9644,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -9771,6 +9732,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9858,13 +9820,10 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "customLineAngle": null, "elbowed": false, "elementId": "id0", - "endBindingElement": "keep", "hoverPointIndex": -1, - "isDragging": false, - "isEditing": false, - "lastUncommittedPoint": null, - "pointerDownState": { - "lastClickedIsEndPoint": false, + "initialState": { + "altFocusPoint": null, + "arrowStartIsInside": false, "lastClickedPoint": -1, "origin": null, "prevSelectedPointsIndices": null, @@ -9874,13 +9833,17 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "value": null, }, }, + "isDragging": false, + "isEditing": false, + "lastCommittedPoint": null, + "lastUncommittedPoint": null, + "pointerDownState": undefined, "pointerOffset": { "x": 0, "y": 0, }, "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, - "startBindingElement": "keep", }, "selectionElement": null, "shouldCacheIgnoreZoom": false, @@ -9892,7 +9855,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -9908,7 +9871,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] exports[`regression tests > key l selects line tool > [end of test] number of elements 1`] = `0`; -exports[`regression tests > key l selects line tool > [end of test] number of renders 1`] = `6`; +exports[`regression tests > key l selects line tool > [end of test] number of renders 1`] = `7`; exports[`regression tests > key l selects line tool > [end of test] redo stack 1`] = `[]`; @@ -9949,7 +9912,6 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1 "height": 30, "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -10001,6 +9963,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10095,7 +10058,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -10183,6 +10146,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState "locked": false, "type": "freedraw", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10275,7 +10239,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -10319,10 +10283,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta "height": 30, "index": "a0", "isDeleted": false, - "lastCommittedPoint": [ - 30, - 30, - ], "link": null, "locked": false, "opacity": 100, @@ -10381,6 +10341,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10475,7 +10436,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -10563,6 +10524,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10665,7 +10627,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -11096,6 +11058,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11192,7 +11155,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -11378,6 +11341,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11470,7 +11434,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -11503,6 +11467,7 @@ exports[`regression tests > shift click on selected element should deselect it o "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11597,7 +11562,7 @@ exports[`regression tests > shift click on selected element should deselect it o "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -11705,6 +11670,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11803,7 +11769,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -12026,6 +11992,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12126,7 +12093,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -12457,6 +12424,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12561,7 +12529,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -13099,6 +13067,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13194,7 +13163,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -13227,6 +13196,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13323,7 +13293,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -13860,6 +13830,7 @@ exports[`regression tests > switches from group of selected elements to another "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13985,7 +13956,7 @@ exports[`regression tests > switches from group of selected elements to another "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -14201,6 +14172,7 @@ exports[`regression tests > switches selected element on pointer down > [end of "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14325,7 +14297,7 @@ exports[`regression tests > switches selected element on pointer down > [end of "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -14467,6 +14439,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14559,7 +14532,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -14592,6 +14565,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14686,7 +14660,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -14706,27 +14680,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] number exports[`regression tests > undo/redo drawing an element > [end of test] redo stack 1`] = ` [ - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedLinearElement": null, - }, - "inserted": { - "selectedLinearElement": { - "elementId": "id6", - "isEditing": false, - }, - }, - }, - }, - "elements": { - "added": {}, - "removed": {}, - "updated": {}, - }, - "id": "id13", - }, { "appState": AppStateDelta { "delta": Delta { @@ -14741,10 +14694,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st "id6": { "deleted": { "height": 10, - "lastCommittedPoint": [ - 60, - 10, - ], "points": [ [ 0, @@ -14755,15 +14704,11 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st 10, ], ], - "version": 9, + "version": 6, "width": 60, }, "inserted": { "height": 20, - "lastCommittedPoint": [ - 100, - 20, - ], "points": [ [ 0, @@ -14778,13 +14723,13 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st 20, ], ], - "version": 8, + "version": 5, "width": 100, }, }, }, }, - "id": "id14", + "id": "id11", }, { "appState": AppStateDelta { @@ -14793,11 +14738,16 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st "selectedElementIds": { "id3": true, }, + "selectedLinearElement": null, }, "inserted": { "selectedElementIds": { "id6": true, }, + "selectedLinearElement": { + "elementId": "id6", + "isEditing": false, + }, }, }, }, @@ -14806,7 +14756,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st "id6": { "deleted": { "isDeleted": true, - "version": 10, + "version": 7, }, "inserted": { "angle": 0, @@ -14822,10 +14772,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st "height": 10, "index": "a2", "isDeleted": false, - "lastCommittedPoint": [ - 60, - 10, - ], "link": null, "locked": false, "opacity": 100, @@ -14849,7 +14795,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 9, + "version": 6, "width": 60, "x": 130, "y": 10, @@ -14859,7 +14805,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st "removed": {}, "updated": {}, }, - "id": "id15", + "id": "id12", }, ] `; @@ -14970,7 +14916,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] undo st }, "updated": {}, }, - "id": "id17", + "id": "id14", }, ] `; @@ -14986,6 +14932,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes "locked": false, "type": "text", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15078,7 +15025,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -15111,6 +15058,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15206,7 +15154,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, diff --git a/packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap index f47b89813f..5d5c701f0a 100644 --- a/packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap @@ -16,7 +16,6 @@ exports[`select single element on the scene > arrow 1`] = ` "id": "id0", "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -65,7 +64,6 @@ exports[`select single element on the scene > arrow escape 1`] = ` "id": "id0", "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, diff --git a/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap b/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap index d59a829a0f..95826081f4 100644 --- a/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap +++ b/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap @@ -16,7 +16,6 @@ exports[`restoreElements > should restore arrow element correctly 1`] = ` "id": "id-arrow01", "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -175,7 +174,6 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = ` "id": "id-freedraw01", "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -222,7 +220,6 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] = "id": "id-line01", "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -270,7 +267,6 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] = "id": "id-draw01", "index": "a1", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, diff --git a/packages/excalidraw/tests/dragCreate.test.tsx b/packages/excalidraw/tests/dragCreate.test.tsx index 566c839050..e943bce431 100644 --- a/packages/excalidraw/tests/dragCreate.test.tsx +++ b/packages/excalidraw/tests/dragCreate.test.tsx @@ -157,9 +157,9 @@ describe("Test dragCreate", () => { fireEvent.pointerUp(canvas); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `5`, + `6`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); @@ -195,9 +195,9 @@ describe("Test dragCreate", () => { fireEvent.pointerUp(canvas); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `5`, + `6`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index aca5530d4c..219b002777 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -1021,7 +1021,7 @@ describe("history", () => { // leave editor Keyboard.keyPress(KEYS.ESCAPE); - expect(API.getUndoStack().length).toBe(6); + expect(API.getUndoStack().length).toBe(5); expect(API.getRedoStack().length).toBe(0); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); @@ -1038,7 +1038,7 @@ describe("history", () => { ]); Keyboard.undo(); - expect(API.getUndoStack().length).toBe(5); + expect(API.getUndoStack().length).toBe(4); expect(API.getRedoStack().length).toBe(1); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing).toBe(true); @@ -1058,11 +1058,11 @@ describe("history", () => { mouse.clickAt(0, 0); mouse.clickAt(10, 10); mouse.clickAt(20, 20); - expect(API.getUndoStack().length).toBe(5); + expect(API.getUndoStack().length).toBe(4); expect(API.getRedoStack().length).toBe(1); Keyboard.undo(); - expect(API.getUndoStack().length).toBe(4); + expect(API.getUndoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(2); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing).toBe(true); @@ -1079,10 +1079,10 @@ describe("history", () => { ]); Keyboard.undo(); - expect(API.getUndoStack().length).toBe(3); + expect(API.getUndoStack().length).toBe(2); expect(API.getRedoStack().length).toBe(3); expect(assertSelectedElements(h.elements[0])); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); // undo `open editor` + expect(h.state.selectedLinearElement?.isEditing).toBe(false); // undo `open editor` expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1095,29 +1095,29 @@ describe("history", () => { }), ]); - Keyboard.undo(); - expect(API.getUndoStack().length).toBe(2); - expect(API.getRedoStack().length).toBe(4); - expect(assertSelectedElements(h.elements[0])); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); - expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize` - expect(h.elements).toEqual([ - expect.objectContaining({ - isDeleted: false, - points: [ - [0, 0], - [10, 10], - [20, 0], - ], - }), - ]); + // Keyboard.undo(); + // expect(API.getUndoStack().length).toBe(2); + // expect(API.getRedoStack().length).toBe(4); + // expect(assertSelectedElements(h.elements[0])); + // expect(h.state.selectedLinearElement?.isEditing).toBe(false); + // expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize` + // expect(h.elements).toEqual([ + // expect.objectContaining({ + // isDeleted: false, + // points: [ + // [0, 0], + // [10, 10], + // [20, 0], + // ], + // }), + // ]); Keyboard.undo(); expect(API.getUndoStack().length).toBe(1); - expect(API.getRedoStack().length).toBe(5); + expect(API.getRedoStack().length).toBe(4); expect(assertSelectedElements(h.elements[0])); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); - expect(h.state.selectedLinearElement).toBeNull(); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); + expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id); expect(h.elements).toEqual([ expect.objectContaining({ isDeleted: false, @@ -1130,9 +1130,8 @@ describe("history", () => { Keyboard.undo(); expect(API.getUndoStack().length).toBe(0); - expect(API.getRedoStack().length).toBe(6); + expect(API.getRedoStack().length).toBe(5); expect(API.getSelectedElements().length).toBe(0); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); expect(h.state.selectedLinearElement).toBeNull(); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1146,10 +1145,10 @@ describe("history", () => { Keyboard.redo(); expect(API.getUndoStack().length).toBe(1); - expect(API.getRedoStack().length).toBe(5); + expect(API.getRedoStack().length).toBe(4); expect(assertSelectedElements(h.elements[0])); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); - expect(h.state.selectedLinearElement).toBeNull(); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); + expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id); expect(h.elements).toEqual([ expect.objectContaining({ isDeleted: false, @@ -1160,25 +1159,25 @@ describe("history", () => { }), ]); + // Keyboard.redo(); + // expect(API.getUndoStack().length).toBe(2); + // expect(API.getRedoStack().length).toBe(3); + // expect(assertSelectedElements(h.elements[0])); + // expect(h.state.selectedLinearElement?.isEditing).toBe(false); + // expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize` + // expect(h.elements).toEqual([ + // expect.objectContaining({ + // isDeleted: false, + // points: [ + // [0, 0], + // [10, 10], + // [20, 0], + // ], + // }), + // ]); + Keyboard.redo(); expect(API.getUndoStack().length).toBe(2); - expect(API.getRedoStack().length).toBe(4); - expect(assertSelectedElements(h.elements[0])); - expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); - expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize` - expect(h.elements).toEqual([ - expect.objectContaining({ - isDeleted: false, - points: [ - [0, 0], - [10, 10], - [20, 0], - ], - }), - ]); - - Keyboard.redo(); - expect(API.getUndoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(3); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); // undo `open editor` @@ -1195,7 +1194,7 @@ describe("history", () => { ]); Keyboard.redo(); - expect(API.getUndoStack().length).toBe(4); + expect(API.getUndoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(2); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing).toBe(true); @@ -1212,7 +1211,7 @@ describe("history", () => { ]); Keyboard.redo(); - expect(API.getUndoStack().length).toBe(5); + expect(API.getUndoStack().length).toBe(4); expect(API.getRedoStack().length).toBe(1); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing).toBe(true); @@ -1229,7 +1228,7 @@ describe("history", () => { ]); Keyboard.redo(); - expect(API.getUndoStack().length).toBe(6); + expect(API.getUndoStack().length).toBe(5); expect(API.getRedoStack().length).toBe(0); expect(assertSelectedElements(h.elements[0])); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); @@ -1581,21 +1580,24 @@ describe("history", () => { // bind arrow to rect1 and rect2 UI.clickTool("arrow"); - mouse.down(0, 0); - mouse.up(100, 0); + mouse.down(3, 0); + mouse.moveTo(50, 0); + mouse.up(47, 0); arrow = h.elements[3] as ExcalidrawLinearElement; expect(API.getUndoStack().length).toBe(5); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + 0.5379561888991137, 0.5379561888991137, + ]), + mode: "orbit", }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([0.5001, 0.5001]), + mode: "orbit", }); expect(rect1.boundElements).toStrictEqual([ { id: text.id, type: "text" }, @@ -1612,13 +1614,15 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(1); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + 0.5379561888991137, 0.5379561888991137, + ]), + mode: "orbit", }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([0.5001, 0.5001]), + mode: "orbit", }); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1635,13 +1639,15 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(0); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + 0.5379561888991137, 0.5379561888991137, + ]), + mode: "orbit", }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([0.5001, 0.5001]), + mode: "orbit", }); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1666,13 +1672,15 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(0); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + 0.5379561888991137, 0.5379561888991137, + ]), + mode: "orbit", }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([0.5001, 0.5001]), + mode: "orbit", }); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1689,13 +1697,15 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(1); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + 0.5379561888991137, 0.5379561888991137, + ]), + mode: "orbit", }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([0.5001, 0.5001]), + mode: "orbit", }); expect(h.elements).toEqual([ expect.objectContaining({ @@ -1744,13 +1754,19 @@ describe("history", () => { id: arrow.id, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), isDeleted: true, }), @@ -1789,13 +1805,19 @@ describe("history", () => { id: arrow.id, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), isDeleted: false, }), @@ -1833,8 +1855,11 @@ describe("history", () => { startBinding: null, endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), isDeleted: false, }), @@ -1868,13 +1893,19 @@ describe("history", () => { id: arrow.id, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), isDeleted: false, }), @@ -1941,13 +1972,19 @@ describe("history", () => { id: arrow.id, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), isDeleted: false, }), @@ -2298,15 +2335,13 @@ describe("history", () => { ], startBinding: { elementId: "KPrBI4g_v9qUB1XxYLgSz", - focus: -0.001587301587301948, - gap: 5, fixedPoint: [1.0318471337579618, 0.49920634920634904], + mode: "orbit", } as FixedPointBinding, endBinding: { elementId: "u2JGnnmoJ0VATV4vCNJE5", - focus: -0.0016129032258049847, - gap: 3.537079145500037, fixedPoint: [0.4991935483870975, -0.03875193720914723], + mode: "orbit", } as FixedPointBinding, }, ], @@ -2421,10 +2456,9 @@ describe("history", () => { captureUpdate: CaptureUpdateAction.NEVER, }); - Keyboard.undo(); // undo `actionFinalize` Keyboard.undo(); expect(API.getUndoStack().length).toBe(1); - expect(API.getRedoStack().length).toBe(2); + expect(API.getRedoStack().length).toBe(1); expect(h.elements).toEqual([ expect.objectContaining({ points: [ @@ -2438,7 +2472,7 @@ describe("history", () => { Keyboard.undo(); expect(API.getUndoStack().length).toBe(0); - expect(API.getRedoStack().length).toBe(3); + expect(API.getRedoStack().length).toBe(2); expect(h.elements).toEqual([ expect.objectContaining({ isDeleted: true, @@ -2451,7 +2485,7 @@ describe("history", () => { Keyboard.redo(); expect(API.getUndoStack().length).toBe(1); - expect(API.getRedoStack().length).toBe(2); + expect(API.getRedoStack().length).toBe(1); expect(h.elements).toEqual([ expect.objectContaining({ isDeleted: false, @@ -2464,21 +2498,6 @@ describe("history", () => { Keyboard.redo(); expect(API.getUndoStack().length).toBe(2); - expect(API.getRedoStack().length).toBe(1); - expect(h.elements).toEqual([ - expect.objectContaining({ - points: [ - [0, 0], - [5, 5], - [10, 10], - [15, 15], - [20, 20], - ], - }), - ]); - - Keyboard.redo(); // redo `actionFinalize` - expect(API.getUndoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(0); expect(h.elements).toEqual([ expect.objectContaining({ @@ -2978,7 +2997,7 @@ describe("history", () => { // leave editor Keyboard.keyPress(KEYS.ESCAPE); - expect(API.getUndoStack().length).toBe(4); + expect(API.getUndoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(0); expect(h.state.selectedLinearElement).not.toBeNull(); expect(h.state.selectedLinearElement?.isEditing).toBe(false); @@ -2995,11 +3014,11 @@ describe("history", () => { Keyboard.undo(); expect(API.getUndoStack().length).toBe(0); - expect(API.getRedoStack().length).toBe(4); + expect(API.getRedoStack().length).toBe(3); expect(h.state.selectedLinearElement).toBeNull(); Keyboard.redo(); - expect(API.getUndoStack().length).toBe(4); + expect(API.getUndoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(0); expect(h.state.selectedLinearElement).toBeNull(); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); @@ -4500,16 +4519,30 @@ describe("history", () => { // create start binding mouse.downAt(0, 0); - mouse.moveTo(0, 1); - mouse.moveTo(0, 0); + mouse.moveTo(0, 10); + mouse.moveTo(0, 10); mouse.up(); // create end binding mouse.downAt(100, 0); - mouse.moveTo(100, 1); - mouse.moveTo(100, 0); + mouse.moveTo(100, 10); + mouse.moveTo(100, 10); mouse.up(); + expect( + (h.elements[2] as ExcalidrawElbowArrowElement).startBinding + ?.fixedPoint, + ).not.toEqual([1, 0.5001]); + expect( + (h.elements[2] as ExcalidrawElbowArrowElement).startBinding?.mode, + ).toBe("orbit"); + expect( + (h.elements[2] as ExcalidrawElbowArrowElement).endBinding, + ).not.toEqual([1, 0.5001]); + expect( + (h.elements[2] as ExcalidrawElbowArrowElement).endBinding?.mode, + ).toBe("orbit"); + expect(h.elements).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -4524,13 +4557,19 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), }), ]), @@ -4593,13 +4632,13 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: [0.6363636363636364, 0.6363636363636364], + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: [0.41019091151895054, 0.5898090884810495], + mode: "orbit", }), }), ]), @@ -4636,13 +4675,13 @@ describe("history", () => { // create start binding mouse.downAt(0, 0); - mouse.moveTo(0, 1); - mouse.upAt(0, 0); + mouse.moveTo(0, 10); + mouse.upAt(0, 10); // create end binding mouse.downAt(100, 0); - mouse.moveTo(100, 1); - mouse.upAt(100, 0); + mouse.moveTo(100, 10); + mouse.upAt(100, 10); expect(h.elements).toEqual( expect.arrayContaining([ @@ -4658,13 +4697,19 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), }), ]), @@ -4702,9 +4747,8 @@ describe("history", () => { newElementWith(h.elements[2] as ExcalidrawElbowArrowElement, { endBinding: { elementId: remoteContainer.id, - gap: 1, - focus: 0, - fixedPoint: [0.5, 1], + fixedPoint: [0.5001, 1], + mode: "orbit", }, }), remoteContainer, @@ -4731,14 +4775,14 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: [0.6363636363636364, 0.6363636363636364], + mode: "orbit", }), // rebound with previous rectangle endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: [0.39511653718091, 0.6048834628190899], + mode: "orbit", }), }), expect.objectContaining({ @@ -4768,12 +4812,8 @@ describe("history", () => { endBinding: expect.objectContaining({ // now we are back in the previous state! elementId: remoteContainer.id, - fixedPoint: [ - expect.toBeNonNaNNumber(), - expect.toBeNonNaNNumber(), - ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: [0.5001, 1], + mode: "orbit", }), }), expect.objectContaining({ @@ -4791,15 +4831,13 @@ describe("history", () => { type: "arrow", startBinding: { elementId: rect1.id, - gap: 1, - focus: 0, fixedPoint: [1, 0.5], + mode: "orbit", }, endBinding: { elementId: rect2.id, - gap: 1, - focus: 0, fixedPoint: [0.5, 1], + mode: "orbit", }, }); @@ -4853,8 +4891,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }), endBinding: expect.objectContaining({ // now we are back in the previous state! @@ -4863,8 +4900,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }), }), expect.objectContaining({ @@ -4900,15 +4936,13 @@ describe("history", () => { newElementWith(h.elements[0] as ExcalidrawElbowArrowElement, { startBinding: { elementId: rect1.id, - gap: 1, - focus: 0, fixedPoint: [0.5, 1], + mode: "orbit", }, endBinding: { elementId: rect2.id, - gap: 1, - focus: 0, fixedPoint: [1, 0.5], + mode: "orbit", }, }), newElementWith(rect1, { @@ -4935,8 +4969,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, @@ -4944,8 +4977,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }), isDeleted: true, }), @@ -4975,8 +5007,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }, endBinding: expect.objectContaining({ elementId: rect2.id, @@ -4984,8 +5015,7 @@ describe("history", () => { expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(), ], - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + mode: "orbit", }), isDeleted: false, }), @@ -5010,11 +5040,13 @@ describe("history", () => { // bind arrow to rect1 and rect2 UI.clickTool("arrow"); mouse.down(0, 0); - mouse.up(100, 0); + mouse.moveTo(50, 0); + mouse.up(50, 0); const arrowId = h.elements[2].id; Keyboard.undo(); + expect(API.getUndoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1); expect(h.elements).toEqual( @@ -5028,13 +5060,13 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: 0, - gap: 1, + fixedPoint: expect.arrayContaining([ + 0.548442798411514, 0.548442798411514, + ]), }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: -0, - gap: 1, + fixedPoint: expect.arrayContaining([0.5001, 0.5001]), }), isDeleted: true, }), @@ -5076,13 +5108,19 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), endBinding: expect.objectContaining({ elementId: rect2.id, - focus: expect.toBeNonNaNNumber(), - gap: expect.toBeNonNaNNumber(), + fixedPoint: expect.arrayContaining([ + expect.toBeNonNaNNumber(), + expect.toBeNonNaNNumber(), + ]), + mode: "orbit", }), isDeleted: false, }), diff --git a/packages/excalidraw/tests/lasso.test.tsx b/packages/excalidraw/tests/lasso.test.tsx index d84ce1ffb9..f9a9d12d27 100644 --- a/packages/excalidraw/tests/lasso.test.tsx +++ b/packages/excalidraw/tests/lasso.test.tsx @@ -210,7 +210,6 @@ describe("Basic lasso selection tests", () => { [0, 0], [168.4765625, -153.38671875], ], - lastCommittedPoint: null, startBinding: null, endBinding: null, startArrowhead: null, @@ -250,7 +249,6 @@ describe("Basic lasso selection tests", () => { [0, 0], [206.12890625, 35.4140625], ], - lastCommittedPoint: null, startBinding: null, endBinding: null, startArrowhead: null, @@ -354,7 +352,6 @@ describe("Basic lasso selection tests", () => { ], pressures: [], simulatePressure: true, - lastCommittedPoint: null, }, ].map( (e) => @@ -1229,7 +1226,6 @@ describe("Special cases", () => { locked: false, startBinding: null, endBinding: null, - lastCommittedPoint: null, startArrowhead: null, endArrowhead: null, points: [ @@ -1271,7 +1267,6 @@ describe("Special cases", () => { locked: false, startBinding: null, endBinding: null, - lastCommittedPoint: null, startArrowhead: null, endArrowhead: null, points: [ @@ -1312,7 +1307,6 @@ describe("Special cases", () => { locked: false, startBinding: null, endBinding: null, - lastCommittedPoint: null, startArrowhead: null, endArrowhead: null, points: [ @@ -1353,7 +1347,6 @@ describe("Special cases", () => { locked: false, startBinding: null, endBinding: null, - lastCommittedPoint: null, startArrowhead: null, endArrowhead: null, points: [ @@ -1692,7 +1685,6 @@ describe("Special cases", () => { locked: false, startBinding: null, endBinding: null, - lastCommittedPoint: null, startArrowhead: null, endArrowhead: null, points: [ @@ -1744,7 +1736,6 @@ describe("Special cases", () => { locked: false, startBinding: null, endBinding: null, - lastCommittedPoint: null, startArrowhead: null, endArrowhead: null, points: [ diff --git a/packages/excalidraw/tests/library.test.tsx b/packages/excalidraw/tests/library.test.tsx index 55c25188de..1905741efa 100644 --- a/packages/excalidraw/tests/library.test.tsx +++ b/packages/excalidraw/tests/library.test.tsx @@ -67,9 +67,8 @@ describe("library items inserting", () => { type: "arrow", endBinding: { elementId: "rectangle1", - focus: -1, - gap: 0, fixedPoint: [0.5, 1], + mode: "orbit", }, }); diff --git a/packages/excalidraw/tests/move.test.tsx b/packages/excalidraw/tests/move.test.tsx index 095db38a0c..a881bb83e6 100644 --- a/packages/excalidraw/tests/move.test.tsx +++ b/packages/excalidraw/tests/move.test.tsx @@ -1,16 +1,12 @@ import React from "react"; import { vi } from "vitest"; - -import { bindOrUnbindLinearElement } from "@excalidraw/element"; - import { KEYS, reseed } from "@excalidraw/common"; - +import { bindBindingElement } from "@excalidraw/element"; import "@excalidraw/utils/test-utils"; import type { - ExcalidrawLinearElement, + ExcalidrawArrowElement, NonDeleted, - ExcalidrawRectangleElement, } from "@excalidraw/element/types"; import { Excalidraw } from "../index"; @@ -83,12 +79,21 @@ describe("move element", () => { const rectA = UI.createElement("rectangle", { size: 100 }); const rectB = UI.createElement("rectangle", { x: 200, y: 0, size: 300 }); const arrow = UI.createElement("arrow", { x: 110, y: 50, size: 80 }); + act(() => { // bind line to two rectangles - bindOrUnbindLinearElement( - arrow.get() as NonDeleted, - rectA.get() as ExcalidrawRectangleElement, - rectB.get() as ExcalidrawRectangleElement, + bindBindingElement( + arrow.get() as NonDeleted, + rectA.get(), + "orbit", + "start", + h.app.scene, + ); + bindBindingElement( + arrow.get() as NonDeleted, + rectB.get(), + "orbit", + "end", h.app.scene, ); }); @@ -97,16 +102,19 @@ describe("move element", () => { new Pointer("mouse").clickOn(rectB); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `17`, + `16`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`13`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`15`); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(3); expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); expect([rectA.x, rectA.y]).toEqual([0, 0]); expect([rectB.x, rectB.y]).toEqual([200, 0]); - expect([arrow.x, arrow.y]).toEqual([110, 50]); - expect([arrow.width, arrow.height]).toEqual([80, 80]); + expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[110, -4.576537]], 0); + expect([[arrow.width, arrow.height]]).toCloselyEqualPoints( + [[79, 132.89433]], + 0, + ); renderInteractiveScene.mockClear(); renderStaticScene.mockClear(); @@ -124,8 +132,11 @@ describe("move element", () => { expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); expect([rectA.x, rectA.y]).toEqual([0, 0]); expect([rectB.x, rectB.y]).toEqual([201, 2]); - expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[110, 50]]); - expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[81, 81.4]]); + expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[111, 6.1499]], 0); + expect([[arrow.width, arrow.height]]).toCloselyEqualPoints( + [[79, 124.1678]], + 0, + ); h.elements.forEach((element) => expect(element).toMatchSnapshot()); }); diff --git a/packages/excalidraw/tests/multiPointCreate.test.tsx b/packages/excalidraw/tests/multiPointCreate.test.tsx index 926c8d47f3..26cd88e66b 100644 --- a/packages/excalidraw/tests/multiPointCreate.test.tsx +++ b/packages/excalidraw/tests/multiPointCreate.test.tsx @@ -118,8 +118,10 @@ describe("multi point mode in linear elements", () => { key: KEYS.ENTER, }); - expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`7`); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( + `11`, + ); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(h.elements.length).toEqual(1); const element = h.elements[0] as ExcalidrawLinearElement; @@ -161,8 +163,10 @@ describe("multi point mode in linear elements", () => { fireEvent.keyDown(document, { key: KEYS.ENTER, }); - expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`7`); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( + `11`, + ); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(h.elements.length).toEqual(1); const element = h.elements[0] as ExcalidrawLinearElement; diff --git a/packages/excalidraw/tests/regressionTests.test.tsx b/packages/excalidraw/tests/regressionTests.test.tsx index 2ee2167914..f16606b6f2 100644 --- a/packages/excalidraw/tests/regressionTests.test.tsx +++ b/packages/excalidraw/tests/regressionTests.test.tsx @@ -369,7 +369,6 @@ describe("regression tests", () => { Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.keyPress(KEYS.Z); Keyboard.keyPress(KEYS.Z); - Keyboard.keyPress(KEYS.Z); }); expect(h.elements.filter((element) => !element.isDeleted).length).toBe(2); Keyboard.withModifierKeys({ ctrl: true }, () => { diff --git a/packages/excalidraw/tests/rotate.test.tsx b/packages/excalidraw/tests/rotate.test.tsx index 38079db8f3..47f7e469e4 100644 --- a/packages/excalidraw/tests/rotate.test.tsx +++ b/packages/excalidraw/tests/rotate.test.tsx @@ -24,7 +24,7 @@ test("unselected bound arrow updates when rotating its target element", async () const arrow = UI.createElement("arrow", { x: -80, y: 50, - width: 70, + width: 85, height: 0, }); @@ -35,8 +35,8 @@ test("unselected bound arrow updates when rotating its target element", async () expect(arrow.endBinding?.elementId).toEqual(rectangle.id); expect(arrow.x).toBeCloseTo(-80); expect(arrow.y).toBeCloseTo(50); - expect(arrow.width).toBeCloseTo(110.7, 1); - expect(arrow.height).toBeCloseTo(0); + expect(arrow.width).toBeCloseTo(132.491, 1); + expect(arrow.height).toBeCloseTo(82.267, 1); }); test("unselected bound arrows update when rotating their target elements", async () => { @@ -48,9 +48,10 @@ test("unselected bound arrows update when rotating their target elements", async height: 120, }); const ellipseArrow = UI.createElement("arrow", { - position: 0, - width: 40, - height: 80, + x: -10, + y: 80, + width: 50, + height: 60, }); const text = UI.createElement("text", { position: 220, @@ -59,8 +60,8 @@ test("unselected bound arrows update when rotating their target elements", async const textArrow = UI.createElement("arrow", { x: 360, y: 300, - width: -100, - height: -40, + width: -140, + height: -60, }); expect(ellipseArrow.endBinding?.elementId).toEqual(ellipse.id); @@ -69,16 +70,16 @@ test("unselected bound arrows update when rotating their target elements", async UI.rotate([ellipse, text], [-82, 23], { shift: true }); expect(ellipseArrow.endBinding?.elementId).toEqual(ellipse.id); - expect(ellipseArrow.x).toEqual(0); - expect(ellipseArrow.y).toEqual(0); + expect(ellipseArrow.x).toEqual(-10); + expect(ellipseArrow.y).toEqual(80); expect(ellipseArrow.points[0]).toEqual([0, 0]); - expect(ellipseArrow.points[1][0]).toBeCloseTo(48.98, 1); - expect(ellipseArrow.points[1][1]).toBeCloseTo(125.79, 1); + expect(ellipseArrow.points[1][0]).toBeCloseTo(66.317, 1); + expect(ellipseArrow.points[1][1]).toBeCloseTo(144.38, 1); expect(textArrow.endBinding?.elementId).toEqual(text.id); expect(textArrow.x).toEqual(360); expect(textArrow.y).toEqual(300); expect(textArrow.points[0]).toEqual([0, 0]); - expect(textArrow.points[1][0]).toBeCloseTo(-94, 0); - expect(textArrow.points[1][1]).toBeCloseTo(-116.1, 0); + expect(textArrow.points[1][0]).toBeCloseTo(-95.74, 0); + expect(textArrow.points[1][1]).toBeCloseTo(-119.7354, 0); }); diff --git a/packages/excalidraw/tests/selection.test.tsx b/packages/excalidraw/tests/selection.test.tsx index 10f4f7ad98..dde3c96e48 100644 --- a/packages/excalidraw/tests/selection.test.tsx +++ b/packages/excalidraw/tests/selection.test.tsx @@ -425,8 +425,8 @@ describe("select single element on the scene", () => { fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 }); fireEvent.pointerUp(canvas); - expect(renderInteractiveScene).toHaveBeenCalledTimes(8); - expect(renderStaticScene).toHaveBeenCalledTimes(6); + expect(renderInteractiveScene).toHaveBeenCalledTimes(9); + expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); @@ -469,8 +469,8 @@ describe("select single element on the scene", () => { fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 }); fireEvent.pointerUp(canvas); - expect(renderInteractiveScene).toHaveBeenCalledTimes(8); - expect(renderStaticScene).toHaveBeenCalledTimes(6); + expect(renderInteractiveScene).toHaveBeenCalledTimes(9); + expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); @@ -487,7 +487,12 @@ describe("tool locking & selection", () => { expect(h.state.activeTool.locked).toBe(true); for (const { value } of Object.values(SHAPES)) { - if (value !== "image" && value !== "selection" && value !== "eraser") { + if ( + value !== "image" && + value !== "selection" && + value !== "eraser" && + value !== "arrow" + ) { const element = UI.createElement(value); expect(h.state.selectedElementIds[element.id]).not.toBe(true); } diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index c7857382cb..66110700cb 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -6,8 +6,6 @@ import type { EditorInterface, } from "@excalidraw/common"; -import type { SuggestedBinding } from "@excalidraw/element"; - import type { LinearElementEditor } from "@excalidraw/element"; import type { MaybeTransformHandleType } from "@excalidraw/element"; @@ -34,6 +32,7 @@ import type { ExcalidrawIframeLikeElement, OrderedExcalidrawElement, ExcalidrawNonSelectionElement, + BindMode, } from "@excalidraw/element/types"; import type { @@ -205,6 +204,7 @@ export type StaticCanvasAppState = Readonly< frameRendering: AppState["frameRendering"]; currentHoveredFontFamily: AppState["currentHoveredFontFamily"]; hoveredElementIds: AppState["hoveredElementIds"]; + suggestedBinding: AppState["suggestedBinding"]; // Cropping croppingElementId: AppState["croppingElementId"]; } @@ -218,8 +218,9 @@ export type InteractiveCanvasAppState = Readonly< selectedGroupIds: AppState["selectedGroupIds"]; selectedLinearElement: AppState["selectedLinearElement"]; multiElement: AppState["multiElement"]; + newElement: AppState["newElement"]; isBindingEnabled: AppState["isBindingEnabled"]; - suggestedBindings: AppState["suggestedBindings"]; + suggestedBinding: AppState["suggestedBinding"]; isRotating: AppState["isRotating"]; elementsToHighlight: AppState["elementsToHighlight"]; // Collaborators @@ -234,6 +235,11 @@ export type InteractiveCanvasAppState = Readonly< // Search matches searchMatches: AppState["searchMatches"]; activeLockedId: AppState["activeLockedId"]; + // Non-used but needed in binding highlight arrow overdraw + hoveredElementIds: AppState["hoveredElementIds"]; + frameRendering: AppState["frameRendering"]; + shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"]; + exportScale: AppState["exportScale"]; } >; @@ -293,7 +299,7 @@ export interface AppState { selectionElement: NonDeletedExcalidrawElement | null; isBindingEnabled: boolean; startBoundElement: NonDeleted | null; - suggestedBindings: SuggestedBinding[]; + suggestedBinding: NonDeleted | null; frameToHighlight: NonDeleted | null; frameRendering: { enabled: boolean; @@ -368,6 +374,7 @@ export interface AppState { | { name: "imageExport" | "help" | "jsonExport" } | { name: "ttd"; tab: "text-to-diagram" | "mermaid" } | { name: "commandPalette" } + | { name: "settings" } | { name: "elementLinkSelector"; sourceElementId: ExcalidrawElement["id"] }; /** * Reflects user preference for whether the default sidebar should be docked. @@ -450,6 +457,7 @@ export interface AppState { // as elements are unlocked, we remove the groupId from the elements // and also remove groupId from this map lockedMultiSelections: { [groupId: string]: true }; + bindMode: BindMode; } export type SearchMatch = { @@ -466,7 +474,7 @@ export type SearchMatch = { export type UIAppState = Omit< AppState, - | "suggestedBindings" + | "suggestedBinding" | "startBoundElement" | "cursorButton" | "scrollX" @@ -750,6 +758,8 @@ export type AppClassProperties = { onPointerUpEmitter: App["onPointerUpEmitter"]; updateEditorAtom: App["updateEditorAtom"]; onPointerDownEmitter: App["onPointerDownEmitter"]; + + bindModeHandler: App["bindModeHandler"]; }; export type PointerDownState = Readonly<{ diff --git a/packages/utils/src/test-utils.ts b/packages/utils/src/test-utils.ts index 1dfd14cacb..966a589ab9 100644 --- a/packages/utils/src/test-utils.ts +++ b/packages/utils/src/test-utils.ts @@ -6,11 +6,11 @@ expect.extend({ throw new Error("expected and received are not point arrays"); } - const COMPARE = 1 / Math.pow(10, precision || 2); + const COMPARE = 1 / precision === 0 ? 1 : Math.pow(10, precision ?? 2); const pass = expected.every( (point, idx) => - Math.abs(received[idx]?.[0] - point[0]) < COMPARE && - Math.abs(received[idx]?.[1] - point[1]) < COMPARE, + Math.abs(received[idx][0] - point[0]) < COMPARE && + Math.abs(received[idx][1] - point[1]) < COMPARE, ); if (!pass) { diff --git a/packages/utils/tests/__snapshots__/export.test.ts.snap b/packages/utils/tests/__snapshots__/export.test.ts.snap index d3bbff7af7..f914a2bf2b 100644 --- a/packages/utils/tests/__snapshots__/export.test.ts.snap +++ b/packages/utils/tests/__snapshots__/export.test.ts.snap @@ -11,6 +11,7 @@ exports[`exportToSvg > with default arguments 1`] = ` "locked": false, "type": "selection", }, + "bindMode": "orbit", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -104,7 +105,7 @@ exports[`exportToSvg > with default arguments 1`] = ` "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null,