Compare commits

...
Author SHA1 Message Date
Mark Tolmacs fc30ca5720 feat(editor): Remove complex binding
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-10-01 09:52:16 +00:00
David Luzar 1919728724 fix(editor): reach text containers below transparent ones (#12210)
The text tool (hover and click), and a double-click with nothing
selected, took the top-most shape whose bounds held the pointer. A
transparent shape stacked above a nested one kept the inner one out of
reach at its center, and an arrow below a transparent shape couldn't get
a label.

- only a filled element or an image (the rule arrow binding uses, now
  exported as isOpaqueForBinding) or an arrow ends the search
- of the containers found, the innermost one whose center is at the
  pointer wins, else the top-most as before
- an arrow counts only where its path is hit, not anywhere in its bounds
- a frame's children aren't hit outside the frame, where they're clipped
  (App.isClippedByFrameAt, shared with getElementsAtPosition)

Claude Opus 5.5 (1M), xhigh effort
2026-09-30 23:26:42 +02:00
David Luzar 35e854ecf7 feat(app): label E+ banner Upgrade / Sign in (#12204)
Claude Opus 5.5 (xhigh)
2026-09-29 17:44:41 +02:00
Renan-MouraN 5a406e5187 fix(app): correct typo in Mermaid input placeholder (#12186)
fix: correct typo in Mermaid input placeholder
2026-09-28 22:18:02 +00:00
David Luzar e283f724c8 refactor(editor): move the clipboard out of App into AppClipboard (app.clipboard) (#12197)
* refactor(editor): move the clipboard out of App into AppClipboard (app.clipboard)

A pure move of the clipboard handling out of App.tsx (13,478 → 12,942
lines) into App.clipboard.ts: onCut and onCopy (the document's cut/copy
events, through their actions), pasteFromClipboard (the paste event,
parsed, and handed to a host's onPaste first), insertClipboardContent
(inserting the pasted data as whatever it is: images or SVG code,
elements, a Mermaid definition, embeddable URLs, a spreadsheet as a
chart, mixed content, or text), addElementsFromMixedContentPaste and
addTextFromPaste. The plain-paste flags move along as module-level state;
Ctrl/Cmd+V's marking of a plain paste, and its reset, are App's former
lines behind app.clipboard.onPasteShortcut and clearPlainPaste.

Staying in App: addElementsFromPasteOrLibrary and insertImages, shared
with drop, the library and text-to-diagram (insertImages becomes public
for the clipboard), and onIframeSrcCopy, which belongs to the magic frame.

Callers go through `app.clipboard`: no delegate is left on App, and
AppClassProperties exposes `clipboard` (as it does `textTool`) instead of
`pasteFromClipboard`. Beyond the move, `this.` becomes `this.app.` for
what stays on App, onCut and onCopy become public, and the container ref
comes in as a dependency. The moved code is identical to the removed,
modulo those rewrites, whitespace and trailing commas (checked
mechanically).

Claude Opus 5.5 (1M), xhigh effort

* test(editor): cover copy/cut events and more paste routes

Only 80% of the clipboard code ran under the suite, and nothing went
through the document's copy and cut events, which is how Ctrl/Cmd+C/X
reach the editor in a browser. Now a copy event copies the selection and
a cut event cuts it; a host's onPaste returning false keeps the paste
from being inserted; a pasted spreadsheet opens the charts dialog; and
with the image tool off, a paste of mixed content inserts its text.

Claude Opus 5.5 (1M), xhigh effort
2026-09-28 23:51:36 +02:00
David Luzar 3947883063 refactor(editor): move text editing out of App into AppText (app.text) (#12195)
* refactor(editor): move text editing out of App into AppText (app.text)

A pure move of the text editing session, and of the text helpers around
it, out of App.tsx (14,250 → 13,478 lines) into App.text.ts. It sits next
to AppTextTool, which keeps the text tool's pointer interaction.

AppText holds:
- the session: startTextEditing (creates or picks the text to edit, and
  opens the editor), handleTextWysiwyg (the editor's lifecycle) and the
  open editor's submit handler
- the part of the canvas a typed or pasted text is kept within:
  getTextSidePanels, getTextViewportOffsets and getMaxTextWidth (paste,
  which stays in App for now, calls into them)
- where a new text goes: getTextCreationGridPoint and
  getTextWysiwygSnappedToCenterPosition
- the queries that find the text or container to edit:
  getTextElementAtPosition, getTextBindableContainerAtPosition,
  getSelectedTextElement, getSelectedTextEditingContainerAtPosition and
  isEditingTextContent
- the auto-resize handle: isHittingTextAutoResizeHandle and
  handleTextAutoResizeHandlePointerDown

Callers go through `app.text`, startTextEditing's included: no delegate
is left on App (no host or test used it). Beyond the move, `this.`
becomes `this.app.` for what stays on App, members called from outside
become public (App.deselectElements too), and the container ref and the
styles panel mode come in as dependencies, as AppViewport's do. The moved
code is identical to the removed, modulo those rewrites, whitespace and
trailing commas (checked mechanically).

Claude Opus 5.5 (1M), xhigh effort

* test(editor): click the text auto-resize handle through the pointer

The handle that turns a fixed-width text back into a growing one was only
covered through its action: nothing hovered or clicked it. Hovering it on
a selected wrapped text now shows the pointer cursor, and clicking it
unwraps the text, through AppText's hit test and pointerdown handler.

Claude Opus 5.5 (1M), xhigh effort
2026-09-28 22:18:13 +02:00
David Luzar 3d056dbc8a feat(editor): wrap text at 800 and keep it clear of the side panels (#12192)
* feat(editor): wrap typed & pasted text at 800, clear of the side panels

A free text growing as it's typed stopped only at the view's width
(#12175), so on a large screen it ran to 2000+ px before wrapping, and a
pasted text wrapped at half the view. Both now follow one rule, in
`getMaxTextWidth`:

- no wider than TEXT_MAX_WRAP_WIDTH (800, in @excalidraw/common) in scene
  units, at any zoom: the paste's former cap, now shared. A paste no
  longer wraps at half the view.
- nor wider than the canvas between the side panels, less
  TEXT_VIEWPORT_PADDING at each side: the docked sidebar, as before; the
  full styles panel (desktop), which shows next to a text while it's
  edited, and once it's pasted and selected (pasting with nothing
  selected, its room is reserved: as last measured, else approximately);
  and the stats panel, only for a text beside it, i.e. with rows within
  that padding of it, as it covers only the top of the canvas' side.

A text that starts wrapping is brought in between the same panels, as
before. A paste is laid out as if the stats panel weren't there, and
again, clear of it, only if it lands beside it. As the stats panel grows
to show a selection's properties, with it open the paste is rendered
selected first (not captured yet, via `flushSync`), so the panels are
measured as they'll be; laid out again, it stays the same elements, and
one undo step. A wrapped paste is then moved into view (as far as the view
would have scrolled), instead of scrolling the view.

The stats panel is marked as side viewport UI (`data-viewport-ui="side"`,
named "stats"), so the viewport offsets' other users (zoom to fit, scroll
to content, search, a host's `setViewport` with UI offsets) keep clear of
it too while it's open. `AppViewport` gains `getSideInsets(names,
{ reserve })`, generalizing `getSidebarInsets`, and `getSideUIRect(name)`;
`getOffsets` shares the reserve fallback through `getReservedSide`.

Tests: the cap at 50% and 200%; a typed text clear of the styles and the
stats panel, and of the stats panel only beside it; a paste wrapping as a
typed one, with the styles panel's room reserved; a paste clear of the
stats panel only where it lands beside it, and of the stats panel grown to
show it, as one undo step; a wrapped paste moved into view, the view not
scrolled.

Claude Opus 5.5 (1M), xhigh effort

* feat(editor): pan the canvas when the caret goes under the stats or styles panel

The canvas follows the caret past the view's edge, and behind the docked
sidebar, through the editor's box, which the browser scrolls to reveal a
caret (#12175). The stats and the full styles panel don't span the box's
height, so a caret typed in under them stayed out of sight.

After each input, once the update (and any scroll it made) is in the
app's state, the editor works out where the caret is: its line from
`getWrappedTextLines`, its x from the width of the line before it,
rotated with the text. In a line with RTL characters (`hasRTLChars`, new
in @excalidraw/common) the x comes from the browser's bidirectional
layout of the line instead, in its paragraph's direction (the editor's
`dir="auto"` makes it `unicode-bidi: plaintext`: each paragraph goes its
first strong character's way): with Hebrew then English, the caret after
the English is mid-line, not at the line's edge. That's measured through
a range in an off-screen mirror of the line, shared with the
click-to-caret lookup. If the caret's under one of those panels, the
canvas pans to bring it out on the canvas' side, with
CARET_FOLLOW_PADDING to spare. Only then: a text beside or below a panel
stays where it is.

Tests: typing past the stats panel's left edge pans the canvas under it,
but not below it; the caret in a bidirectional line, a paragraph after
one going the other way, is found where the browser lays it out.

Claude Opus 5.5 (1M), xhigh effort
2026-09-28 21:06:41 +02:00
2d3707b816 feat(editor): add showHints to command palette (#12187)
* feat(editor): route showHints through the action system (#12165 follow-up)

`showHints` was the only item in the Preferences submenu that flipped
appState directly instead of going through an action, so it was missing
everything the action system provides: no command palette entry, no
`trackEvent`, no `viewMode` declaration.

- add `actionToggleShowHints`, modelled on `actionToggleMidpointSnapping`
  (`viewMode: false` — hints live in the toolbar, which view mode hides)
- register it under `ActionName` and export it from `actions/index.ts`
- `PreferencesToggleShowHintsItem` now calls `executeAction`
- list it in the command palette's editor commands
- document `appState.showHints` /
  `MainMenu.DefaultItems.Preferences.ToggleShowHints` in the CHANGELOG,
  which the original commit never touched

No keyboard shortcut: like `arrowBinding` and `midpointSnapping`, this is
a set-once preference, and the natural mnemonic (`Alt+H`) is already
distribute-horizontally.

Adds `tests/showHints.test.tsx` — the preference previously had no
behavioural coverage at all, only incidental snapshot entries.

* chore(editor): drop showHints tests & changelog entry

Both will be rewritten in a follow-up PR.

Opus 5.5 (1M context), xhigh

---------

Co-authored-by: hiennd <hiennd@aidia.vn>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-09-28 09:17:05 +00:00
David Luzarandhiennd 834e5af631 fix(editor): follow-ups to zen mode polish & delayed tooltips (#12188)
* fix(editor): follow-ups to zen mode polish & delayed tooltips (#12165)

Tooltip state (timer + warm window) is shared by all instances, since they
share one tooltip node. Without an owner, any unrelated Tooltip unmounting
(a collaborator leaving, a sidebar breakpoint) cancelled or hid a tooltip
the user was still hovering. Track the owning item and only hide from the
paths that own it; `hideTooltip` no longer creates the tooltip node just to
hide it.

Zen mode hides or slides controls without the pointer moving, so nothing
fired pointerleave to retract their tooltips — markZenModeTransition now
hides the tooltip up front.

`:focus-visible` was not excluded from the chrome-less zen-mode rules, so
the keyboard focus ring (a box-shadow) disappeared for the main menu
trigger, scroll-back-to-content and exit-zen-mode buttons.

The zoom buttons lost their native `title`, which was the only channel
carrying their shortcut to keyboard/AT users — expose it via
`aria-keyshortcuts`, as Tools.tsx does.

Also: schedule the zen-mode timer on `ownerWindow` like every other
DOM-touching timer in App.tsx, re-read the container ref when unflagging
(and unflag on unmount), and drop the footer-left `--transition-left`
class, which never matched a selector.

Adds the missing coverage for the transition-attribute lifecycle, the
`openMenu`-across-view-mode fix, and the tooltip delay/warm window.

Split out of #12187 and ported onto the #12173 Tooltip rework: the owner
is set in `showTooltip`, and its MutationObserver already retracts a
tooltip whose wrapper got `disabled`, so that extra effect is dropped, as
is the delayed-show label ref (`showTooltip` takes the label by value).

Opus 5.5 (1M context), xhigh

* refactor(editor): drop tooltip owner tracking, trim tests

Since #12173, `showTooltip`'s MutationObserver retracts a visible tooltip
whose wrapper leaves the DOM, and its `isConnected` check drops a pending
one. That makes the Tooltip unmount effect, which hid whatever tooltip was
showing, redundant: removing it fixes the unrelated-unmount bug on its own,
without tracking an owner.

Also drop zenMode.test.tsx and the tooltip tests that duplicated others.

Opus 5.5 (1M context), xhigh

---------

Co-authored-by: hiennd <hiennd@aidia.vn>
2026-09-28 10:14:30 +02:00
Dinesh Yadavanddwelle 438d89861f docs(repo): remove obsolete CodeSandbox contribution workflow (#11908)
* docs: remove obsolete CodeSandbox contribution workflow

https://codesandbox.io/p/github/excalidraw/excalidraw (forking the
whole repo as an editable sandbox to submit PRs from) no longer works.
It was documented in two places — the Contributing guide and the
Development guide — as an alternative to the manual clone/fork setup.

This doesn't touch the other CodeSandbox links in the docs (README,
installation/integration/development guides for the @excalidraw/excalidraw
package), which point at the separate with-script-in-browser usage example
and are unrelated to this contribution workflow.

* chore: trigger CI

Claude Opus 5.5 (low)

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-09-27 20:53:17 +00:00
David Luzar 02fc9f3505 fix(packages/excalidraw): setViewport stops following a collaborator (#12184)
Navigating with `setViewport` takes the view over from the followed user,
as a user's pan or zoom does, so it now asks to stop following
(`requestUnfollow`: `onUserFollow` with `action: "UNFOLLOW"`, when
`userToFollow` is set). `setViewport(null)`, which only clears a lock, and
a target that doesn't resolve don't navigate and leave following alone.

Following itself doesn't go through `setViewport`: excalidraw-app and
Excalidraw+ both apply the followed user's viewport with `updateScene` +
`zoomToFitBounds`, so it's unaffected.

Tests: navigating emits the UNFOLLOW intent once; clearing a lock and an
unresolved target don't.

Claude Opus 5.5 (1M), xhigh effort
2026-09-27 19:47:29 +02:00
David Luzar 74423812ec perf(editor): avoid rebuilding id sets on every drag (#12183)
perf(element): reuse one moved-element ID set when dragging

dragSelectedElements passed a fresh Array.from(elementsToUpdate) to every
element's updateBoundElements call. Each call missed the per-array ID-set
cache and rebuilt a selection-sized set, so a drag step was quadratic in the
number of dragged elements.

Build the array once per drag step, pass it to every call, and reuse its
cached set (getSimultaneouslyUpdatedElementIds, now exported) for the
unbinding checks too. Document that callers must pass one array instance
per operation and must not change its membership afterwards.

Dragging 1K / 2K / 4K selected rectangles in the app went from 45 / 160 /
643 ms to 18.5 / 31 / 54 ms per frame (same tab, same window, dev build).

Add coverage for the shared array, grouped labels across z-order, frames
with their children, bound arrows, bindings, unbinding, and unchanged
coordinates.

Claude Opus 5.5 (1M context), reasoning effort 40
2026-09-27 18:45:39 +02:00
Nicolas OgaandClaude Opus 5.5 84e3f5a40c perf(editor): avoid rebuilding id sets on every resize (#12180)
perf: avoid rebuilding simultaneously-updated id set per element on multi-element resize

updateBoundElements() rebuilt a Set of all simultaneouslyUpdated ids on
every call. Multi-element resize/rotate call it once per selected element
with the same array, making each pointer move O(n^2). Cache the set per
array in a WeakMap.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:01:38 +00:00
Alexandre Kohler 974f054baf fix(editor): embed YouTube live links (#12178) 2026-09-27 07:49:24 +00:00
afed9e6e27 fix(editor): handle the save-as shortcut while editing text (#11922)
* fix(editor): handle the save-as shortcut while editing text

Cmd/Ctrl+Shift+S did nothing while a text editor was open — the editor stayed
open and no save happened. Plain Cmd/Ctrl+S worked.

The wysiwyg keydown handler only branches on `actionSaveToActiveFile`, whose
keyTest requires `!event.shiftKey`, so the shift variant matched nothing and was
swallowed. This adds the sibling branch for `actionSaveFileToDisk`, mirroring
what the plain shortcut already does: commit the text, then run the action.

Complements #9281, which fixed the plain shortcut in the same handler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(editor): assert the save-as shortcut actually dispatches the save

Review feedback: the regression test only asserted that editing ended, which
`handleSubmit()` satisfies on its own — the test still passed with the
`executeAction(actionSaveFileToDisk)` call deleted, so half the fix was unpinned.

Now spies on `saveAsJSON`, which is what the action ultimately reaches, and
captures `editingTextElement` at the moment it runs so the ordering (commit,
then save) is pinned too. Verified: removing the dispatch now fails with
`expected "saveAsJSON" to be called 1 times, but got 0 times`.

Also adds a negative case — closing the editor with Escape must not save — so
the spy proves the save is tied to the shortcut rather than to teardown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(editor): dispatch text-editor save shortcuts as keyboard actions, trim tests

- pass the "keyboard" source for both save shortcuts in the wysiwyg keydown
  (was the default "api", which skipped the interaction gate and mistracked)
- drop the explanatory comment on the save-as branch
- fold the three save tests into one it.each in the existing container-unbound
  suite, covering both Cmd/Ctrl+S and Cmd/Ctrl+Shift+S and pinning that the
  text is committed before saveAsJSON runs

Claude Opus 5.5 (1M context), xhigh

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-09-26 22:38:03 +00:00
David Luzar d29d864884 feat(editor): file drop overlay & shift-to-merge imported scene (#12177)
* feat(editor): add file drop overlay and Shift-to-insert support

* fix(editor): report copy drop effect for file drags

Shift defaults a drag to "move" (and a "Move" cursor), but a dropped file
is only ever read in, never moved.

Opus 5.5 (xhigh)

* fix(editor): ignore file drops in view mode and when non-interactive

The dragover/drop cancel listeners were only registered in edit mode, so
elsewhere the browser opened a dropped file natively. Register them always,
show a no-drop cursor where drops are ignored, and don't handle the drop.

Opus 5.5 (xhigh)

* feat(editor): cross out the existing file in the drop overlay when replacing

Opus 5.5 (xhigh)

* fix(editor): note that library files won't replace content in the drop overlay

OS-dragged .excalidraw and .excalidrawlib files both report an empty MIME
type until dropped, so the overlay can't tell them apart. Keep the replace
copy for untyped files and add a secondary note for libraries.

Opus 5.5 (xhigh)

* fix(editor): use --overlay-bg-color for modal and file drop backdrops

Replaces color-mix() in the file drop overlay, which is newer than our
browserslist, with the (previously unused) --overlay-bg-color token, now
hardcoded per theme, and reuses it for the <Modal> backdrop.

Opus 5.5 (xhigh)

* fix(editor): scope image-scene drop try to parsing, drop redundant preventDefault

Only loadFromBlob is wrapped now, so an error while inserting or replacing
isn't reported as an invalid file or followed by a plain image insert.
App's dragover listener is always registered, so the overlay no longer needs
to call preventDefault itself.

Opus 5.5 (xhigh)

* fix(editor): don't let the browser open files dropped on modals

Modal portals live outside the editor container, so its dragover/drop
listeners didn't cover them. Cancel file drags on body-level portal
containers, leaving non-file drags (e.g. text into inputs) native.

Opus 5.5 (xhigh)

* chore(editor): move fileDrop locale strings next to overwriteConfirm

Opus 5.5 (xhigh)

* feat(editor): toast how to undo after a drop replaces the scene

Opus 5.5 (xhigh)
2026-09-27 00:18:02 +02:00
David Luzar f3d99c42e9 fix(editor): pan the canvas to follow the caret instead of shifting the UI (#12175)
* fix(editor): keep the text editor flush with the canvas when a click places the caret in a tall text

Editing a text that reaches past the viewport with the text tool (a click
places the caret) scrolled the editor's content: the textarea is clamped to
the viewport, focus() reveals the caret — which sits at the end of the value
until the initial selection is applied — and the deferred refocus after the
pointerdown's blur did that on an unfocused textarea, so the text moved up by
the overflow while the box stayed put. The selection tool's single-click edit
starts on pointerup, with no blur, so it never refocused and looked fine.

After placing the initial caret — inside the visible box by construction —
the textarea's own scroll goes back to zero.

Verified in the browser on a 49-line text hanging 250px below the viewport at
90% zoom: internal scroll was 278px, now 0, for the text tool with the
mousedown blur; the selection-tool path stays at 0. jsdom textareas don't
scroll, so there is no unit test for it.

Claude Fable 5.1, high effort

* fix(editor): place the editor of a rotated text where the text is

The editor's textarea was scaled and rotated about its own box's center,
while the canvas rotates a text about the text's center. The two only agree
when the box is the text's size — and it isn't once the box is clamped to
the viewport (a tall text reaching past it) or, slightly, through the 5%
height buffer. A rotated text scrolled partly off the viewport got its
editor displaced by the rotated pivot offset (36px for a 49-line text at
-18° and 130% zoom), and the clamp — a cut along the box's own edges —
removed lines that were still on screen on the side the rotation lifted.

Now the transform origin is the text's center whatever the box's size, with
the translate derived from the text's dimensions, so the editor lands on the
text in every case; the viewport clamps (right and bottom) apply only to an
unrotated box, whose edges are the viewport's, and a rotated box is left
whole. Since an unclamped box can reach past the viewport, both focus()
calls pass preventScroll so revealing the caret can never scroll the
container — the app owns scrolling.

Verified in the browser: all four corners of the rotated editor coincide
with the text's (they were off by up to 36px), typing at the off-screen end
leaves the container unscrolled, and the unrotated case is unchanged — still
clamped to the viewport bottom, top-left in place, no internal scroll on a
click caret, select-all on Enter.

Also updates the linear-editor style snapshot for the editor's explicit
transform origin.

Claude Fable 5.1, high effort
Claude Opus 5.5 (1M), xhigh effort (moved the snapshot update in)

* fix(editor): never let the browser scroll the editor container to reveal a caret

The root container is `overflow: hidden`, which still makes it a scroll
container the browser scrolls programmatically. Typing in a text editor
whose box reaches past the viewport — a rotated text's editor cannot be
clamped to the viewport without losing lines still on screen — made the
browser reveal the caret by scrolling the container, pushing the whole UI
out: two new lines below the viewport moved the toolbar up by 416px.

`overflow: clip` clips exactly like `hidden` but is not a scroll container,
so there is nothing for the caret reveal (or a focus) to scroll; `hidden`
stays as the fallback where `clip` is unknown. The editor never scrolled
this box itself.

Verified in the browser with real key input on a rotated 49-line text
hanging below the viewport at 130% zoom: the container stays at 0 and the
toolbar in place through new lines and characters typed past the viewport.

Claude Fable 5.1, high effort

* fix(editor): the canvas follows the caret instead of the page scrolling to reveal it

With the editor root at `overflow: clip` (previous commit), the browser's
caret reveal skips the root, since it is no longer a scroll container. Full
page, nothing moves, but the caret stays out of view. Embedded in a page
that scrolls (directly, or in a same-origin iframe), the reveal scrolls that
host page instead: 434px for a rotated text reaching past the editor's
bottom edge, 345px for a text scrolled below it while edited. Only a
cross-origin iframe stops it.

The root goes back to `overflow: hidden`, so it absorbs the reveal again.
While a text is being edited, the text editor hands any scroll of the root
to the canvas (`viewport.translate` by the same screen distance) and resets
the root. Scroll events fire before the frame is painted, so the root's
shift is never seen. Nothing moves, host page included, and the canvas pans
just far enough to bring the caret into view. The reveal is the browser's,
so this covers everything that moves the caret: typing, Enter, arrow keys,
paste, IME, and drag-selecting past the edge.

Measured with real key input in Chromium, full page and in a same-origin
iframe embed: host page and root unmoved, canvas panned 444px / 330-355px,
caret visible, the editor still on its text. Outside text editing, a root
scroll is left as it was.

Test: a root scroll while editing is reset and pans the canvas (at 200%
zoom, both axes) with the editor on it; with no text being edited, the root
is left alone.

Claude Opus 5.5 (1M), xhigh effort

* fix(editor): never cut the text editor to the viewport; the canvas follows the caret for every text

The editor of an unrotated text was cut to the viewport's right and bottom
edges (the width of unbound text, plus a max-height), so a caret past an
edge was revealed by scrolling the textarea's content inside the cut box.
The canvas stood still, and the editor's text left its place on the canvas
while typing. Rotated texts were already left whole, and their caret reveal
panned the canvas (previous commit).

Now no editor box is cut: rotated or not, it is the text's own box, and a
caret past the viewport's edge is revealed by panning the canvas. Typing
past the bottom edge, or a long line past the right edge, moves the view
along; the editor stays on its text.

What only existed for the cut goes with it: the `maxWidth` bookkeeping, and
the reset of the textarea's internal scroll after placing a click's caret
(a whole box never scrolls inside).

Measured with real input in Chromium: 455px vertical pan for a 30-line text
crossing the bottom edge, 454px horizontal pan for a line crossing the right
edge, root and textarea unscrolled, editor on its text. A text-tool click on
line 3 of a text taller than the viewport places the caret there without any
pan or internal scroll.

Tests: the max-width test now checks the editor isn't cut at either edge
(it fails with the cut in place); the linear-editor style snapshot loses its
max-height.

Claude Opus 5.5 (1M), xhigh effort

* feat(editor): leave room past the caret when the canvas follows it

The browser reveals a caret only as far as it has to, so text typed past
the viewport's edge came into view flush against the edge. The canvas now
pans a further CARET_FOLLOW_PADDING (5 screen px) along the axis it
follows, so the caret keeps a little room from the edge while the view
follows it.

Not done with the root's `scroll-padding`: the browser honors it only as
far as it can scroll, and the editor box ends at the text (5% below it,
nothing past a line's end). Measured in Chromium: `scroll-padding: 80px`
left 39px below the caret and none to its right.

Measured with real input: once the caret reaches the edge, every new line
or character pans the canvas and the caret stays 4-5px from the edge, both
typing down and along a long line.

Test: the pan includes the padding, and a reveal along one axis pans along
that axis only.

Claude Opus 5.5 (1M), xhigh effort

* feat(editor): the canvas follows a caret behind a docked sidebar; the reveal stays in the editor's box

A caret that went behind a docked sidebar stayed hidden: it was still
inside the editor root, so the browser had nothing to reveal and the canvas
never followed.

The text editor's container (`.excalidraw-textEditorContainer`) is now the
box a caret is revealed into: the canvas area, clipped, and inset off a
docked sidebar (measured from `.sidebar--docked` on every editor update, on
the right or the left for RTL; the editor's position is relative to the
box). A caret past its edge, or behind the sidebar, is revealed by
scrolling the box, which the text editor turns into a canvas pan (+
padding) and resets, as it did for the root before.

The reveal now never gets past the box, so the editor root is back to
master's CSS, and what a host does to the root's overflow no longer
matters: with the root forced to `overflow: visible` (as Excalidraw+ does,
inside a wrapper that resets its own scroll), the canvas follows the same
way and the wrapper never scrolls.

Measured with real input in Chromium, full page, with the root forced to
`visible`, in the Excalidraw+ arrangement, and in a same-origin iframe
embed: nothing scrolled (host page, root, box), the canvas panned 449 /
360 / 580px for texts past the bottom edge, and for a line typed in behind
the docked sidebar it panned 120px, leaving the caret 5px left of the
sidebar. Clicks inside the editor still place the caret; clicks on the
canvas still end editing.

Tests: the pan test scrolls the editor's box; a new test covers the insets
for a sidebar docked on either side, and their removal with the editor.

Claude Opus 5.5 (1M), xhigh effort

* refactor(editor): measure the sidebar through the viewport UI markers

The text editor's box was kept off a docked sidebar by querying the
`.sidebar--docked` class and measuring it inline in textWysiwyg. How far
viewport UI reaches into the canvas is AppViewport's concern:
`getOffsets` measures every UI marked `data-viewport-ui`, and the sidebar
is marked (`side` / `sidebar`, except on phones, where it's an overlay).

AppViewport gains `getSidebarInsets()`, which measures just the sidebar
through those markers, with a `measureSide()` that `getOffsets` now shares.
The text editor asks the viewport for the insets. An open sidebar that
isn't docked covers the canvas the same way and now counts too; it closes
when the canvas is clicked to start a text, so this rarely matters.

`getOffsets` isn't used for the box: it gives one band per side for all
marked UI, so the top toolbar and the styles panel (open while a text is
edited) would pan the canvas as soon as one typed below them.

Test: the docked-sidebar test's fake sidebar carries the markers.

Claude Opus 5.5 (1M), xhigh effort

* feat(editor): a text stops growing at the view's width, and is brought into view when it wraps

Closes #8855. Typing a long line grew a free text past the view's edge.
The canvas followed the caret, so the start of the text went out of view
on the other side, and the box had to be resized by hand afterwards.

A free text that grows with its content (`autoResize`) now stops at the
view's width: the canvas minus the sidebar, less TEXT_VIEWPORT_PADDING
(20px, in @excalidraw/common) at each side, in scene units, so it follows
the zoom. On the keystroke that would cross it, the text becomes fixed
width at exactly that width and wraps from then on (`autoResize: false`),
anchored the way it grew, so a right-aligned or centered text keeps its
edge or center. The editor switches to wrapping with it. A text that is
already wider keeps growing; nothing shrinks. The rule is an optional
`maxWidth` on `refreshTextDimensions`, which the editor's per-keystroke
update passes.

When a text starts wrapping in the editor, while typed or pasted into it,
it is scrolled into view with the same room at the edges. Being exactly
the padded view's width, it then fills it. That happens horizontally
always, and vertically only if all of it fits; a taller text leaves the
vertical to the caret-follow, so the view doesn't jump away from the caret.

Pasting onto the canvas keeps its wrap width (half the view, 200-800),
capped by the same limit, so that the 200 minimum can't outgrow a narrow
or zoomed-in view. A paste that wrapped is scrolled fully into view; if
it is taller than the view, its top is brought in.

Scrolling into view is computed by a pure `scrollBoundsIntoView` in
viewport.ts, next to `centerScrollOn` and `constrainScrollState`. It moves
by the least amount, keeps the zoom, takes the usable area as `Offsets`,
and returns null when nothing needs to move. The move itself goes through
`viewport.translate`, as a user pan does: it respects scroll locks, yields
to a host's pending locked navigation, and stops following a
collaborator. No move, no translate.

Measured with real input in Chromium on a 2560px view: typing
character by character at 400% from 100px in, and a sentence inserted at
once, both end as a wrapped text from 20 to 2540; with the docked sidebar
the limit is 2226 (2560 - 294 - 40) and the text sits 20px left of it; a
paste near the bottom-right corner ends 20px inside the right and bottom
edges. Nothing scrolled the page or the editor box.

Tests: the text stops and wraps at the view's width, and keeps that width;
the limit follows the zoom and a docked sidebar; a right-aligned text keeps
its right edge; a text already wider keeps growing (the not-cut-off test,
reworked); starting to wrap scrolls it into view with room at its right
edge, but not vertically when it's taller than the view; a paste isn't
wider than the view at 800% and, wrapped, ends fully in view;
`scrollBoundsIntoView` unit tests.

Claude Opus 5.5 (1M), xhigh effort
2026-09-26 23:00:28 +02:00
c10499eebb feat(editor): text tool edits what you click, shows what a click will do, drags out free text in shapes (#12082)
* fix(editor): do not inherit groupIds/angle from container if not binding the text to it

* fix(editor): text tool edits a label only when clicking the label itself

Clicking elsewhere inside a labeled container creates an independent text at
the clicked position, with no modifier needed (so also on touch/pen), instead
of warping to the label center and editing the existing label.

* feat(editor): highlight what the text tool would act on when hovering

Mirror the click logic on hover: the text element a click would edit gets
a box highlight, and an empty text-bindable container the click would bind
into gets the same binding highlight arrows use (box for arrow containers,
which the binding outline renderer can't draw). Cleared on pointerdown and
managed only while the text tool owns the interaction, so the frame flows
sharing elementsToHighlight are untouched.

Also documents suggestedBinding, isMidpointSnappingEnabled and
elementsToHighlight in AppState.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): hit-test arrow labels at their derived position

An arrow label's stored coords aren't updated when the arrow moves
(dragging deliberately skips them — the position is derived at render
time), which left hitElementItself internally inconsistent: its bounds
pre-check derives the position, but the precise shape test read the stale
stored coords, so a moved arrow's label was only hittable where its old
and new rects overlap. Substitute the derived coords for the precise test,
and skip the version-keyed point cache for container-bound labels (their
position can change without a version bump, mirroring ElementBounds).

Pass the elements map through renderElementsBoxHighlight so the highlight
box is also drawn at the label's derived position.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): dashed text box for the text-tool hover, cleared when the tool is left

The text a click would edit gets the same subtle dashed box as a wrapped text
being edited (at the label's derived position for arrows), instead of the
frame-style bounding box. The hover highlights are cleared whichever way the
text tool is left — Esc/finalize paths bypass setActiveTool.

* refactor(editor): dedupe container-bound label position derivation

An arrow label's accurate position is derived from the arrow rather than
stored on the element (dragging deliberately skips updating it), and three
call sites hand-rolled the same clone-with-derived-coords dance. Extract it
into getTextElementWithAccuratePosition next to the existing position
helpers.

Also routes the text-tool dashed hover highlight through the helper, so it
renders at the label's actual position instead of its stale stored coords.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): defer text-tool hover highlights to arrow text anchors

The arrow-endpoint text binding (#11777) added its own text-tool hover
affordance (hoveredArrowTextAnchor) covering arrow endpoints and midpoint
label anchors, and gave endpoints precedence on click. Mirror that in the
text-tool highlight updater: skip positions where a bindable endpoint would
take the click, and leave arrow anchors (endpoint & midpoint label) entirely
to the anchor affordance — the highlights now only cover editing an existing
text and binding into an empty non-arrow container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): make endpoint-vs-text preference z-aware to prevent affordance flicker

The endpoint offer was suppressed by any text element under the cursor,
regardless of stacking order. With an arrow drawn over an existing text,
the text's bbox edge cut into the endpoint's hit circle, and slow pointer
movement flickered between the endpoint anchor and text editing.

Drop the z-blind text check and let the existing z-order occlusion rule
decide: whatever is stacked above owns its hit area. A text above the
arrow keeps its edit behavior; when the arrow is on top, its endpoint has
full preference over the whole hit circle, matching the drawn highlight.
Container-bound labels need no special casing — a labeled container is
interior-hittable, so the occlusion rule already yields to labels whose
container sits above the arrow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): defer a text-tool center click so a drag creates free text

A click on an empty container's center still binds a label, but the decision
now waits for pointerup: dragging horizontally past the autowrap threshold
instead creates an independent fixed-width text at the pointer origin, and
small shapes are no longer provisionally enlarged while dragging. Tool
changes and the missing-pointerup cleanup cancel the pending click.

Shape hover and pointerdown share the empty-container / center-snap / Alt
eligibility via getTextCreationContainerAtPosition, and the hover indicators
are cleared when deferred creation starts (stale outline under a locked tool
at 200% zoom).

Also: a direct collision-cache regression for labels of moved arrows, exact
width/position assertions for drag creation across mouse/pen/touch,
labelPosition coverage at 25/50/75%, and stale AppState comments fixed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): redraw the dragged text box under a locked text tool

Drag-sizing a new text mutates it in place without informing the scene, so
sceneNonce never moves and the interactive canvas — which draws the dashed
text box from the live element but memoizes on canvasNonce — kept the first
size. Unlocked drags only repainted by accident: the tool had already
reverted to selection, whose box-selection branch churns state every move.

Fold the new text's versionNonce into canvasNonce, as is already done for
framed new elements. Four rendering regressions cover successive drag widths
on empty canvas and from a container's center, locked and unlocked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(editor): extract the text tool's pointer interaction into AppTextTool

Pure move of handleTextOnPointerDown, resetTextTool, maybeStartPendingText,
maybeUpdateTextToolHighlightOnPointerMove and getTextCreationContainerAtPosition
into App.textTool.ts — only `this.` became `this.app.` and the names lost
their "Text" prefix. maybeDragNewGenericElement is now public so the pending
center-drag can re-dispatch to it. No behavior change.

Text editing itself (startTextEditing / handleTextWysiwyg and the hit
helpers) is shared with Enter, double-click and the other entry points and
stays in App.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): resolve text-tool targets once; single textToolHover state

What a text-tool click would do at a position was computed in three places
— the text/container highlight, the arrow-anchor hover and the pointerdown —
with different modifier awareness, and shown through three AppState fields
(elementsToHighlight, suggestedBinding, hoveredArrowTextAnchor) cleared from
seven sites. The affordance could promise what the click wouldn't deliver:

- pressing or releasing Alt without moving left the container outline stale
  (and Ctrl/Cmd left the text box stale)
- Alt-hovering an empty arrow's midpoint advertised a label while the click
  created free text
- Ctrl/Cmd hid the container outline (the renderer gates suggestedBinding on
  isBindingEnabled) while the click still bound a label
- over a shape's or arrow's label the cursor was a crosshair while the box
  promised an edit

AppTextTool.getTargetAt now answers the question once — endpoint → text →
empty centered container (unless Alt) → free — and feeds the hover, the
cursor and the pointerdown. The hover lives in one field, textToolHover
(replacing hoveredArrowTextAnchor), drawn by one renderTextToolHover branch;
it is cleared on pointerdown and when the tool is left, nowhere else. Alt and
Ctrl/Cmd keydown/keyup refresh it at the last pointer position. The scene is
hit-tested once per pointermove instead of twice.

Also drops AppArrowText's anchor-state members (the resolver covers them) and
getLabelCenter, dead since the label-center warp was removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(editor): let AppTextTool own the pending center click; explicit textElement

The deferred center click was threaded through PointerDownState.text and a
textCreation parameter that made startTextEditing — the entry point shared
with Enter, double-click, sticky-note drops and autoshape — re-derive what the
text tool's pointerdown had already established, then bail. The helper now
arms `pending` itself (like AppBucketFill), decides it on pointermove/up, and
PointerDownState loses its text slot. The affordance armed at pointerdown now
stays up until the click resolves instead of being cleared and re-shown.

startTextEditing takes an explicit `textElement` instead: the tool passes
the text it resolved, or null to create — so a text selected through the
host API can no longer hijack a text-tool click elsewhere (the Enter and
double-click paths, which leave it undefined, resolve as before). The same
selection shortcut leaves getTextBindableContainerAtPosition, which is now
purely positional; the double-click path keeps it inline.

Also: a pending container deleted mid-press (collab) now still ends the
tool's turn, and the drag threshold's choices are documented in place.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): ctrl/cmd, not alt, opts out of labeling a container with the text tool

Ctrl/cmd already turns off arrow-endpoint binding for the text tool; it now
also keeps a center click from binding a label to an empty container, so one
key means "no binding" throughout the tool, as elsewhere in the editor. It is
read off the event rather than the arrow-binding preference — a label is not
an arrow binding. Alt no longer plays a part in the text tool, so its hover
refresh hooks go (alt keeps its generic drag meaning: resize from center).
The double-click path is unchanged.

Ported from #11210.

Co-authored-by: Loric Brevet <loric.brevet@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): cancel a pending text-tool click on tool exit and pointer cancel; refresh hover and cursor when the viewport moves

Three lifecycle gaps in the text tool's hover and pending center click:

- A pending center click only checked the active tool at the next pointer
  event, so leaving the tool (Esc) and picking it again before releasing
  revived the old press: moving then created free text at the original
  position. And with no pointerup after a pointercancel (scroll, palm
  rejection), the pending click stayed armed for unrelated movement. The
  helper now has an explicit cancel, called when the tool is left and on
  pointercancel next to the bucket fill's.
  The cancel also clears the outline the armed click had set, which no
  hovering pointer would clear otherwise — including when a second finger
  turns the press into a pinch/pan (that discard goes through the same
  cancel).
- The hover was refreshed on pointer moves and modifier keys, but not when
  the viewport scrolled or zoomed under a still pointer, so an outline stayed
  on a shape that had moved away. The refresh now re-derives the scene point
  from the pointer's last viewport position, remembers the last modifiers,
  and runs from the viewport-change lifecycle.
- A modifier press changed the target without updating the cursor (an
  endpoint's pointer cursor survived Ctrl). The refresh sets the cursor from
  the same target, mirroring the pointermove path and leaving a panning or
  scrollbar gesture its own cursor.

Also fixes a stale renderer comment about the container outline's binding
gate.

Tests: tool exit and re-pick mid-press, pointercancel, wheel scroll and zoom
under a still pointer for container and text targets, cursor on modifier
press and release. Verified in Chromium as well.

Claude Fable 5.1, high effort
Claude Opus 5.5 (1M), xhigh effort (moved the snapshot update to the caret-overflow branch)
Claude Opus 5.5 (1M), xhigh effort (the cancel clears the armed outline, from the 2026-09-26 review)

* fix(editor): tear a gesture down on pointercancel; a replayed cleanup no longer counts as a click

A canceled press (the browser taking the pointer for a scroll, a pinch or
palm rejection) left the gesture's window listeners and its missing-pointerup
cleanup subscription in place. The next pointerdown then replayed the old up
handler, whose end-of-gesture revert switched the tool to selection while the
new press was being armed — so after a canceled center press with the text
tool, the next center click switched tools and created nothing.

pointercancel now runs the missing-pointerup cleanup immediately, so nothing
of the canceled gesture lies in wait. And the up handler's tool revert is
gated on a genuine pointerup: a replay by the cleanup — a second finger
landing, a pointercancel, the next press after a lost pointerup — only tears
the gesture down and leaves the tool to the press that follows, instead of
reverting it under that press.

The pointercancel regression no longer sends a synthetic mouse-up that hid
this: after the cancel the tool must still be the text tool and the next
center click must bind a label. It fails without the fix.

Claude Fable 5.1, high effort

* refactor(editor): cursor from the text-tool target alone; a hover refresh needs a hovering pointer

Follow-ups from the whole-branch simplification review (simplification.md),
narrowed with the reviewer:

- The cursor is derived from the resolved target only. The old fallback to
  the plain hit under the pointer kept a text cursor over other texts while
  editing or drag-sizing — where a click has nothing to point at — and forced
  a second hit test and a coordinate recomputation on every modifier press
  and viewport change. Both go. With a locked text tool, editing or dragging
  now shows the crosshair everywhere; hover, endpoint and label cursors are
  unchanged.
- The hover refresh takes the pointer's last viewport position only. The
  fallback to the last *scene* coordinates was reachable when a tap preceded
  any pointer movement, and after a viewport change it resolved at a position
  nothing hovers — a phantom affordance. A tap leaves nothing to refresh,
  pinned by a test that fails with the fallback in place. Nor does a finger
  ever hover: a touch's last move is where it lifted, so touch input doesn't
  count as a hovering pointer (mouse and pen still refresh). The test drives
  real touches — a tap with jitter on a text, a short drag from a center —
  instead of clearing the pointer bookkeeping by hand. The pointercancel
  test now checks the armed outline is gone right after the cancel, and a
  second-finger test covers the missing-pointerup discard.
- The explicit pending-click cancel on pointercancel stays, and its reason is
  written down: the gesture teardown that follows flushes a queued
  pointermove, which would otherwise find the click still pending and resolve
  it as a drag. Verified in Chromium by the reviewer; the test harness runs
  the throttle synchronously, so it cannot be reproduced there.
- Two duplicated test scenarios removed: the arrow-anchor hover test the
  endpoint suite already covers, and a repeated Ctrl assertion.

Claude Fable 5.1, high effort
Claude Opus 5.5 (1M), xhigh effort (touch input never hovers; tests, from the 2026-09-26 review)

* refactor(editor): the text tool refreshes its own hover and cursor; viewport navigation check lives in AppPan

App's `refreshTextToolHover` was text-tool logic kept in App only because
its cursor gate read App-private state. It moves into `AppTextTool` as
`refresh(modifiers?)`, which re-resolves the hover and sets the cursor that
goes with it, taking the place of `refreshHover`.

The gate — space held, a pan in progress, a scrollbar drag, the hand tool —
is the same check the canvas pointermove makes before touching hover or
cursor. It becomes `AppPan.isNavigating()`, used by both. The scrollbar drag
stays App's own and reaches AppPan as a dependency, like the pointer count.

No behavior change.

Claude Opus 5.5 (1M), xhigh effort

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Loric Brevet <loric.brevet@gmail.com>
2026-09-26 12:38:23 +02:00
David Luzar c12a8cd037 fix(editor): reduce cursor hint cooldown to 30s (#12174) 2026-09-26 11:19:00 +02:00
David Luzar 5feb2172d5 feat(editor): better library search + display names (#12173)
* feat(editor): delayed name tooltip on library items

- library items with a name show it in a tooltip above the item after
  the standard tooltip delay (500ms, or right away when moving over from
  another tooltip); hidden on leave and on press
- driven by pointer (not mouse) events so touch taps don't leave the
  tooltip behind, while the mouse-driven hover state (which reveals the
  checkbox on tablets) stays as is
- no per-item hooks: Tooltip exposes `showTooltip()` / `hideTooltip()`
  (plus a `position` option) for elements that can't be wrapped in
  <Tooltip>, and <Tooltip> itself now uses them
- a visible tooltip now hides once its item is removed from the DOM
  (e.g. filtered out by the library search, sidebar closed), which fires
  no pointerleave. A MutationObserver is active only while a tooltip is
  visible; a delayed tooltip whose item unmounted while pending no
  longer shows

Opus 5.5 (1M context), xhigh

* fix(editor): Escape in library search clears it instead of closing sidebar

With a search active in an undocked sidebar, Escape closed the sidebar:
the search relied on the browser's native clearing of search inputs, but
nothing stopped the event from reaching the Sidebar's document Escape
listener. Clear the search ourselves and stop the event on the input.
A second Escape (empty search) still closes the sidebar.

Also adds a test for Escape clearing the item selection without closing
the sidebar (already working).

Opus 5.5 (1M context), xhigh

* feat(editor): larger named items in library search results

- search results render in 2 columns with bigger previews and the item
  name below (ellipsified when it overflows); the whole card, name
  included, is clickable/draggable
- the name tooltip only shows when the name is truncated
- placeholders match the card size, and the grid is full width so it
  doesn't shrink to its content while only placeholders are rendered
- reserve the library scrollbar's space (scrollbar-gutter: stable) so
  the content doesn't shift when results start/stop overflowing

Opus 5.5 (1M context), xhigh
2026-09-26 11:03:47 +02:00
David LuzarandGitUserVivek 5db42c3ddb feat(editor): zen mode polish, hide toolbar hints preference & delayed canvas tooltips (#12165)
* feat(editor): preference to hide toolbar hints

Add a "Show hints" checkbox to the Preferences menu, backed by a new
browser-persisted `appState.showHints` (default true). When off,
HintViewer renders nothing. Also exposed as `Preferences.ToggleShowHints`
for hosts composing their own preferences list.

Opus 5.5 (1M context), low

* fix(editor): keep main menu open when toggling view mode

Entering view mode ran terminateActiveInteraction, which reset
`openMenu` and closed the main menu the user had just toggled view mode
from (Preferences submenu). The main menu stays available in view mode,
so only close it when interaction gets disabled.

Opus 5.5 (1M context), low

* feat(editor): chrome-less canvas controls & in-place fades in zen mode

- UI hidden in zen mode fades out in place (opacity + visibility) instead
  of sliding off the viewport, and is non-interactive from the start of
  the fade
- visible canvas controls (toolbar, main menu trigger, zoom, scroll back
  to content, exit zen mode) drop their background/border/shadow;
  hover/active states still apply, popups & dialogs are unaffected
- only the zoom level stays visible out of the zoom controls
- chrome fades are applied only while toggling (data-zen-mode-transition
  on the container, set before the DOM update) so hover transitions stay
  instant
- single ZEN_MODE_TRANSITION_DURATION, exposed to CSS as
  --zen-mode-transition-duration
- zoom & undo/redo button groups paint a single background (on the
  group) instead of one per button

Opus 5.5 (1M context), low

* feat(editor): shift zoom level into the zoom-out slot in zen mode

With the zoom +/- buttons hidden in zen mode, slide the zoom level over
to the left edge of the group. The shift is applied to the Tooltip
wrapper (new `className` prop) rather than the button, so the tooltip
(positioned off the wrapper) moves along.

Also give the reset-zoom button its own hover background: unlike the
+/- buttons it has no .ToolIcon__icon, and lost its (incidental) hover
once the per-button backgrounds were removed.

Opus 5.5 (1M context), low

* feat(editor): delayed tooltips on canvas buttons

- Tooltip gets a `delay` prop: shows after 500ms, or right away if a
  tooltip was visible within the last 300ms (moving across adjacent
  buttons); passing over a button quickly doesn't flash one
- enable it for the zoom, undo/redo & help buttons
- zoom +/- use our Tooltip instead of the native `title`, and the reset
  zoom button drops its native `title` (it was showing both)
- zen mode now hides the zoom +/- tooltip wrappers rather than the
  buttons, so the invisible wrappers don't show tooltips

Opus 5.5 (1M context), low

Co-authored-by: GitUserVivek <jagtapvs9832@gmail.com>
2026-09-25 00:42:59 +02:00
David Luzar e40d1cba95 feat(editor): compact styles panel (#12163)
* fix(editor): align buttons inflating styles panel intrinsic width

The flex-wrap row break (zero-height flex-basis: 100% spacer) only breaks
the line at layout time; for intrinsic sizing both rows were measured as
a single line. Render the two rows as separate flex rows instead.

Opus 5.5 (1M context), low

* fix(editor): position range slider value label and track via CSS

The label position and filled track were computed in JS from the input's
offsetWidth, and only when the value changed, so they went stale when the
container resized. Drive both from a --range-progress CSS variable, and
drop the UA default inline margin that offset the track from the label.

Opus 5.5 (1M context), low

* feat(editor): compact styles panel

Styles panel buttons are 32px regardless of screen size, rows are closer
together, top-pick swatches are packed tighter, and the panel sizes to its
content (up to the previous fixed width).

Opus 5.5 (1M context), low

* reduce large icon size on 1920px+ screens

* fix color picker border color on mobile
2026-09-24 22:18:52 +02:00
David LuzarandMark Tolmacs 1118751f3e feat(editor): customizable font picker top picks (#12160)
* feat(editor): customizable font picker top picks

Font families can be dragged from the font picker list onto the top-picks
strip to replace a pick, the strip can be reordered by dragging, and
right-clicking it resets to the default picks — same as the color picker
top picks. The drag ghost is only the font's icon (on a strip-button tile).

The color picker's drag & drop is generalized into
components/TopPicksDnD (value-agnostic hook, reorder preview, drop outline,
reset context menu, shared styles), with thin per-picker adapters
(colorTopPicksDnD / fontTopPicksDnD) supplying value equality and the
ghost visual.

Custom picks are kept in `appState.fontTopPicks` (browser-persisted only,
kept on canvas clear); restore drops unknown/internal/fallback families,
dedupes, and caps at the strip's 3 slots.

Claude Opus 5.5 (1M context), reasoning effort: 40

* feat(editor): reset link in top picks tips

The "drag to pin" tip in the font picker and color picker popups now ends
with a plain-text "Reset" link while that picker's top picks are
customized, restoring the defaults without leaving the popup.

Shared as TopPicksTip (tip text styles merged into it). The link never
takes focus on click and hands it back to the picker when activated by
keyboard, since it unmounts on reset; Enter/Space are kept from the
pickers' key handlers.

Claude Opus 5.5 (1M context), reasoning effort: 15

* fix(editor): don't clip or overlap font list item focus/pressed rings

The font list scrolls (clipping outer box-shadows at its edges) and each
item paints over the previous one's bottom edge, so the focus-visible and
:active rings are drawn inset there. Pressing and holding items became
common with dragging fonts onto the top picks.

Claude Opus 5.5 (1M context), reasoning effort: 15

* fix(editor): cancelled top picks drag flying to viewport top-left

The ghost (and its home rect) was built on drag activation, by which time
the source could have been re-rendered away — the font list re-renders
on hover — and a detached element measures as a zero rect. Build and
measure it on pointerdown instead, while the source is mounted.

Claude Opus 5.5 (1M context), reasoning effort: 15

* fix: Always show 3 slots to reorder

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

* fix: Cleanup

Inconsistent state updates. Font picks go through useExcalidrawSetAppState directly, while color picks go through the action's updateData.

Duplicate icons in the strip. Virgil and Excalifont both use FreedrawIcon, so pinning both shows two identical tiles that can only be told apart by their tooltips.

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

* fix: remove useMemo

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

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-09-24 15:06:17 +02:00
David Luzar dc2c16d9e2 fix(editor): Binding follow-ups to #10753 (#12156)
* fix: Midpoint highlight color in dark mode

The highlighted binding midpoint hardcoded rgba(3, 93, 161) for dark mode,
the pre-filter color from when the interactive canvas was inverted by a
CSS filter. The canvas now draws post-filter colors directly, so it
rendered dark blue. Use BINDING_HIGHLIGHT_RGB like the rest of the binding
highlight.

Claude Opus 5.5 (1M context), high reasoning effort

* fix: Show binding midpoints like before the hit test rework

The binding highlight drew all four midpoints for any suggested binding,
including simple arrows with the cursor deep inside the element. Restore
the previous visibility: simple arrows show no midpoints from inside, and
from outside only the one nearest the cursor once it gets near; elbow
arrows show all of them. The highlighted midpoint still comes from the
suggested binding, so it sits where the arrow actually snaps.

Claude Opus 5.5 (1M context), high reasoning effort

* fix: Images and locked opaque elements occlude binding

Only filled shapes hid the elements behind them from binding. Images now
count as opaque, and locked opaque elements hide what is behind them too
(they still can't be bound to). Frames are future-proofed: once they get
a background, an opaque frame hides the non-children behind it without
ending the search, as its children sit just below it in z-order.

Claude Opus 5.5 (1M context), high reasoning effort

* refactor: Add DEFAULT_ZOOM for computations without an editor zoom

Replace the scattered { value: 1 } as Zoom / AppState["zoom"] casts used
as a zoom fallback (elbow arrow routing, transform, element rendering,
binding repair on restore, tests) with a shared frozen constant.

Claude Opus 5.5 (1M context), high reasoning effort
2026-09-24 13:06:56 +02:00
Márk Tolmácsanddwelle 4850bf336f fix(editor): Binding hit test based on distance (#10753)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-09-23 22:10:02 +02:00
Márk Tolmácsanddwelle 2b9da96100 fix(editor): Freedraw hit test precision (#11550)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-09-22 22:02:13 +02:00
Márk Tolmács 31df3e6ef2 fix(editor): Tab convert bound arrow update (#12143)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-09-22 10:26:19 +02:00
David Luzar 97c68dd371 refactor(editor): split out duplication logic into App.duplicate.ts (#12125) 2026-09-19 11:53:31 +02:00
David Luzar 14e1c614d4 feat(packages/excalidraw): support onDuplicate replacements, vetoes and lookups (#12124)
feat(packages/excalidraw): make `onDuplicate` changes stick, support veto & lookups

The editor kept working with the duplicates it created after calling
`props.onDuplicate`, so a host returning new objects for them detached
them from the frame assignment and bound text redraw on paste, and from
the alt-drag handover.

- Add `reconcileDuplicatedElements()` and use it in all three duplication
  paths (paste/library insert, duplicate action, alt-drag). Elements the
  host returns for duplicates are shallow-merged into the editor's own
  objects (omitted properties are kept), invalidating what may have been
  cached for them by then (shape, bounds). Other returned elements keep
  the replace semantics.
- Omitting a duplicate (or returning it as deleted) vetoes it. So does
  returning `false` for the duplication as a whole. Bound text goes with
  its vetoed container, and references to vetoed duplicates are cleared
  from the remaining ones (as in `fixDuplicatedBindingsAfterDuplication`).
  If no duplicate remains the duplication is cancelled: paste and the
  action do nothing, alt-drag keeps moving the originals. On a partial
  veto during alt-drag the vetoed originals are left behind.
- On paste, resolve the target frame (and the z-order under it) before the
  callback, so that the host sees the duplicates the way they end up in
  the scene, and a vetoed duplicate isn't brought back.
- Pass lookups covering just the participating elements as a third
  argument: `duplicateElements`, `originalElements`, `origIdToDuplicateId`
  and `duplicateIdToOrigId`.

Claude Fable 5.1
2026-09-19 10:00:47 +02:00
David Luzar c0ad61c674 feat(editor): rightclick to pan canvas (#12110)
* refactor(editor): move the drag-pan out of App.tsx into App.pan.ts

`handleCanvasPanUsingWheelOrSpaceDrag` — gating, the Linux middle-click
paste guard, the throttled move and the teardown — moves as-is into an
`AppPan` helper (`app.pan`), in the shape of `App.cursor` and
`App.viewport`. The session state it ran on was module-level in App.tsx
(`isHoldingSpace`, `isPanning`, `flushPanMove`), shared by every `App`
instance in the page: with two editors mounted, a wheel-button pan in one
made the other ignore wheel input. It is instance state now — `spaceHeld`,
`active`, the pending move flush and the teardown — behind `start`,
`isActive`, `flushMove`, `end` and the space accessors. Only the pointer
count is injected; `gesture` stays where it is.

`AppWheel` asks `app.pan` directly instead of taking those two callbacks
as dependencies, the wysiwyg's wheel-button pan calls `app.pan.start`,
and `maybeCleanupAfterMissingPointerUp` ends the pan explicitly, which
leaves `lastPointerUp` to the scrollbar drag alone. `savePointer` becomes
public for the teardown. No behavior change.

Claude Fable 5.1, high effort

* feat(editor): pan by dragging with the secondary mouse button

A secondary-button press starts a pan session like the wheel button
does, except that it stays a right-click until the pointer travels past
the drag threshold: nothing moves and the cursor stays put before that;
past it the canvas follows the pointer (from that point, the threshold
distance is not caught up) and no menu opens. Released before the
threshold, the session opens the context menu itself at the release
point, through the same `openContextMenu` the `contextmenu` handler now
delegates to.

That handler ignores the platform's `contextmenu` event while a
secondary-button session is active — macOS and Linux fire it on
mousedown, before a drag can be told from a click — and once after the
session ends if it has not fired yet, which is Windows firing it on
mouseup. Touch long-press, pen barrel taps and keyboard-triggered menus
never go through a session, so they open as before. On macOS and Linux
the menu now opens on release rather than on press.

View mode already let any button pan; a right-click there opens the
view-mode menu on release the same way. With interaction disabled the
drag still pans (when navigation is enabled) and no menu opens.

Tests: the threshold, the menu on release, both platform orders of the
native event, no menu after a drag, element selection under the pointer,
and the non-interactive editor.

Claude Fable 5.1, high effort

* fix(editor): let the contextmenu after mouseup open the menu for a right-click, as before

The secondary-button session opened the menu on release everywhere. On
Windows the platform's `contextmenu` fires after mouseup, by which time
our menu was rendered under the pointer; the event then targeted the
popover instead of the canvas, the canvas handler that prevented the
native menu never ran, and the native menu opened on top of ours.

The session now opens the menu on release only where the platform's
`contextmenu` already came with the press (macOS, Linux) and was
swallowed because a drag could not be told from a click yet. Where it
follows the release, it opens the menu itself, exactly as before this
branch, and lands on the canvas as there is nothing under the pointer.
After a drag that event is swallowed once, as it was.

Tests: after mouseup, nothing is open at release and the platform's event
opens the menu; the mousedown-order and drag cases are unchanged.

Claude Fable 5.1, high effort

* reintroduce: ctrl+wheel should always zoom for both input devices

* fix(editor): lower right-button pan threshold to 5px

* fix(editor): broadcast the final pointer position after panning

* fix(editor): suppress cursor broadcasts while panning
2026-09-16 19:50:39 +02:00
David Luzar a918648012 feat(packages/excalidraw): add renderOverrides to allow overriding of element rendering (#12100)
* feat(packages/excalidraw): add renderOverrides to allow overriding rendering of elements

* refactor(renderer): centralize overrides and inherit label offsets

* fix(renderer): preserve frame-name editing during visual overrides

* perf(renderer): limit override culling to translated elements

* fix(renderer): keep frame-title hit bounds at document positions

* fix(renderer): skip clearing an already clear override snapshot

`setElementRenderOverrides(null)` (or an empty map) on an editor whose
snapshot is already empty no longer copies, derives offsets or schedules a
render. Every other submission still repaints, equivalent ones included:
a value comparison would cost about as much as the copy it saves on every
changing frame, so hosts are told to submit only on change and to dedupe
at their own level, where the snapshot's structure is known.

Fable 5.1, medium reasoning

* refactor(renderer): drop two override edge cases

Culling no longer keeps an off-screen container alive because its bound
label alone would be in view: the label follows its container, as it does
for document culling, and the case needed a label wider than its (short
arrow) container at the viewport edge during a translation.

Frame clipping no longer tracks whether an element carries an explicit
offset entry (`hasPositionOverride`); a non-zero offset is what matters.
An explicit zero offset leaves the element at rest, where the ordinary
overlap check already decides correctly.

Fable 5.1, medium reasoning

* perf(renderer): leave bound-label entries out of override culling

A bound label is drawn with its container and its own entry in the visible
list is skipped by the static renderer, so override culling has no reason
to adjust that entry. Doing so made a visible container whose label is
document-off-screen (a long arrow with its label past the viewport) report
the label as newly visible on every translation, which rebuilt the visible
set from the whole scene each frame. Translated elements now update only
their own membership; the label lookup per translated element goes too.

Fable 5.1, medium reasoning
2026-09-15 14:12:53 +02:00
David Luzar 4e758db17e feat(editor): wheel-button zoom and "zoom with scroll wheel" preference (#12099)
* refactor(editor): move wheel handling out of App.tsx into App.wheel.ts

The wheel handler (pan, ctrl/cmd zoom, shift horizontal pan) and the
non-interactive browser-zoom guard move as-is into a new `AppWheel`
helper (`app.wheel`), in the same shape as `App.cursor` / `App.viewport`.
The zoom math becomes a private `zoomBy(deltaY)` so the upcoming wheel
features (wheel-button zoom, zoom-with-scroll-wheel preference) only add
branches to the dispatch instead of another copy of it.

`isPanning` is module state in App.tsx, so it is handed in as a
dependency the way `AppViewport` gets `isGestureActive`; the debounced
`shouldCacheIgnoreZoom` reset is shared with the touch pinch handler and
becomes public. No behavior change.

Claude Fable 5.1, high effort

* feat(editor): zoom by scrolling with the wheel button held down

Scrolling while the wheel (middle) mouse button is held zooms the canvas
around the pointer, exactly like ctrl/cmd+wheel. The button is what
starts a drag-pan and the wheel sits under the same finger, so a pan can
be zoomed one-handed without reaching for a modifier. It wins over the
modifiers (shift included) and over the drag-pan's own wheel gate, which
until now swallowed all wheel input mid-pan. It is read off
`event.buttons`, so no extra state is tracked.

Zooming mid-pan exposed three ways the point grabbed by the pan could
drift from under the cursor, compounding with every tick while the
pointer moves between zooms:

- The drag-pan wrote its scroll as a plain object computed from
  `this.state`. Under React 19 a wheel zoom is flushed asynchronously,
  and the pan's animation frame runs before that flush, so the pan
  overwrote the zoom's anchoring scroll with a pre-zoom value — the zoom
  then happened around the viewport origin instead of the cursor. The pan
  is now a state updater.
- A pointer move still waiting for its frame landed after the zoom, which
  had anchored on a viewport that didn't include it. The wheel handler
  flushes the pending move first.
- The zoom updater read `viewport.lastPosition` when React evaluated it,
  by which time the pointer had moved on; it also started from
  `app.state.zoom`, so ticks queued before a flush collapsed into one.
  Both are now taken at wheel time / from the updater's own state.

Tests: plain wheel pans, shift pans horizontally and ctrl/cmd zooms
(pinning the baseline); with the button held, scrolling zooms in and out
regardless of shift; during a wheel-button drag-pan, plain wheel input is
still swallowed while wheel-button scrolling zooms, and panning is back
after release. Two regression tests pin the grabbed point under the
cursor: one batches the zoom with a pan landing in the same React flush,
one holds the pan's frame back until after the wheel fires (through a
file-level `withBatchedUpdatesThrottled` mock, since the test setup makes
the throttle synchronous). Both fail on the previous code by 11.8 and
2.7 scene units respectively.

Claude Fable 5.1, high effort

* feat(editor): "zoom with scroll wheel" preference

Adds `appState.zoomWithScrollWheel` (default `false`, persisted to
browser storage like the other preferences), for mouse users who would
rather scroll to zoom: a plain wheel zooms around the pointer, and
ctrl/cmd+wheel pans vertically instead — the two roles swap, the way
tldraw's wheel-behavior preference does it. Shift+wheel keeps panning
horizontally, and the wheel-button zoom keeps winning over everything.
One consequence to be aware of: a trackpad pinch arrives as ctrl+wheel,
so with the preference on it pans.

The toggle lives in the main menu's preferences submenu ("Zoom with
scroll wheel", right under "Select on"), exposed as
`MainMenu.DefaultItems.Preferences.ToggleZoomWithScrollWheel` and backed
by the `zoomWithScrollWheel` action, allowed in view mode since it only
concerns navigation.

Tests: the preference is off by default and its action toggles it; with
it on, plain wheel zooms while ctrl and cmd wheel pan vertically, shift
still pans horizontally and the wheel button still zooms. Snapshots pick
up the new default.

Claude Fable 5.1, high effort

* perf(editor): keep the zoom bitmap cache across pan frames during a wheel zoom

`viewport.translate` cleared `shouldCacheIgnoreZoom` on every call. Wheel
zoom sets the flag so elements draw from zoom-scaled cached bitmaps, but
a drag-pan frame landing between two ticks cleared it again, and the
static renderer then re-rasterized every element at a zoom the next tick
was about to change. Wheel-button zoom-while-panning interleaves the two
by design (the old `isPanning` gate made the combination impossible with
a mouse); trackpad pan-plus-pinch has always alternated plain and ctrl
wheel events the same way.

Measured with 1,200 rough shapes on screen, a 12-tick zoom with a small
pointer move on every frame: 10,434 / 8,774 bitmaps regenerated in two
runs, longest frame 242 / 283 ms, gesture 2.9 / 2.3 s. With the pointer
still: 0 bitmaps, 9 ms, 0.39 s.

The reset now happens only when a gesture interrupts an animated
`setViewport`, whose frames set the flag and whose completion handler
would have cleared it. Everywhere else the flag is left to the zoom paths
and their 300 ms debounce. After the change the moving-pointer run
regenerates 0 bitmaps, longest frame 25–33 ms, gesture 0.7–0.8 s. A
plain wheel pan within 300 ms of a zoom now draws from scaled bitmaps
until the debounce fires instead of re-rasterizing on its first frame.

Tests: pan frames between wheel-button zoom ticks keep the flag until the
gesture is idle (fails on the previous code); the interrupted-animation
test also pins the reset, with a 60 s animation and a polling assertion
so a saturated machine cannot finish the animation before the check.

Claude Fable 5.1, high effort

* fix(editor): handle wheel input over a frame label once

The frame label had a React `onWheel` handler while also sitting inside
the editor container, whose native wheel listener already accepts
`.frame-name` targets. Both received the same event, so every wheel tick
over a label panned or zoomed twice (measured: −140 px of scroll for a
tick that moves the canvas −70 px elsewhere). React registers `wheel`
as passive, so the React path could not even preventDefault. Predates
this branch; with the zoom-with-scroll-wheel preference it became a
two-step zoom per tick over labels.

Drop the prop. With no React path left, the wheel handler's event type
narrows to the native `WheelEvent`.

Test: a wheel event on a frame label pans one step; it fails on the
previous code by exactly one extra step.

Claude Fable 5.1, high effort

* refactor(editor): model the wheel preference as an input device

    `appState.zoomWithScrollWheel` becomes `appState.inputDevice:
    "auto" | "mouse" | "trackpad"`.

    Claude Fable 5.1, high effort

* fix(editor): decide whether a translate zoomed against the state it applies to

`viewport.translate` captured `prevZoom` from `app.state` before queuing
its update, then a second updater compared it with the zoom it found in
`prevState` to tell a zoom apart from a pan — a zoom drops the rubberband
overscroll to 0, a pan keeps it. With a zoom queued in the same React
flush, `app.state` still holds the pre-zoom value, so a pan applied on
top of that zoom read as a zoom of its own and hard-clamped the
overscroll away in one frame instead of easing it back. Two wheel events
in one flush could already do this; the wheel-button zoom makes it
routine, as the handler applies the pending pan move on every tick.

The two `setState` calls become one updater: the update is applied to
`prevState`, the zoom comparison is between that state and the result,
and the constrained viewport is merged into it. No captured state, no
ordering to get wrong.

Test: rubberbanded past the box edge, a ctrl+wheel zoom tick and a wheel
pan batched into one flush keep the overscroll; on the previous code it
lands exactly on the edge.

Claude Fable 5.1, high effort

* fix(editor): pan sideways on a horizontal-only wheel; no-op zoom ticks at the limit

In mouse mode a wheel event with only a horizontal delta — a tilt wheel,
a sideways two-finger scroll — took the zoom branch with `deltaY` 0: no
viewport change, but every such event flipped the bitmap-cache flag,
cancelled collab follow and re-armed the cache debounce, and the sideways
scroll itself went nowhere. A wheel with no vertical delta never zooms
now; it falls through to the pan, which moves the canvas by `deltaX`. A
wheel with no delta at all is ignored outright.

The zoom updater also returns early when the tick lands on the zoom it
started from — at MIN or MAX zoom — instead of setting the cache flag and
redrawing the scene on every tick. This predates the branch. To make
that a real no-op, `viewport.translate` treats a `null` update as nothing
to do rather than re-constraining and re-setting the whole state.

Tests: in mouse mode a horizontal-only wheel pans sideways and leaves the
cache flag alone; a zoom-out tick at MIN_ZOOM changes nothing. Both fail
on the previous code.

Claude Fable 5.1, high effort
2026-09-14 16:17:41 +02:00
David LuzarandClaude Fable 5.1 afa3a653fc feat(editor): sticky notes (#12064)
* feat(editor): sticky notes

* clamp sticky note font size

* [dragInput] reject non-finite values

* chore: prettier formatting after rebase

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(editor): sticky notes cleanup (plan §7.1)

- hide `transparent` in the sticky stroke picker through master's
  `excludedColors` (#11872) instead of the branch's own
  `hiddenPaletteColorNames`; the swatch becomes a grid-preserving
  placeholder, its hotkey stays dead, and arrow-key navigation steps over
  it in the arrow's direction (unit + integration tests)
- move the text pixel-snap change out to `dwelle/text-pixel-snap`
  (705e497e2): it affects every text element at every zoom and is not
  zoom-aware, so it lands separately
- rename `STICKY_NOTE_DEFAULT_FONT_SIZE` → `STICKY_NOTE_FALLBACK_FONT_SIZE`:
  it is only the non-finite fallback; new sticky text seeds its ceiling from
  `currentItemFontSize` (decision D3)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): one sticky note layout core + resize intents (plan §7.2, 1/2)

- `getStickyNoteLayout` is the single fit/grow/position calculation (pure;
  ceiling-anchored size grid, warm-started binary search instead of the
  linear descent); `updateStickyNoteLayout` applies it to a scene and runs
  the bound-arrow pass by default (callers forward `simultaneouslyUpdated`
  or opt out when they own a pass); `relayoutStickyNotes` applies it inside
  immutable element arrays, identity- and version-preserving
- resize intents are absolute values recomputed from the gesture-start
  snapshot on every pointer-move (`getStickyNoteResizeIntent`): width-only
  gestures keep the base height, height gestures set it, proportional
  gestures (Shift on any handle, aspect-locked multi-select, Stats group)
  also scale the font ceiling, flips preserve everything; the content
  correction anchors at the held edge (n* → bottom, Alt → center,
  Shift+E/W → center). The corner pre-fit clamp and the multi-resize
  sentinel/veto are gone: one fit per pointer-move
- arrows bound to a note follow text-driven growth, and multi-resize /
  Stats group update them after the content correction, not before
- `fontSizeMax` is `number | null`; every read/write goes through the
  container-aware `getUserFontSize` / `getUserFontSizeUpdate` (a detached
  label with a stale ceiling neither shows nor keeps writing it); unbind
  clears it and returns an empty note to its base height; restore seeds or
  clears it after binding repair and normalizes a sticky label's stroke
- paste styles lays out a restyled note and its label together in a
  post-pass (no phantom-container mismatch) and reads the source ceiling
  from the copied snapshot rather than the fitted size
- `redrawTextBoundingBox` / `handleBindTextResize` delegate sticky notes up
  front (no wasted generic wrap); the `refreshTextDimensions` sticky arm and
  the wysiwyg duplicate arm are gone; `clampStickyNoteProps` becomes the
  identity-preserving `normalizeStickyNote`

Tests: content-pinned corner drag keeps the requested base, Shift+E scales
note/base/ceiling, width-only drag preserves the base of a grown note,
Shift released mid-gesture restores gesture-start values, flipping a rotated
grown note keeps base/ceiling/label angle, multi-resize syncs an empty
note's base.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): sticky note invariants in changeProperty + regression tests (plan §7.2, 2/2)

- `changeProperty` normalizes sticky notes after any property write
  (identity-preserving `normalizeStickyNote`), replacing the three per-action
  wraps in the background, sloppiness and roundness actions
- tests (new `tests/stickyNotes.test.tsx` + restore): arrows bound to a
  note follow text-driven growth while typing; binding free text seeds the
  ceiling and unbinding clears it (`null`) and returns the note to its base
  height; pasting styles copies the user's ceiling and lays out the note
  together with its label; unselected notes keep object identity through
  property actions; a transparent background write is normalized; restore
  clears a stale ceiling on unbound text and seeds ceiling + visible stroke
  on a sticky label

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): sticky note creation feel + sticky color domain (plan §7.3)

Creation
- a note starts as a 0×0 draft inserted like every other tool and is
  previewed at its true dragged size (no jump to 40px on the first move);
  a gesture under the drag threshold is a click → the default 250×250
  square centered on the pointer; a longer drag snaps to the 40×40 minimum
  only on release, growing away from the origin corner. The preview
  suppression flag and the pointer-move sticky arm are gone
- `normalizeStickyNote` splits into style (constructor + every pass) and
  geometry (finalize, restore, action passes) so a draft is legal

Colors — sticky notes are their own color domain (decision D2)
- new `currentItemStickynoteBackgroundColor` default (yellow) next to the
  stroke one; both persisted/restored non-transparent; new notes and their
  labels seed from the sticky defaults (a label takes the note's stroke)
- own top-pick slots (`colorTopPicks.stickyNoteStroke/Background`) and
  classic note picks; transparent excluded like bucket fill
- one `resolveColorTarget` (regular / sticky / mixed, incl. the text being
  edited) resolved at execution time in the stroke and background actions'
  `perform`; the panel only sends `{ color }`, so the memoized picker's
  stale closure can no longer write the wrong tool's default (A1)
- one `getColorUpdate` policy used by the actions, both eyedroppers (which
  now include bound labels, so a stroke pick colors a note's visible text —
  1.12), paste styles and bind-existing-text (a transparent label bound to
  a note takes the note's text color — A3); the per-surface forks and
  `isStickyNoteStrokeColorElement` are gone

Tests: click under the threshold, 20px drag previews at 20 and snaps to 40
with the origin corner fixed (both directions); closed-popup top pick after
a tool switch writes the sticky default; mixed selection writes both
domains; notes are created from the sticky defaults; bind normalizes a
transparent label; the keyboard eyedropper colors a note's label. App-state
snapshots gain the three new keys.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): font-aware minimum sticky note size

The 40×40 floor let a freshly placed note grow on the very first keystroke:
one line at the default 20px ceiling plus the 16px padding needs 57px.

- `STICKY_NOTE_MIN_SIZE = 75` replaces the width/height pair as the data
  floor (normalization, layout, restore)
- `getStickyNoteMinSize({ fontSize, fontFamily })` =
  max(75, round(ceiling × lineHeight) + 2 × padding) is the UI floor: the
  creation snap on release uses the current font size/family, the single
  resize floor uses the label's ceiling (constant floor for an empty note).
  Excalifont: 75 up to the L ceiling, 77 at 36px, 92 at 48px

Tests: the 20px drag now snaps to 75; a 48px ceiling yields a 92px note
that keeps its height on the first character.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(editor): sticky note regression matrix (plan §7.4) + two Stats fixes

Tests (27 new): Alt/center resize keeps the center of a content-pinned
note; proportional multi-select scales the label's ceiling, a free
multi-select of an empty note uses the requested height; a proportional
gesture returning to scale 1 restores everything; rotated growth keeps the
top edge; the fit takes ≤ 2 measurements on a warm keystroke and ≤ 12 on a
cold search from a 512 ceiling; a lowered ceiling wins over a still-fitting
old size; odd/fractional ceilings stay reachable on the ceiling-anchored
grid; the font-aware minimum size. Bound arrows follow the note on unbind,
after a content-constrained resize and after a font-size action, and are
not moved twice when resized as part of the same selection. A top pick
while editing a note's label targets the label and the sticky default; the
preview eyedropper colors a selected note's label; paste styles resolves a
since-deleted source's ceiling from the copied snapshot; a detached label
with a stale ceiling neither shows nor writes it; duplication keeps the
ceiling and base height; an empty note escaped from editing stays and is
removed by undo; Stats W keeps the base height and a group W edit scales
the ceiling; restore normalizes both sticky defaults, keeps the new
top-pick slots and refits note + label under `refreshDimensions`; unit
tests for `resolveColorTarget` (regular / sticky / mixed / editing) and the
default normalization.

Source fixes the tests surfaced:
- Stats `DragInput`: the typed-value path handed callbacks the live
  elements map as "original", so gesture-start values (a note's width, its
  label's ceiling) read as the already-applied values; it now snapshots the
  scene like the pointer-drag path does
- Stats group resize (`MultiDimension`): a group unit lists the note's
  label too and scaled it as a standalone element after the note, wiping
  the fitted size; the note's layout owns its label

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): sticky note skeleton API, tool hint, help row, docs (plan §7.5)

- `convertToExcalidrawElements` accepts `{ type: "stickynote", x, y, label?,
  ... }` (new `ValidStickyNote` skeleton member): the note is constructed
  through `newStickyNoteElement` + `normalizeStickyNoteGeometry` (250×250
  default, `baseHeight` finalized, sticky color invariants), and a label is
  bound through the sticky fit so its `fontSize` becomes the ceiling and the
  note grows only if the text overflows at the minimum size (decision D7)
- tool hint for the sticky note tool ("Click to place a note, or drag to
  size it") and the `N` shortcut row in the help dialog
- dev-docs: "Sticky Notes" section in the element-skeleton page; CHANGELOG
  entry (PR number placeholder)

Tests: skeleton defaults, invariant enforcement on given props, label
binding with the ceiling and the note's text color, growth for an
overflowing label.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor: move the seeded PRNG (mulberry32) to @excalidraw/common

Pure utility next to `randomInteger`/`reseed`; the sticky note corner
jitter imports it from there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(editor): drop the dead roughjs Drawable for sticky notes

Nothing ever generated or drew it: both renderers paint a note directly from
`getStickyNoteRenderPoints`, and every `ShapeCache` consumer is type-gated
to the rectangle family or linear elements. It also disagreed with the
rendered outline (generic corner radius, no jitter). Sticky notes now sit
with text/image/frame in the `null`-shape group, which the cache treats
as a valid value.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): sticky note creation-date footer

Every note reserves a 32px band under its label body and paints the
note's `created` date there, on canvas and in SVG export: "7 Sep" while
the year is the current one, "7 Sep 2025" otherwise, nothing for an
unknown timestamp (the band stays reserved so geometry never depends on
data availability). The label form is chosen by width bucket, never
measured, so painting stays measurement-free — server-side SVG export has
no text measurer — and an absolute date means exports never go stale.

- `STICKY_NOTE_FOOTER` and `STICKY_NOTE_BODY_INSET_Y` in @excalidraw/common;
  `getStickyNoteDateLabel` / `getStickyNoteFooter` in stickyNote.ts
- the layout, `getBoundTextMaxHeight`, the text editor and both renderers
  share the one inset; a rotated label rotates about the note center, not
  the now-asymmetric body center
- `getStickyNoteMinSize` returns `{ width, height }`; creation and resize
  clamp each axis to its own floor (a Shift drag stays square)
- tests: date label, width buckets, body height, resize floor, SVG + canvas
  export, creation minimums

Plan: sticky_notes_review_final.md §9.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): image tool into "More tools", letter shortcut badges

With the sticky tool on N between text (8) and eraser (0), the numbered
badge row no longer reads as a sequence. On desktop and tablet, move the
image tool into the "More tools" menu (first entry, still `9`,
`UIOptions.tools.image` keeps hiding it) and badge the toolbar tools
with their letter shortcuts (V R D O A L P T N E) instead of numbers.
The number keys keep working; tooltips and the help dialog are unchanged.
The mobile toolbar keeps its image slot.

- Toolbar.tsx: image item in `ExtraToolsDropdown` (trigger highlight and
  icon while active), `UIOptions` passed through for the gate
- Tools.tsx: `keyBindingLabel` prefers the letter
- tests: `UI.clickExtraTool`, image test uses it, toolbar describe in
  tool.test.tsx (menu placement, UIOptions gate, letter badges)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): a sticky note and its label share one ink

The creation-date footer paints from the note's `strokeColor`, the label
from its own, and three writes let them drift: coloring the label while
editing it (the wysiwyg clears the selection, so `changeProperty` targets
only the edited text), binding an already-colored text (only transparent
was rewritten), and paste styles from a source whose container and label
carry two colors. Red text under a black date.

The note's `strokeColor` is its ink — the seed for a new label and what
the footer paints with — and the label always mirrors it:

- `syncStickyNoteInk(elements, prevElementsMap)`: after a write, copies
  the side that changed onto the other; when both changed or the data
  drifted, the label wins (it is the text the user styled); a transparent
  label takes the note's color; identity-preserving when nothing changes
- entrances: the stroke action (after `changeProperty`, which covers edit
  mode), paste styles (before the relayout), bind text (the text's color
  wins, transparent falls back), the skeleton path (a label that sets its
  own color gives it to the note), restore (label wins over a drifted note)
- the stroke picker reads "Text color" for notes (`labels.textColor`)
- tests for the helper and for every entrance

Plan: sticky_notes_review_final.md §12.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* make footer opacity=1

* chore: fix sticky note test import order

* feat(editor): drag a tool out of the toolbar to drop a default-sized element

Press a tool button and drag onto the canvas: a default-sized element
follows the pointer as a translucent preview and is placed where the
pointer is released. Sticky notes opt in; the mechanism is generic.

`App.toolDrag.ts` owns the gesture. `DRAGGABLE_TOOLS` is the registry —
a tool provides `createElement(app, center)` (the element it drops,
centered on the pointer, from the same `currentItem*` defaults a click
uses) and an optional `onDrop` (a sticky note starts editing its label).
Adding a tool is one entry.

The preview exists only on the unit until the drop: not in the scene, the
store or the history, so collaborators never see it and Escape,
pointercancel, window blur or a release outside the canvas leave nothing
behind. It is painted by the existing `NewElementCanvas`, drawn exactly as
it will land, with the canvas itself made translucent (a CSS opacity on
the canvas — per-draw-call alpha would darken where a shadow and a fill
overlap).

The button arms on pointerdown (a new `onPointerDown` hook on the toggle
`IconButton`) and the drag starts once the pointer has moved
`DRAGGING_THRESHOLD`, so a plain click still selects the tool. Listeners
live on the owner window like the other pointer gestures. The drop is
`insertNewElement` + one store capture (one undo entry), selects the
element and switches to the preferred selection tool — whatever tool was
active (an arrow, say) must not stay armed under the dropped element or
its editor. The hit test prefers `elementFromPoint` (sees through a
touch's implicit capture on the button) and falls back to the event
target where it is missing (jsdom).

Made public on App for the unit: `store`, `startTextEditing`,
`getCurrentItemRoundness`, `getCurrentItemStrokeWidth`.

Tests: drag → preview with an untouched scene and history → drop centered
at the pointer with one history entry, the editor open and the selection
tool active, undo removes it; Escape mid-drag leaves nothing and a late
pointerup is inert; a release outside the canvas drops nothing; a plain
click still selects.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): default a text element's fontSizeMax to null

`newTextElement` left `fontSizeMax` undefined while restore normalizes it
to `null`. `Delta.distinctKeysIterator` compares with `!==`, so a
restored copy of a locally created text element reported a phantom
`fontSizeMax` change — today only in collab reconciliation deltas, which
are not undoable, but a latent source of spurious version bumps.

Normalize to `null` in the constructor, like `labelPosition`. Snapshots
change accordingly (`undefined` → `null`), including the embedded scene
payload in the SVG export snapshot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(editor): drop a duplicated scheduleCapture in the sticky note finalize

A paste error: the two calls in one tick set the same flag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): decide history visibility from the applied result

Creating a sticky note and abandoning its editor by clicking out left two
history entries: the note's creation, and one that only deleted the empty
label and dropped the note's reference to it — nothing a user can see, so
the first undo appeared to do nothing. (Escape left the same deletion
pending instead, folded into the next entry.) The text tool's own
click-create-and-abandon had the same invisible entry.

History already skips entries with no visible change, but visibility was
decided per property patch, before binding repair and layout ran: an
`isDeleted` flip or a `boundElements` edit always counted, and anything
layout moved afterwards never did.

`ElementsDelta.applyTo` now decides it from the applied result. After the
deltas, reordering, binding repair and text/arrow layout, the "before"
and "after" of every affected element are compared:

- `idsToCheck` = the changed elements plus what layout can move for them:
  a text's container and everything in `boundElements` (a container's
  label and arrows); the set is walked as it grows, so an arrow brings in
  its own label.
- Unchanged elements are shared between the incoming map and the result
  and layout mutates them in place, so their "before" is a shallow copy;
  changed elements already have a fresh instance. The whole scene is
  never copied.
- An element counts as visible only if it is not deleted and, for text,
  not empty — except an empty label carrying arrow bindings or sitting on
  an arrow, where it cuts the gap. `version`, `versionNonce`, `updated`,
  `created` are metadata and `index` is covered by the z-order check;
  `boundElements` is compared as `type:id` strings with references to
  empty labels dropped, so binding repair that rebuilds equal objects
  reads as unchanged. Everything else counts.

A dev/test-only assertion records every element's version before layout
and throws if layout bumped one outside `idsToCheck`, so a new layout
dependency fails a test instead of letting undo silently skip a real
change. For that to work the `return` had to leave the `finally` block: a
return in `finally` overrides a thrown exception, so the existing
dev/test rethrow for failures in reordering and layout never actually
propagated. Production behavior is unchanged — the catch still returns
with the flag forced to visible.

Cost, production build, median per `applyTo`: sparse changes at parity
through 50k elements; 10k visible bulk updates +13%; 10k metadata-only
updates 44 → 95 ms, because proving "nothing visible" cannot stop at the
first element — the case the old code answered wrongly. `applyTo` runs
on undo/redo and the version slider, both user-paced.

Creation and typing still undo separately, and a drop from the toolbar
still records at the drop. Tests: delta-level (empty-label deletion and
restoration on a rectangle and a sticky note ignored; a non-empty label,
an arrow's label gap, a container resized by a restored label, arrow
bindings on empty text, a label and an arrow repositioned by a container
redraw all visible; excluded or already-applied properties and a
metadata-only update invisible; snapshot restoration visible) and the
sticky note flows (Escape, click-out, font change on an empty label,
typing).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): snap sticky note placement to the grid

* perf(editor): ignore arrow bindings in sticky layout input check

* feat(editor): center a sticky note's label in the whole note, tighten the footer

A middle-aligned label was centered in the body above the creation-date
footer, so it sat visibly high — half a footer above the note's center.
It is now centered in the whole padded note (`height − 2·padding`), the
footer ignored, and only pushed up against the body's bottom once
centering would run it into the footer. The fit and the growth rule are
unchanged: the note still grows at the same label height, no vertical
budget is lost, and top and bottom alignment are as before. Rotation
already turned the label about the note's center.

The footer takes less room: its band shrinks from 32px to 20px and the
date's baseline is defined from the note's bottom edge (14px) rather than
from the padding, so the glyphs sit ~11px from the edge (was 17) with
~13px to the label body above (was 19). The vertical inset follows
(64 → 52), and with it the font-aware minimum note sizes.

Tests: a middle-aligned label is centered on the note's center at three
angles; centering is clamped only once it would overlap the footer;
existing sizes derive from the inset constant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): resize sticky notes proportionally by default

* fix(editor): preserve resize flips and text container anchors

Apply minimum sizes and proportional font scaling to dimension magnitudes, then restore their signs for shared flip geometry.

Share rotation-aware positioning for text-driven height changes and pass vertical flip state through single and multiple-element resizing.

Add regression coverage for mirrored resizing, proportional minimum sizes, and text growth after crossing the resize anchor.

* feat(editor): lift a seeded sticky note corner at high roughness

Curve one corner at roughness 2, chosen from the note seed so it stays consistent through redraws and resizes. Pull its shadow inward by half the bend while retaining the existing down-right offset.

Share the generated paper and shadow paths between Canvas and SVG rendering.

* fix(editor): share the default sticky note color with its quick pick

* refactor(editor): rename a label's `fontSizeMax` to `baseFontSize`

The field holds the font size the user picked, from which the sticky note
layout derives the fitted `fontSize`. "Max" described today's fitter (it
only shrinks); "base" names the intent and pairs with the note's
`baseHeight`, the other user-set value the layout derives from. Renamed
before the field ships, since it is persisted in scene files.

The type is now required (`number | null`): the constructor and restore
already always write it, and history deltas compare with strict
inequality, so an omitted key against `null` would record a spurious
change. Making it required lets tsc catch producers that omit it (one test
fixture did). Helpers follow: `getBaseFontSize`, `getBaseFontSizeUpdate`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): slightly strengthen sticky note edges

* feat(editor): color a sticky note's background while editing its label

While editing a note's label the styles panel targets the label, which has
no fill, so the background picker was gone and the note's color could only
be changed after leaving the editor. The picker now stays available while
editing, shows the note's fill, and a pick colors the note — text color and
background in one editing pass.

One helper, `getColorTargetElement`, decides where a color pick on an
element lands (a background pick on a note's label goes to the note); the
target resolver, the panel predicate, the panel value and the action all
use it, so the picker also gets the sticky domain (sticky picks, no
transparent, the sticky default written).

Also fixes a latent bug in the background action: with nothing selected,
`selectedElements.every(isLine…)` was vacuously true, so the action took
the polygon branch and did nothing. Unreachable before, since no background
picker was available without a selection.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix changelog

* fix snaps

* more snap

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 16:45:37 +02:00
David LuzarandClaude Fable 5.1 6574de60b3 feat(editor): snap element bitmaps, scroll and grid to whole device pixels (#12063)
* feat(editor): blit cached element bitmaps on whole device pixels

Elements are painted from cached bitmaps, blitted 1:1 with smoothing
disabled for unrotated and right-angle elements. Nearest-neighbor at a
fractional offset is then a pixel-exact copy shifted to the nearest
pixel — except at an exact half pixel, where a GPU-accelerated canvas
decides the rounding per scanline by float precision: a few rows sample
the neighboring row and glyph strokes come out doubled or broken, varying
with scroll and position. A centered label lands on a half pixel
routinely (odd free space ÷ 2), which is how it showed up on sticky notes.

Measured in Chrome: a label blitted at y = 639.5 had 3 of 82 scanlines
taken from the adjacent row; rounded to 640 it was clean.

Round the blit position to whole device pixels for those elements. The
context is scaled at the blit (canvas scale × zoom ÷ devicePixelRatio),
so the rounding goes through `context.getTransform()`: rounding the
pre-zoom value only lands on device pixels at 100% zoom. Rotated
elements are resampled regardless and are left alone; export draws
directly and is unaffected.

Tests: the static canvas's `drawImage` events, with their CTM, land on
whole device pixels for text and a rectangle at zoom 1, 1.5, 0.73 and
2.2 under fractional scroll; a rotated element keeps its offset.

Extracted from dwelle/sticky-notes-2 (originally eddb67ca0).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): draw the canvases at a whole-device-pixel scroll

Panning writes a fractional scroll (pointer delta ÷ zoom). With every
bitmap landing on its nearest device pixel, a fractional scroll means
elements with different sub-pixel phases jump on different frames — a
one-pixel wobble between neighbors, a label shifting against its box —
and one scroll value in every pixel puts every integer-positioned
element on the half-pixel boundary at once.

Round the scroll to whole device pixels at render time, in all three
renderers with the same formula so overlays sit on the content — the way
a browser scrolls a page: each element keeps its own constant sub-pixel
phase and the scene moves rigidly. The real scroll in state is untouched,
so hit-testing and DOM overlays are unaffected; the difference stays
under half a device pixel. Export is excluded (it draws vectors).

Tests: the helper rounds to device pixels and is identity when already
whole; a rectangle at integer coordinates blits on whole device pixels at
zoom 1 and 1.5 under fractional scroll, and a scroll a tenth of a pixel
further lands on the same pixels.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): draw grid lines on whole device pixels at every zoom

The grid was nudged onto the pixel grid only at 100% zoom, and only when
the scroll happened to be whole; at any other zoom its one-pixel lines
landed on fractional device pixels and rendered as two lighter pixels.

Draw each line a whole number of device pixels wide, centered to cover
them exactly (on the half pixel when the count is odd), computed from the
zoom and the canvas scale — which also makes "one pixel" one device pixel
group at devicePixelRatio 2 instead of a fractional pair. Lines thinner
than a device pixel (zoomed far out) keep their sub-pixel width: their
lightness is the point. The zoom-1-only nudge is subsumed.

Test: with grid mode on, every grid path start on the static canvas has
its across-axis coordinate on a half device pixel at zoom 1, 1.5 and 2.2
under fractional scroll.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): snap right-angle blits via the transform origin; grid width in device pixels

Two review findings.

Elements rotated by 90° or 270° enter the snapping branch (smoothing is
off for right angles too), but their transform has near-zero `a` and `d`
with the scale in `b`/`c`. Dividing by the diagonal sent the bitmap far
offscreen — a rectangle at (110.37, 120.61) vanished at both angles.
The snap now moves the transform's origin onto the rounded device
position of the blit and draws at (0, 0): rotation, mirroring and scale
stay in `a`–`d`, no inverse is needed, and the origin is an exact integer
even in the canvas's float32 matrix (mapping a rounded position back
through user space left it ~1e-5 off).

The grid's "at least one device pixel wide" test compared
`(1 / zoom) × zoom × scale` against 1 exactly; at 190% that product is
0.9999999999999999, so those lines skipped snapping and stayed blurry
while neighboring zooms were crisp. The width is now computed straight
in device pixels, `min(scale, max × zoom × scale)`, so there is no round
trip to tolerate.

Tests: rectangles at π/2 and 3π/2 blit on whole device pixels and stay
next to the element; the grid test includes 190%. The blit helper in the
test maps positions through the full matrix. The CHANGELOG bullets link
the PR.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(editor): one devicePixelRatio read for the blit, no-op padding scaling dropped

`drawElementFromCanvas` read `window.devicePixelRatio` eleven times; it
reads it once. That read stays the global one on purpose: the cached
bitmap was generated with it (`generateElementCanvas`) and the blit's
size math needs the same value, so switching this path to the owner
window's ratio is a separate change that has to include bitmap
generation. `(padding × scale) / scale` is `padding`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): snap blits only where smoothing is off, through one predicate

The snap ran during zoom gestures too, where the cached bitmaps stay at
the old scale and are resampled with smoothing on: rounding there could
only make elements twitch as the scale changes.

The smoothing switch and the snap now share `canSnapElement` — unrotated
or at a right angle, and not mid-gesture — so the two cannot drift
apart. It is eligibility, not a guarantee of a 1:1 copy: freedraw is
blitted with smoothing on and a size-capped cache is rescaled; snapping
is harmless for both.

Test: with `shouldCacheIgnoreZoom` set, the blit keeps its fractional
position.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): snap a label relative to its container, tie-safe

Each bitmap rounding its own origin lets a label shift by a pixel against
its box as a drag crosses half pixels (nearest-neighbor sampling did the
same implicitly before the explicit snap). A label under an unrotated
container now anchors to the container's rounded origin and rounds its
offset from it separately, so both land on whole pixels and their
distance never changes; every other element anchors to itself with a
zero offset, through the same code path.

The offset is formed from scene coordinates before the scroll is added,
and a tiny bias breaks an exact half-pixel tie the same way every frame.
Ties are common — any odd scene offset at 150% — and the float noise of
a fractional drag otherwise flips `Math.round` between the two neighbors:
a 200-wide box with a centered label went 92 → 91 → 92 device pixels
across drags of 0, 0.3 and 0.4.

Tests: a plain fractional offset and the half-pixel tie keep a constant
device offset across five fractional drags at 150%.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 14:39:59 +02:00
David LuzarandMárk Tolmács 854d00c31b feat(editor): track element creation timestamps (#12060)
feat(editor): element creation timestamp tracking

Co-authored-by: Márk Tolmács <mark@lazycat.hu>
2026-09-08 15:26:14 +02:00
Excalidraw Botanddwelle 0361bdd9cb chore(editor): Update translations from Crowdin (#11942)
* New translations en.json (Italian)

[ci skip]

* New translations en.json (Persian)

[ci skip]

* New translations en.json (Swedish)

[ci skip]

* New translations en.json (Slovenian)

[ci skip]

* New translations en.json (Slovenian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Thai)

[ci skip]

* Auto commit: Calculate translation coverage

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-09-07 22:32:05 +02:00
David Luzar 2b1c2a9fbf fix(editor): dismiss dropdown menus on outside clicks (#12053)
fix: dismiss dropdown menus on outside clicks
2026-09-07 17:28:20 +02:00
David LuzarandClaude Fable 5.1 f1a79b73df perf(editor): stop inverting the interactive canvas via CSS filter in dark mode (#12050)
The interactive canvas (selection outlines, transform handles, binding
highlights, collaborator cursors, snap lines, scrollbars) was still
composited through `filter: invert(93%) hue-rotate(180deg)` in dark mode
(#10578 only de-filtered the static canvas). The canvas covers the whole
viewport and repaints on nearly every pointer move, so browsers that
composite in software (Firefox/Zen on software WebRender or with hardware
acceleration disabled) paid for a full-viewport color-matrix pass per frame
and dropped from 120fps to ~40fps during marquee/drag. Light mode was
unaffected.

Colors are now mapped in JS via `applyDarkModeFilter`, like the static
canvas. Hardcoded dark-mode values are the exact post-filter equivalents of
the previous ones, so rendering is unchanged, with two intentional
exceptions:

- collaborator cursors and remote selection outlines render in the
  collaborators' actual colors (matching their avatars) instead of inverted
  ones (per the existing TODO in clients.ts)
- the lasso trail now uses the theme's `--color-selection` like the marquee
  selection, instead of a hardcoded light-theme purple that never matched in
  dark mode

`--color-selection` for the dark theme now holds the actual rendered color
(#b4b0ff); hosts overriding it no longer need to pre-invert.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 11:52:22 +02:00
214cd6e6e8 feat(editor): allow for dragging bound text along an arrow's path (#10947)
Arrow labels can be dragged along their arrow. A label's place is stored on the text element as labelPosition, a normalized arc-length parameter (0–1) over the arrow's whole path, and the label's x/y is derived from it. Because the parameter covers the whole path rather than one segment, the label keeps its place when points are dragged, when midpoints are inserted or removed, and when the arrow is resized. (An earlier iteration anchored labels to a segment; that approach is gone.)

Closes #9338.
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-09-03 17:21:13 +02:00
zsvicziananddwelle e1bb9ff8f8 feat(packages/excalidraw): support rendering into another document (#11974)
* feat: support rendering into another document

* add missed places

* tweak jsdoc

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-08-26 15:33:04 +02:00
David Luzar 4a6f4e7a32 feat(app): d2c streaming (#11960)
* support d2c streaming

* render status spinner while streaming

* enable analytics for ai
2026-08-25 16:08:37 +02:00
Excalidraw Botanddwelle e160ff7ba0 chore(editor): Update translations from Crowdin (#11815)
* New translations en.json (Romanian)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (French)

[ci skip]

* New translations en.json (Spanish)

[ci skip]

* New translations en.json (Arabic)

[ci skip]

* New translations en.json (Bulgarian)

[ci skip]

* New translations en.json (Catalan)

[ci skip]

* New translations en.json (Czech)

[ci skip]

* New translations en.json (Danish)

[ci skip]

* New translations en.json (German)

[ci skip]

* New translations en.json (Greek)

[ci skip]

* New translations en.json (Basque)

[ci skip]

* New translations en.json (Finnish)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Hungarian)

[ci skip]

* New translations en.json (Italian)

[ci skip]

* New translations en.json (Japanese)

[ci skip]

* New translations en.json (Korean)

[ci skip]

* New translations en.json (Kurdish)

[ci skip]

* New translations en.json (Lithuanian)

[ci skip]

* New translations en.json (Dutch)

[ci skip]

* New translations en.json (Punjabi)

[ci skip]

* New translations en.json (Polish)

[ci skip]

* New translations en.json (Portuguese)

[ci skip]

* New translations en.json (Russian)

[ci skip]

* New translations en.json (Slovak)

[ci skip]

* New translations en.json (Slovenian)

[ci skip]

* New translations en.json (Swedish)

[ci skip]

* New translations en.json (Turkish)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]

* New translations en.json (Chinese Traditional)

[ci skip]

* New translations en.json (Vietnamese)

[ci skip]

* New translations en.json (Galician)

[ci skip]

* New translations en.json (Portuguese, Brazilian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Persian)

[ci skip]

* New translations en.json (Khmer)

[ci skip]

* New translations en.json (Tamil)

[ci skip]

* New translations en.json (Bengali)

[ci skip]

* New translations en.json (Marathi)

[ci skip]

* New translations en.json (Thai)

[ci skip]

* New translations en.json (Norwegian Nynorsk)

[ci skip]

* New translations en.json (Kazakh)

[ci skip]

* New translations en.json (Latvian)

[ci skip]

* New translations en.json (Azerbaijani)

[ci skip]

* New translations en.json (Hindi)

[ci skip]

* New translations en.json (Burmese)

[ci skip]

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

[ci skip]

* New translations en.json (Sinhala)

[ci skip]

* New translations en.json (Uzbek)

[ci skip]

* New translations en.json (Norwegian Bokmal)

[ci skip]

* New translations en.json (Occitan)

[ci skip]

* New translations en.json (German, Switzerland)

[ci skip]

* New translations en.json (Bengali, India)

[ci skip]

* New translations en.json (Kabyle)

[ci skip]

* New translations en.json (Karakalpak)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (French)

[ci skip]

* New translations en.json (Spanish)

[ci skip]

* New translations en.json (Arabic)

[ci skip]

* New translations en.json (Bulgarian)

[ci skip]

* New translations en.json (Catalan)

[ci skip]

* New translations en.json (Czech)

[ci skip]

* New translations en.json (Danish)

[ci skip]

* New translations en.json (German)

[ci skip]

* New translations en.json (Greek)

[ci skip]

* New translations en.json (Basque)

[ci skip]

* New translations en.json (Finnish)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Hungarian)

[ci skip]

* New translations en.json (Italian)

[ci skip]

* New translations en.json (Japanese)

[ci skip]

* New translations en.json (Korean)

[ci skip]

* New translations en.json (Kurdish)

[ci skip]

* New translations en.json (Lithuanian)

[ci skip]

* New translations en.json (Dutch)

[ci skip]

* New translations en.json (Punjabi)

[ci skip]

* New translations en.json (Polish)

[ci skip]

* New translations en.json (Portuguese)

[ci skip]

* New translations en.json (Russian)

[ci skip]

* New translations en.json (Slovak)

[ci skip]

* New translations en.json (Slovenian)

[ci skip]

* New translations en.json (Swedish)

[ci skip]

* New translations en.json (Turkish)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]

* New translations en.json (Chinese Traditional)

[ci skip]

* New translations en.json (Vietnamese)

[ci skip]

* New translations en.json (Galician)

[ci skip]

* New translations en.json (Portuguese, Brazilian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Persian)

[ci skip]

* New translations en.json (Khmer)

[ci skip]

* New translations en.json (Tamil)

[ci skip]

* New translations en.json (Bengali)

[ci skip]

* New translations en.json (Marathi)

[ci skip]

* New translations en.json (Thai)

[ci skip]

* New translations en.json (Norwegian Nynorsk)

[ci skip]

* New translations en.json (Kazakh)

[ci skip]

* New translations en.json (Latvian)

[ci skip]

* New translations en.json (Azerbaijani)

[ci skip]

* New translations en.json (Hindi)

[ci skip]

* New translations en.json (Burmese)

[ci skip]

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

[ci skip]

* New translations en.json (Sinhala)

[ci skip]

* New translations en.json (Uzbek)

[ci skip]

* New translations en.json (Norwegian Bokmal)

[ci skip]

* New translations en.json (Occitan)

[ci skip]

* New translations en.json (German, Switzerland)

[ci skip]

* New translations en.json (Bengali, India)

[ci skip]

* New translations en.json (Kabyle)

[ci skip]

* New translations en.json (Karakalpak)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (French)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (French)

[ci skip]

* New translations en.json (Spanish)

[ci skip]

* New translations en.json (Arabic)

[ci skip]

* New translations en.json (Bulgarian)

[ci skip]

* New translations en.json (Catalan)

[ci skip]

* New translations en.json (Czech)

[ci skip]

* New translations en.json (Danish)

[ci skip]

* New translations en.json (German)

[ci skip]

* New translations en.json (Greek)

[ci skip]

* New translations en.json (Basque)

[ci skip]

* New translations en.json (Finnish)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Hungarian)

[ci skip]

* New translations en.json (Italian)

[ci skip]

* New translations en.json (Japanese)

[ci skip]

* New translations en.json (Korean)

[ci skip]

* New translations en.json (Kurdish)

[ci skip]

* New translations en.json (Lithuanian)

[ci skip]

* New translations en.json (Dutch)

[ci skip]

* New translations en.json (Punjabi)

[ci skip]

* New translations en.json (Polish)

[ci skip]

* New translations en.json (Portuguese)

[ci skip]

* New translations en.json (Russian)

[ci skip]

* New translations en.json (Slovak)

[ci skip]

* New translations en.json (Slovenian)

[ci skip]

* New translations en.json (Swedish)

[ci skip]

* New translations en.json (Turkish)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]

* New translations en.json (Chinese Traditional)

[ci skip]

* New translations en.json (Vietnamese)

[ci skip]

* New translations en.json (Galician)

[ci skip]

* New translations en.json (Portuguese, Brazilian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Persian)

[ci skip]

* New translations en.json (Khmer)

[ci skip]

* New translations en.json (Tamil)

[ci skip]

* New translations en.json (Bengali)

[ci skip]

* New translations en.json (Marathi)

[ci skip]

* New translations en.json (Thai)

[ci skip]

* New translations en.json (Norwegian Nynorsk)

[ci skip]

* New translations en.json (Kazakh)

[ci skip]

* New translations en.json (Latvian)

[ci skip]

* New translations en.json (Azerbaijani)

[ci skip]

* New translations en.json (Hindi)

[ci skip]

* New translations en.json (Burmese)

[ci skip]

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

[ci skip]

* New translations en.json (Sinhala)

[ci skip]

* New translations en.json (Uzbek)

[ci skip]

* New translations en.json (Norwegian Bokmal)

[ci skip]

* New translations en.json (Occitan)

[ci skip]

* New translations en.json (German, Switzerland)

[ci skip]

* New translations en.json (Bengali, India)

[ci skip]

* New translations en.json (Kabyle)

[ci skip]

* New translations en.json (Karakalpak)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (Italian)

[ci skip]

* New translations en.json (Chinese Traditional)

[ci skip]

* New translations en.json (Japanese)

[ci skip]

* New translations en.json (Japanese)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (French)

[ci skip]

* New translations en.json (Spanish)

[ci skip]

* New translations en.json (Arabic)

[ci skip]

* New translations en.json (Bulgarian)

[ci skip]

* New translations en.json (Catalan)

[ci skip]

* New translations en.json (Czech)

[ci skip]

* New translations en.json (Danish)

[ci skip]

* New translations en.json (German)

[ci skip]

* New translations en.json (Greek)

[ci skip]

* New translations en.json (Basque)

[ci skip]

* New translations en.json (Finnish)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Hungarian)

[ci skip]

* New translations en.json (Italian)

[ci skip]

* New translations en.json (Japanese)

[ci skip]

* New translations en.json (Korean)

[ci skip]

* New translations en.json (Kurdish)

[ci skip]

* New translations en.json (Lithuanian)

[ci skip]

* New translations en.json (Dutch)

[ci skip]

* New translations en.json (Punjabi)

[ci skip]

* New translations en.json (Polish)

[ci skip]

* New translations en.json (Portuguese)

[ci skip]

* New translations en.json (Russian)

[ci skip]

* New translations en.json (Slovak)

[ci skip]

* New translations en.json (Slovenian)

[ci skip]

* New translations en.json (Swedish)

[ci skip]

* New translations en.json (Turkish)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]

* New translations en.json (Chinese Traditional)

[ci skip]

* New translations en.json (Vietnamese)

[ci skip]

* New translations en.json (Galician)

[ci skip]

* New translations en.json (Portuguese, Brazilian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Persian)

[ci skip]

* New translations en.json (Khmer)

[ci skip]

* New translations en.json (Tamil)

[ci skip]

* New translations en.json (Bengali)

[ci skip]

* New translations en.json (Marathi)

[ci skip]

* New translations en.json (Thai)

[ci skip]

* New translations en.json (Norwegian Nynorsk)

[ci skip]

* New translations en.json (Kazakh)

[ci skip]

* New translations en.json (Latvian)

[ci skip]

* New translations en.json (Azerbaijani)

[ci skip]

* New translations en.json (Hindi)

[ci skip]

* New translations en.json (Burmese)

[ci skip]

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

[ci skip]

* New translations en.json (Sinhala)

[ci skip]

* New translations en.json (Uzbek)

[ci skip]

* New translations en.json (Norwegian Bokmal)

[ci skip]

* New translations en.json (Occitan)

[ci skip]

* New translations en.json (German, Switzerland)

[ci skip]

* New translations en.json (Bengali, India)

[ci skip]

* New translations en.json (Kabyle)

[ci skip]

* New translations en.json (Karakalpak)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* Auto commit: Calculate translation coverage

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-08-16 17:30:01 +02:00
Vishnu RkandVishRk 884225b1c7 fix(app): localize hardcoded "Sign up"/"Sign in" menu labels (#11893)
fix(localization): update signup and signin labels for localization

Co-authored-by: VishRk <VishRk@users.noreply.github.com>
2026-08-16 13:20:15 +00:00
David Luzar abeeaeba21 feat(editor): customizing color top picks (#11872) 2026-08-11 15:59:35 +02:00
JayeshandMark Tolmacs c5a50d223f fix(editor): respect boxSelectionMode ('contain' vs 'overlap') in lasso tool (#11862)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-08-10 13:09:43 +02:00
David Luzarandrafa cf212b2f33 feat(editor): eyedropper fixes and improvements (#11859)
Co-authored-by: rafa <rafaondjango@gmail.com>
2026-08-08 23:17:26 +02:00
David Luzar 4872083c04 feat(editor): bucketfill cursor + eyedropper support (#11849) 2026-08-07 14:07:53 +02:00
David Luzar ea74787447 fix(app): remove ViewportStatusFrame debug (#11848) 2026-08-07 12:53:21 +02:00
David Luzar 219571a718 fix(editor): viewportportStatusFrame UI fixes (#11841) 2026-08-06 22:01:48 +02:00
David Luzar e4ab626739 feat(packages/excalidraw): add props.className (#11839) 2026-08-05 20:10:18 +02:00
David Luzar 1e22618ab2 fix(packages/excalidraw): viewportStatusFrame label offset on mobile (#11840) 2026-08-05 20:00:18 +02:00
David Luzar 74789584be feat(packages/excalidraw): support ViewportStatusFrame label onClick (#11838) 2026-08-05 18:28:52 +02:00
David Luzar e33a1c0e85 feat(packages/excalidraw): do not dedupe collaborators (#11835) 2026-08-05 14:20:18 +02:00
David Luzar 5e375b4331 fix(editor): do not flicker bbox when drag-creating line midpoint (#11834) 2026-08-05 10:52:10 +02:00
David LuzarandTamas Lakatos 85270fcc8d feat(packages/excalidraw): ViewportStatusFrame & factor out user-follow state (#11819)
Co-authored-by: Tamas Lakatos <tamas@excalidraw.com>
2026-08-05 00:01:20 +02:00
yanrin 46c42a6cb0 fix(editor): initialize selectedLinearElement after pasting arrows (#11803) 2026-08-04 23:38:15 +02:00
David Luzar c9c96f619c fix(editor): normalize container + bound text order on restore (#11827) 2026-08-04 23:32:15 +02:00
David Luzar 1da120e914 fix(editor): include custom tools in pointer capture (#11826) 2026-08-04 19:39:21 +02:00
nihaarsirikonda ab0255f21e fix(editor): prevent crash when editing arrow with bound text (#11814) 2026-08-04 16:31:37 +02:00
792ea3a06c feat(editor): bucket fill (#11799)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-08-04 16:25:16 +02:00
David Luzar f1aa8f86a9 feat(editor): disable arrowhead toggle via dblclick (#11822) 2026-08-04 16:25:00 +02:00
Excalidraw Botanddwelle 39103bd33a chore(editor): Update translations from Crowdin (#11813)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-08-03 21:48:06 +02:00
cyforkkandMark Tolmacs 786ab266ff feat(editor): Error message for invalid hex color input (#11782)
---------
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-30 13:30:20 +02:00
Adam 69d4c346cc fix(editor): prevent esc from exiting fullscreen on macos when modal open (#11789) 2026-07-30 07:47:42 +00:00
David Luzar 1acf66edab feat(editor): bind text to hovered arrow endpoint (#11777) 2026-07-28 18:12:33 +02:00
David Luzar 7c0b329576 fix(editor): increase autoresize handle threshold until it hides (#11773) 2026-07-27 23:04:03 +02:00
David Luzar b2e81e38a6 feat(editor): autoshape text + line improvements (#11752) 2026-07-24 16:10:00 +02:00
f179f7ffd2 feat(editor): draw to shape (auto-detection) (#9313)
* Initial commit

* Segments detection

* Remove aspect ratio

* Refactor

* Fix tests

* Toggle shapeSnap

* Fix linting

* debug: live preview

* Rebase fixes

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

* Bind to elements (no hover background)

* Shape closed

* Fix rad calc + rename segments

* Comments

* Fix binding highlighting + lints

* Rename to convert to shape

* fix: Binding

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

* chore: Refactor spline simplification

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

* chore: Remove unused doBoundsIntersect

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

* fix: Restore type signature

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

* feat: Propose new button shapes

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

* chore: Remove unused types

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

* chore: Icon shapes

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

* fix: Element cloning

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

* fix: Element cloning

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

* fix: Remove unused

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

* feat: Shape settings in recognition mode

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

* chore: Remove debug

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

* feat: Actions

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

* fix: Lint

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

* fix: Arrowheads for freedraw

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

* feat: Draw shape tool instead of freedraw

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

* fix: Test

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

* fix: Lint

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

* fix: Keyboard shortcut visibility

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

* feat: Unique cursor for draw shape

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

* fix: Remove selection

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

* fix: Shape selection

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

* feat: Improve shape recognition

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

* fix: Further refine shape recognition

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

* feat: Preview

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

* feat: Add hint

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

* fix: Refined shape recognition

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

* fix: Less ellipse more rectanguloid

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

* fix: Refined arrow recognition

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

* fix: Refinerecognition

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

* chore: Lower linear threshold

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

* feat: Rectangle head arrow

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

* feat: More arrowhead sizes

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

* fix: Arrow remains an arrow

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

* feat: Draw line between elements converts to arrow

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

* remove pendingDrawShapeElement

* fix: Arrow start point binding

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

* fix: Different arrow endpoint heuristic

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

* fix: Point type

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

* fix: Various small fixes

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

* fix: Avoid recompute

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

* fix: Type issues

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

* test(editor): cover the drawShape (snap-to-shape) tool

The drawShape tool had no dedicated tests, which let master's toolbar /
active-tool rewrite silently break it during the merge: actionFinalize
reverted to the selection tool after every sketch, because the drawShape
flow finalizes without a `newElement` and so failed master's new
`&& element` guard.

Covers the behavior the merge had to preserve:
- sketch recognition (rectangle, ellipse) and the too-small no-op
- the tool stays active after finalizing, so shapes can be sketched
  consecutively (regression guard for the above)
- activation via the S shortcut and the extra-tools dropdown
- the tool's own cursor

Points are fed into the trail directly rather than through pointermove,
which is rAF-throttled and would drop all but one point per frame — the
same approach lasso.test.tsx uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: Add test

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

* feat: Breakout from App.tsx

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

* fix: Shortcut is now Shift+X

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

* fix: No selecting arrows for shape recog

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

* fix: Double history

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

* feat: PCA + feature extraction recognition

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

* reduce and normalize min-size gates

* never select newElement when shape recog tool active

* simplify lifecycle & remove double finalize

* make arrows bindings orbit

* funnel though actionFinalize & fix two unrelated cleanup cases

* use crosshair cursor

* show strokeWidth

* switch icons for drawShape & extraTools

* dedupe getBoundsFromPoints

* remove unused helpers

* jsdoc cleanup

* dynamic trail size

* improve rectangle recognition

* disallow linears to deviate too much from a straight line

* put under freedraw tool on tablet/mobile

* tweak label

* rename `drawShape` -> `autoshape`

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:42:42 +02:00
Márk Tolmács e6ae6bf057 fix(editor): Diamond hit test shortcut (#11631)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-22 22:39:16 +02:00
Márk Tolmács 91b78903ab fix(editor): Dangling deleted bound text (#11733)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-22 22:37:35 +02:00
Márk Tolmács a3b90897b5 fix(editor): Lost focus point on transition to inside-inside (#10964)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-22 13:03:45 +02:00
samirananddwelle 53732f08f4 fix(editor): prevent eyedropper preview from overflowing viewport (#11722)
* fix(editor): prevent eyedropper preview from overflowing viewport

* generalize

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-07-21 22:07:15 +00:00
samiran 5cf547650b fix(editor): make Help button tooltip consistent with toolbar (#11681) 2026-07-18 00:17:39 +02:00
David Luzar a1d9b16b0d fix(editor): show scroll-back-to-content button on mobile (#11680) 2026-07-17 16:22:43 +02:00
David Luzar 65aa577e39 fix(editor): fix AnimationController scheduling race conds (#11678) 2026-07-16 20:52:06 +02:00
David Luzar 93dd51060a feat(packages/excalidraw): add props.ui.enabled.zoom/scrollBackToContent (#11677) 2026-07-16 19:50:19 +02:00
David Luzar 344878b0b5 ci(repo): skip docs & example deployments in CI (#11674) 2026-07-16 14:36:11 +00:00
David Luzar 374716304a fix(editor): improve UX around locked animations (#11671) 2026-07-16 13:30:13 +02:00
David Luzar 5ca083436d feat(packages/excalidraw): add props.activeTool and related (#11665) 2026-07-15 11:46:08 +02:00
David Luzar acb48c3f45 fix(editor): make embeddable ignore higher-z-index non-framelike elements (#11662) 2026-07-14 18:18:21 +02:00
David LuzarandClaude Fable 5 2423819ee0 feat(packages/excalidraw): interaction and UI control (#11605)
* feat(packages/excalidraw): add `props.interaction` and `props.ui`

* fix: prevent browser zoom when interaction disabled

* changelog

* clean up interactive state on disable and consolidate `viewMode` and interaction state updates

* render UI rendered to `children` even if editor-exported

* support `allowed.navigation` and `allowed.embeds/interactiveContent`

* change naming `allowed` -> `enabled`

* changelog: add PR links

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* clear flowchart sessions via consolidated `app.flowchart` after master merge

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor getters -> methods

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 16:50:53 +02:00
Márk Tolmács 5776e1cc66 fix(editor): should not select deleted bound text (#11660)
fix: Incorrect selection of deleted bound text

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-14 13:19:19 +02:00
Christopher TangonanandMark Tolmacs e9c856d262 chore(editor): streamline getSelectedElementsByGroup (#11636)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-13 19:38:52 +02:00
Márk Tolmács 082b2eeb1e fix(editor): Hit cache soundness (#11630)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-13 17:53:01 +02:00
minato32andMark Tolmacs d331bb49fc fix(editor): avoid overlap of flowchart children (#11532)
Closes #8518. Fixes the issue when adding a new child to a flowchart node that already has children (after the first creation session was committed), the new child could land on top of, or misaligned with, an existing sibling.
---------
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-13 16:52:19 +02:00
David Luzar 339d3c3733 feat(editor): toolbar / active tool rewrites (#11649) 2026-07-13 00:30:09 +02:00
Márk Tolmácsanddwelle ed7c0c1d7d chore(editor): Refactor Actions for clarity (#11632)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-11 17:22:35 +02:00
Márk Tolmácsanddwelle 3b9e1c07f5 fix(editor): Dragged arrow endpoint ignore grid and angle locks (#10972)
---------
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-07-10 11:19:44 +02:00
Christopher Tangonan adf9631997 fix(editor): keep multi-line bound text and container centered during alt resize (#11480) 2026-07-09 13:02:19 +02:00
David Luzar f790bf7abd feat(editor): tweak sidebar promo style (#11181) 2026-07-09 11:41:13 +02:00
David LuzarandDataVoyager-Aditya 063e0256f5 fix(editor): arrow label rendering fixes and perf improvements (#11637)
Co-authored-by: DataVoyager-Aditya <rajthakuraditya63@gmail.com>
2026-07-08 23:13:01 +02:00
Márk Tolmács fbff832424 fix(editor): Fix binding.test.ts type mismatch (#11633)
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-08 20:20:01 +02:00
Márk Tolmács 8462bb81fb fix(editor): Very small bindables can blow up fixedPoint -> position ratio (#11607)
---
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-08 12:20:30 +02:00
Excalidraw Botanddwelle ba0387d186 chore(editor): Update translations from Crowdin (#10731)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-07-07 22:37:30 +02:00
Augusto XavierandMark Tolmacs dd8296af18 fix(editor): narrow NonDeleted type to isDeleted: false (#11470)
---------
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-07 16:22:31 +02:00
David Luzar b2a729c400 feat(editor): dblclick to toggle arrowhead (#11615) 2026-07-07 14:22:21 +02:00
David Luzar aaa14e9df6 feat(editor): show cursor hint when switching arrow types (#11608) 2026-07-07 12:55:32 +02:00
David Luzar 9357f98a9b fix(editor): optimize UI rendering during animation (#11604) 2026-07-05 18:44:26 +02:00
David Luzar 5b0f5a4c2d fix(editor): revert viewport animation back to 500ms (#11600) 2026-07-05 09:45:59 +02:00
51ca8abde4 feat(packages/excalidraw): viewport locking (#11554)
Co-authored-by: barnabasmolnar <barnabas@excalidraw.com>
Co-authored-by: Arnost Pleskot <arnostpleskot@gmail.com>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-03 22:57:47 +02:00
AnviandChristopher Tangonan cce5001aaa fix(editor): keep eraser shortcut consistent with tool switch rules (#11571)
---------
Co-authored-by: Christopher Tangonan <ctangonan123@gmail.com>
2026-06-29 10:04:08 +02:00
Dany Valverde Caldasanddwelle 4ce70b815e fix(editor): ensure canvas is cleared when background color is invalid to prevent ghosting (#11458)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-06-26 22:59:17 +02:00
David Luzar c070c8ffa6 fix(editor): improve scroll animation interpolation (#11562) 2026-06-26 09:56:12 +02:00
Márk Tolmácsanddwelle e4c70cb6c6 feat(editor): AnimationController for scrollToContent (#11553)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-06-26 09:35:32 +02:00
minato32anddwelle 20f694d110 fix(editor): prevent freeze from extremely large line elements (#11556)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-06-25 20:06:02 +02:00
David Luzar 2a82821ec5 fix(editor): tweak freedraw settings and tablet UI/UX (#11551)
* fix(editor): align constant freedraw stroke width more with generic shapes

* reduce streamline for non-mouse

* do not change `currentItemStrokeVariability` unintentionally

* render penMode button under compact styles panel on tablets

* show stroke variability as standalone button in compact actions menu

* improve toolbar clicking UX with pen

* change streamline defaults

* change to `variable` stroke if toggling penMode for the first time
2026-06-24 16:59:10 +02:00
Márk Tolmácsanddwelle 070df27e4d fix(editor): Modern TS require imports from rootDir (#11552)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-06-24 10:52:56 +00:00
Márk Tolmácsanddwelle cd514d72d6 feat(editor): LaserPointer based freedraw (#11507)
Introduces constant width freedraw mode, keeping the original variable mode as default.

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-06-24 09:16:22 +02:00
zsviczian 0642e72cfa fix(editor): Arrows with text are rendered blurry in PNG export with larger scale (#11492) 2026-06-22 18:22:26 +02:00
David Luzar 28a9b1711d test(repo): less noisy test output (#11505) 2026-06-15 18:19:20 +02:00
Márk Tolmácsanddwelle 1cb9fff569 fix(editor): Double history (#11445)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-06-15 18:19:08 +02:00
David LuzarandDiego Mateos 069982606d fix(editor): update element.frameId on frame change (#11490)
Co-authored-by: Diego Mateos <dimateos@ucm.es>
2026-06-13 22:39:04 +02:00
David Luzar b324a85ab1 fix(editor): elements duplicated when moving frame children (#11485)
* fix(editor): elements duplicated when moving frame children

* fix(editor): accumulate reorders across frames in bring-to-front/back

* add invalid-order tests

* fix: make sure moved indices are within range

* add length/duplicate elements guards
2026-06-13 20:32:45 +02:00
a83ac48853 fix(editor): recalculate roundness type when switching shape types (#11473)
When converting between generic shapes, the roundness type was only
recalculated when the target type was a diamond. Converting a diamond
(which uses the proportional radius algorithm) back to a rectangle kept
the proportional radius, so rectangles ended up rendering with overly
round corners.

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Maruthan G <maruthang4@gmail.com>
Co-authored-by: Sivram <withsivram@gmail.com>
Co-authored-by: Jai Kumar Dewani <jai.dewani.99@gmail.com>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-06-11 19:17:15 +02:00
KrishhnaT 0cf56b19c7 test(editor): add unit tests for BinaryHeap (#11419) 2026-06-10 17:12:42 +02:00
KrishhnaT 61fe15a51d fix(editor): cardinal direction arrows with label are invisible in exported SVG (#11441)
fix: arrows with bound text labels missing from SVG export

Axis-aligned (horizontal/vertical) arrows with a bound label vanished from SVG exports. The label-gap mask defaulted to objectBoundingBox units, whose region collapses to zero area for a zero-size bounding box, masking out the whole line. Pin the mask to userSpaceOnUse with an explicit user-space region (the coords already used by the visible rect).

Fixes #11439
2026-06-07 19:19:44 +02:00
David Luzar 647a264a48 feat(packages/excalidraw): consolidate theme state handling (#11453) 2026-06-06 18:18:06 +02:00
Márk Tolmács b6d80e4256 fix(packages/excalidraw): consolidate bounds checks (#11275)
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-06-04 19:27:25 +02:00
David Luzar 3372149277 feat(packages/excalidraw): export applyDarkModeFilter and simplify (#11429) 2026-06-01 15:43:45 +02:00
David Luzar c08be69618 ci(docker): fix docker dep bundling and pin remaining actions (#11398)
* docker: use slim alpine image to remove bundling deps in Docker image

* pin remaining yml actions

* use lockfile

* remove pulling
2026-05-25 14:39:21 +02:00
Márk Tolmácsanddwelle b42b1a193d fix(editor): excessive battery usage (#11377)
* fix: Excessive battery usage

* chore: Refactor Eraser, Lasso and Laser pointer to use AnimationController

* fix: Last laser trail element is not removed from SVG
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-05-24 16:12:17 +02:00
David Luzar f6d85bc80f feat(packages/excalidraw): expose image size config and optimize resizing (#11332) 2026-05-14 22:45:03 +02:00
David Luzar 0457ac9063 fix(editor): handle invalid points on restore (#11321)
* fix: handle invalid points on restore

* move isValidPoint to @excalidraw/math
2026-05-12 18:44:49 +02:00
Praneeth Kodumagulla b2b2815954 fix(editor): prevent duplicate lasso toolbar item (#11286) 2026-05-06 23:10:50 +02:00
d992c10bc1 fix(app): resolve app-jotai import path in LocalData (#11290)
Co-authored-by: chulkin-mdb <oleksandr.chulkin@mongodb.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 23:03:10 +02:00
melvinandDavid Luzar 091b9053a3 fix(editor): enabled ctrl+y redo shortcut on linux and mac (#11179)
* enabled ctrl+y redo shortcut on linux and mac

* Apply suggestion from @dwelle

---------

Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2026-05-06 22:59:28 +02:00
David Luzar 97274a74b2 ci(repo): enforce scopes (#11292) 2026-05-06 19:54:30 +02:00
Márk Tolmácsanddwelle c59fb8dcbc fix: LocalStorage is empty object on node@25 which breaks tests (#11240)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-05-06 17:42:36 +02:00
Márk Tolmácsanddwelle 7f56cc0cf3 fix: Speculative fixes for arrow invariant failures (#11241)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-05-06 14:39:45 +00:00
974b338b7e fix: Group selection (#11234)
* fix: Group selection

Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Tests

Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Frames and overlap

Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Remove unnecessary crust

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

* revert unused Set signature

* skip ignored elements from group condition

when wrap-mode selecting grouped elements, we should not require to select those we ignore (bound elements or locked ones), else it's impossible to select grouped text containers

unclear whether locked elements should also be excluded - but it feels like a good heuristic on the whole

* apply exclusion

* simplify

* feat: return all elements in group for overlap selection

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-05-06 15:27:34 +02:00
David Luzar d2557474e2 fix(editor): fix target element index when creating/adding elements to frames (#11257) 2026-05-05 21:35:32 +02:00
Márk Tolmácsanddwelle 3004c642da fix: Fractional index validation (#11258)
- Vendored fractional-indexing and converted to TypeScript
- Stricter index format validation in fractional-indexing
- Added format validation to fractional index validation

---
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-05-04 11:37:17 +02:00
Márk Tolmács 2dfcc6f0ce chore: Remove startBoundElement from state (#11264) 2026-05-02 16:36:42 +02:00
David Luzar 3f5fdec04e fix: group defragmenting (#11269) 2026-05-02 15:50:58 +02:00
David Luzar 278cd35772 feat(editor): scale video embeddables based on zoom (#11251) 2026-04-28 21:49:40 +02:00
David Luzar 43fa4b5602 fix: frame selection and membership (#11250)
fix: element fully overlapping frame
2026-04-28 18:23:10 +02:00
2e1a529c67 fix(editor): remove extremely large arrows on restore (#11235)
* fix: Temp fix for elbow arrow at restore

Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Speculative fixes to avoid Infinity

Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* validate/remove arrow size after point normalization & move binding repairs back

* validate even simple arrows

* remove x/y check

* remove duplicate constant

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-04-25 12:03:50 +02:00
David Luzar b1c6bfcf40 chore(docker): bump node (#11208) 2026-04-20 22:07:00 +02:00
Tom LouveauandDavid Luzar 1caec99b29 docs: change twitter label by X (#11158)
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2026-04-13 10:30:58 +02:00
520 changed files with 65251 additions and 15367 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:18-bullseye
FROM node:24-bullseye
# Vite wants to open the browser using `open`, so we
# need to install those utils.
+7
View File
@@ -0,0 +1,7 @@
# VITE_DEBUG_DOM
# When "true", testing-library failures (waitFor / getBy*) include the full
# serialized DOM in the error message. It's off by default because it's noisy.
#
# Flip it to "true" (or use `VITE_DEBUG_DOM=true yarn test`) when you need to
# inspect the DOM of a failing test.
VITE_DEBUG_DOM=false
+2 -2
View File
@@ -9,11 +9,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 2
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20.x
- name: Set up publish access
+1 -1
View File
@@ -9,5 +9,5 @@ jobs:
build-docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- run: docker build -t excalidraw .
+2 -2
View File
@@ -7,10 +7,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20.x
+3 -3
View File
@@ -10,12 +10,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20.x
@@ -43,5 +43,5 @@ jobs:
uses: kt3k/update-pr-description@1b35a6dcd84d81aa0bc1889610efdcde7f37b0c0 # v1.0.1
with:
pr_body: ${{ steps.getCommentBody.outputs.body }}
pr_title: "chore: Update translations from Crowdin"
pr_title: "chore(editor): Update translations from Crowdin"
github_token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Login to DockerHub
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2
with:
+98
View File
@@ -6,11 +6,109 @@ on:
- opened
- edited
- synchronize
- labeled
- unlabeled
jobs:
semantic:
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5
with:
types: |
feat
fix
docs
style
refactor
perf
test
build
ci
chore
revert
requireScope: true
scopes: |
app
editor
packages/excalidraw
packages/utils
docker
repo
ignoreLabels: |
skip-semantic-title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
label-scope:
needs: semantic
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Label scoped PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
scope_labels=(s-app s-editor s-package)
readarray -t desired_labels < <(
node <<'NODE'
const title = process.env.PR_TITLE;
const match = title.match(/^[a-z]+(?:\(([^)]+)\))?!?:/i);
const scopes = match?.[1]?.split(",").map((scope) => scope.trim()) ?? [];
const labels = new Set();
for (const scope of scopes) {
if (scope === "app") {
labels.add("s-app");
} else if (scope === "editor") {
labels.add("s-editor");
} else if (scope.startsWith("packages/")) {
labels.add("s-package");
}
}
process.stdout.write([...labels].join("\n"));
NODE
)
should_apply_label() {
local label="$1"
for desired_label in "${desired_labels[@]}"; do
if [[ "$desired_label" == "$label" ]]; then
return 0
fi
done
return 1
}
for label in "${scope_labels[@]}"; do
if ! should_apply_label "$label"; then
gh api \
--method DELETE \
"repos/${REPOSITORY}/issues/${PR_NUMBER}/labels/${label}" \
--silent 2>/dev/null || true
fi
done
for label in "${desired_labels[@]}"; do
if ! gh api \
--method POST \
"repos/${REPOSITORY}/issues/${PR_NUMBER}/labels" \
--field "labels[]=${label}" \
--silent; then
echo "::warning::Could not apply ${label}. The workflow token likely does not have issues:write permission for this PR."
fi
done
+2 -2
View File
@@ -9,9 +9,9 @@ jobs:
sentry:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20.x
- name: Install and build
+2 -2
View File
@@ -10,9 +10,9 @@ jobs:
CI_JOB_NUMBER: 1
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20.x
- name: Install in packages/excalidraw
+2 -2
View File
@@ -10,9 +10,9 @@ jobs:
pull-requests: write
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: "Install Node"
uses: actions/setup-node@v2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20.x"
- name: "Install Deps"
+2 -2
View File
@@ -8,9 +8,9 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20.x
- name: Install and test
+4
View File
@@ -0,0 +1,4 @@
# Guidelines
- For new DOM/browser API usage, use `app.ownerDocument` and `app.ownerWindow` instead of globals; without `app`, derive them from the mounted node's `ownerDocument` and its `defaultView`.
- When overriding properties of an existing type, prefer `Merge<Base, Overrides>` from `@excalidraw/common/utility-types` over `Omit<Base, keyof Overrides> & Overrides`.
+3 -3
View File
@@ -1,4 +1,4 @@
FROM --platform=${BUILDPLATFORM} node:18 AS build
FROM --platform=${BUILDPLATFORM} node:24@sha256:8530f76a96d88820d288761f022e318970dda93d01536919fbc16076b7983e63 AS build
WORKDIR /opt/node_app
@@ -7,13 +7,13 @@ COPY . .
# do not ignore optional dependencies:
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
RUN --mount=type=cache,target=/root/.cache/yarn \
npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
npm_config_target_arch=${TARGETARCH} yarn --frozen-lockfile --network-timeout 600000
ARG NODE_ENV=production
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
FROM nginx:stable-alpine-slim@sha256:2c605dbeab79a6b2a63340474fe58119d0ef95bdc4b1f41df0aa689659b3d13b
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
@@ -274,6 +274,35 @@ convertToExcalidrawElements([
![image](https://github.com/excalidraw/excalidraw/assets/11256141/f8123cd1-c9aa-452d-b96b-05c846c5030d)
### Sticky Notes
A sticky note is an always-filled note whose label auto-fits: the font shrinks from the label's `fontSize` (the note's font ceiling) down to a minimum, and only then does the note grow in height. Only `type`, `x` and `y` are required; `width`/`height` default to a 250×250 square and `label` is optional. The note's `strokeColor` is its ink — its text and creation-date footer are always one color; a label that sets its own `strokeColor` gives it to the note. A footer band below the label shows the note's creation date (`created`); pass `created: null` for a note without one (the band stays reserved, but empty).
```js
convertToExcalidrawElements([
{
type: "stickynote",
x: 100,
y: 100,
label: {
text: "Remember to ship it",
fontSize: 28,
},
},
{
type: "stickynote",
x: 400,
y: 100,
width: 200,
height: 200,
backgroundColor: "#fcc2d7",
strokeColor: "#c2255c",
},
]);
```
Notes keep their invariants regardless of the properties you pass: the background is never transparent (it falls back to the default yellow), the fill is solid, and the size is at least 75×75. The height you pass is kept as the note's `baseHeight`; if a label needs more room at the minimum font size, the created note is taller.
### Labelled Arrows
Similar to Text Containers, you can create labelled arrows as well.
+12
View File
@@ -62,6 +62,18 @@ When saving an Excalidraw scene locally to a file, the JSON file (`.excalidraw`)
}
```
### Element creation time
Every normalized element has a `created: number | null` field. A number is the client's wall-clock time in epoch milliseconds when this element instance was created. `null` means the creation time is unknown; restoring an older file does not substitute its load time or last-update time.
| Operation | `created` |
| --- | --- |
| Construct a new element | Current time, unless explicitly supplied (including `null`) |
| Duplicate, paste, or insert from a library | New time for the new instance |
| Convert element skeletons with ID regeneration | New time for each resulting instance |
| Restore/open/import an existing scene | Preserve the carried value; missing values become `null` |
| Edit, change type while retaining the ID, capture, undo, or redo | Preserve the value |
## Excalidraw clipboard format
When copying selected excalidraw elements to clipboard, the JSON schema is similar to `.excalidraw` format, except it differs in attributes.
@@ -9,8 +9,6 @@ In case you want to pick up something from the roadmap, comment on that issue an
## Setup
### Option 1 - Manual
1. Fork and clone the repo
1. Run `yarn` to install dependencies
1. Create a branch for your PR with `git checkout -b your-branch-name`
@@ -23,15 +21,6 @@ In case you want to pick up something from the roadmap, comment on that issue an
> git branch --set-upstream-to=upstream/master master
> ```
### Option 2 - CodeSandbox
1. Go to https://codesandbox.io/p/github/excalidraw/excalidraw
1. Connect your GitHub account
1. Go to Git tab on left side
1. Tap on `Fork Sandbox`
1. Write your code
1. Commit and PR automatically
## Pull Request Guidelines
Don't worry if you get any of the below wrong, or if you don't know how. We'll gladly help out.
@@ -1,11 +1,5 @@
# Development
## Code Sandbox
- Go to https://codesandbox.io/p/github/excalidraw/excalidraw
- You may need to sign in with GitHub and reload the page
- You can start coding instantly, and even send PRs from there!
## Local Installation
These instructions will get you a copy of the project up and running on your local machine for development and testing purposes.
+2 -2
View File
@@ -97,8 +97,8 @@ const config = {
href: "https://discord.gg/UexuTaE",
},
{
label: "Twitter",
href: "https://twitter.com/excalidraw",
label: "𝕏",
href: "https://x.com/excalidraw",
},
{
label: "Linkedin",
+6
View File
@@ -1,4 +1,10 @@
{
"git": {
"deploymentEnabled": {
"master": true,
"**": false
}
},
"outputDirectory": "build",
"installCommand": "yarn install"
}
+6
View File
@@ -1,3 +1,9 @@
{
"git": {
"deploymentEnabled": {
"master": true,
"**": false
}
},
"outputDirectory": "build"
}
@@ -102,6 +102,12 @@ export default function ExampleApp({
} = excalidrawLib;
const appRef = useRef<any>(null);
const [viewModeEnabled, setViewModeEnabled] = useState(false);
const [interactive, setInteractive] = useState(true);
const [interactiveLinks, setInteractiveLinks] = useState(false);
const [interactiveEmbeds, setInteractiveEmbeds] = useState(false);
const [interactiveNavigation, setInteractiveNavigation] = useState(false);
const [interactiveBrowserZoom, setInteractiveBrowserZoom] = useState(false);
const [ui, setUi] = useState(true);
const [zenModeEnabled, setZenModeEnabled] = useState(false);
const [gridModeEnabled, setGridModeEnabled] = useState(false);
const [renderScrollbars, setRenderScrollbars] = useState(false);
@@ -192,6 +198,17 @@ export default function ExampleApp({
pointersMap: Gesture["pointers"];
}) => setPointerData(payload),
viewModeEnabled,
interaction: interactive
? true
: {
enabled: {
links: interactiveLinks,
embeds: interactiveEmbeds,
navigation: interactiveNavigation,
browserZoom: interactiveBrowserZoom,
},
},
ui,
zenModeEnabled,
renderScrollbars,
gridModeEnabled,
@@ -696,6 +713,52 @@ export default function ExampleApp({
/>
View mode
</label>
<label>
<input
type="checkbox"
checked={!interactive}
onChange={() => setInteractive(!interactive)}
/>
Non-interactive
</label>
<label>
<input
type="checkbox"
checked={interactiveLinks}
onChange={() => setInteractiveLinks(!interactiveLinks)}
/>
Links when non-interactive
</label>
<label>
<input
type="checkbox"
checked={interactiveEmbeds}
onChange={() => setInteractiveEmbeds(!interactiveEmbeds)}
/>
Embeds when non-interactive
</label>
<label>
<input
type="checkbox"
checked={interactiveNavigation}
onChange={() => setInteractiveNavigation(!interactiveNavigation)}
/>
Navigation when non-interactive
</label>
<label>
<input
type="checkbox"
checked={interactiveBrowserZoom}
onChange={() =>
setInteractiveBrowserZoom(!interactiveBrowserZoom)
}
/>
Browser zoom when non-interactive
</label>
<label>
<input type="checkbox" checked={!ui} onChange={() => setUi(!ui)} />
Hide UI
</label>
<label>
<input
type="checkbox"
@@ -917,8 +980,9 @@ export default function ExampleApp({
}
const elements = excalidrawAPI.getSceneElements();
excalidrawAPI.scrollToContent(elements[0], {
fitToViewport: true,
excalidrawAPI.setViewport({
target: elements[0],
fit: "contain",
});
}}
>
@@ -932,34 +996,14 @@ export default function ExampleApp({
}
const elements = excalidrawAPI.getSceneElements();
excalidrawAPI.scrollToContent(elements[0], {
fitToContent: true,
});
excalidrawAPI.scrollToContent(elements[0], {
fitToContent: true,
excalidrawAPI.setViewport({
target: elements[0],
fit: "scale-down",
});
}}
>
Fit to content, first element
</button>
<button
type="button"
onClick={() => {
if (!excalidrawAPI) {
return;
}
const elements = excalidrawAPI.getSceneElements();
excalidrawAPI.scrollToContent(elements[0], {
fitToContent: true,
});
excalidrawAPI.scrollToContent(elements[0]);
}}
>
Scroll to first element, no fitToContent, no fitToViewport
</button>
<div className="export export-canvas">
<img src={canvasUrl} alt="" />
</div>
@@ -1,4 +1,10 @@
{
"git": {
"deploymentEnabled": {
"master": true,
"**": false
}
},
"outputDirectory": "dist",
"installCommand": "yarn install",
"buildCommand": "yarn build:packages && yarn build"
+47 -12
View File
@@ -22,7 +22,6 @@ import Trans from "@excalidraw/excalidraw/components/Trans";
import {
APP_NAME,
EVENT,
THEME,
VERSION_TIMEOUT,
debounce,
getVersion,
@@ -34,7 +33,7 @@ import {
isDevEnv,
} from "@excalidraw/common";
import polyfill from "@excalidraw/excalidraw/polyfill";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
import { t } from "@excalidraw/excalidraw/i18n";
@@ -98,6 +97,7 @@ import Collab, {
collabAPIAtom,
isCollaboratingAtom,
isOfflineAtom,
userToFollowAtom,
} from "./collab/Collab";
import { AppFooter } from "./components/AppFooter";
import { AppMainMenu } from "./components/AppMainMenu";
@@ -140,6 +140,7 @@ import DebugCanvas, {
isVisualDebuggerEnabled,
loadSavedDebugState,
} from "./components/DebugCanvas";
import { useSimulatedCollaborators } from "./debugCollaborators";
import { AIComponents } from "./components/AI";
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
@@ -410,6 +411,37 @@ const ExcalidrawWrapper = () => {
return isCollaborationLink(window.location.href);
});
const collabError = useAtomValue(collabErrorIndicatorAtom);
const userToFollow = useAtomValue(userToFollowAtom);
const viewportStatusFrame = useMemo(
() =>
userToFollow
? {
border: "var(--color-primary-hover)",
label: {
label: (
<>
Following{" "}
<span
style={{
display: "block",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: 100,
}}
title={userToFollow.username}
>
{userToFollow.username}
</span>
</>
),
onClose: () => collabAPI?.setUserToFollow(null),
},
}
: null,
[userToFollow, collabAPI],
);
useHandleLibrary({
excalidrawAPI,
@@ -435,6 +467,11 @@ const ExcalidrawWrapper = () => {
}
}, [excalidrawAPI]);
// ?collaborators=<N> — populate the canvas with N static fake
// collaborators for exercising avatar/UserList UI without a real
// collab room
useSimulatedCollaborators(excalidrawAPI);
// ---------------------------------------------------------------------------
// Hoisted loadImages
// ---------------------------------------------------------------------------
@@ -909,6 +946,8 @@ const ExcalidrawWrapper = () => {
})}
>
<Excalidraw
viewportStatusFrame={viewportStatusFrame}
userToFollow={userToFollow}
onChange={onChange}
onExport={onExport}
initialData={initialStatePromiseRef.current.promise}
@@ -952,6 +991,7 @@ const ExcalidrawWrapper = () => {
handleKeyboardGlobally={true}
autoFocus={true}
theme={editorTheme}
onThemeChange={setAppTheme}
renderTopRightUI={(isMobile) => {
if (isMobile || !collabAPI || isCollabDisabled) {
return null;
@@ -979,7 +1019,11 @@ const ExcalidrawWrapper = () => {
onLinkOpen={(element, event) => {
if (element.link && isElementLink(element.link)) {
event.preventDefault();
excalidrawAPI?.scrollToContent(element.link, { animate: true });
excalidrawAPI?.setViewport({
target: element.link,
fit: "scale-down",
animation: true,
});
}
}}
>
@@ -988,7 +1032,6 @@ const ExcalidrawWrapper = () => {
isCollaborating={isCollaborating}
isCollabEnabled={!isCollabDisabled}
theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
refresh={() => forceRefresh((prev) => !prev)}
/>
<AppWelcomeScreen
@@ -1229,14 +1272,6 @@ const ExcalidrawWrapper = () => {
}
},
},
{
...CommandPalette.defaultItems.toggleTheme,
perform: () => {
setAppTheme(
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
);
},
},
{
label: t("labels.installPWA"),
category: DEFAULT_CATEGORIES.app,
+67 -23
View File
@@ -48,6 +48,7 @@ import type {
SocketId,
Collaborator,
Gesture,
UserToFollow,
} from "@excalidraw/excalidraw/types";
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
@@ -108,6 +109,7 @@ interface CollabState {
}
export const activeRoomLinkAtom = atom<string | null>(null);
export const userToFollowAtom = atom<UserToFollow | null>(null);
type CollabInstance = InstanceType<typeof Collab>;
@@ -123,6 +125,7 @@ export interface CollabAPI {
getUsername: CollabInstance["getUsername"];
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
setCollabError: CollabInstance["setErrorDialog"];
setUserToFollow: CollabInstance["setUserToFollow"];
}
interface CollabProps {
@@ -139,6 +142,8 @@ class Collab extends PureComponent<CollabProps, CollabState> {
private socketInitializationTimer?: number;
private lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map<SocketId, Collaborator>();
/** the socket ids of the users following the current user */
private followedBy = new Set<SocketId>();
constructor(props: CollabProps) {
super(props);
@@ -212,7 +217,9 @@ class Collab extends PureComponent<CollabProps, CollabState> {
window.addEventListener(EVENT.UNLOAD, this.onUnload);
const unsubOnUserFollow = this.excalidrawAPI.onUserFollow((payload) => {
this.portal.socket && this.portal.broadcastUserFollowed(payload);
this.setUserToFollow(
payload.action === "FOLLOW" ? payload.userToFollow : null,
);
});
const throttledRelayUserViewportBounds = throttleRAF(
this.relayVisibleSceneBounds,
@@ -238,6 +245,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
getUsername: this.getUsername,
getActiveRoomLink: this.getActiveRoomLink,
setCollabError: this.setErrorDialog,
setUserToFollow: this.setUserToFollow,
};
appJotaiStore.set(collabAPIAtom, collabAPI);
@@ -406,9 +414,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.lastBroadcastedOrReceivedSceneVersion = -1;
this.portal.close();
this.fileManager.reset();
this.followedBy = new Set();
if (!opts?.isUnload) {
this.setIsCollaborating(false);
this.setActiveRoomLink(null);
appJotaiStore.set(userToFollowAtom, null);
this.collaborators = new Map();
this.excalidrawAPI.updateScene({
collaborators: this.collaborators,
@@ -629,11 +639,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
case WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS: {
const { sceneBounds, socketId } = decryptedData.payload;
const appState = this.excalidrawAPI.getAppState();
const userToFollow = appJotaiStore.get(userToFollowAtom);
// we're not following the user
// (shouldn't happen, but could be late message or bug upstream)
if (appState.userToFollow?.socketId !== socketId) {
if (userToFollow?.socketId !== socketId) {
console.warn(
`receiving remote client's (from ${socketId}) viewport bounds even though we're not subscribed to it!`,
);
@@ -641,19 +651,17 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}
// cross-follow case, ignore updates in this case
if (
appState.userToFollow &&
appState.followedBy.has(appState.userToFollow.socketId)
) {
if (this.followedBy.has(userToFollow.socketId)) {
return;
}
const appState = this.excalidrawAPI.getAppState();
this.excalidrawAPI.updateScene({
appState: zoomToFitBounds({
appState,
bounds: sceneBounds,
fitToViewport: true,
viewportZoomFactor: 1,
fit: "contain",
}).appState,
});
@@ -690,9 +698,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.portal.socket.on(
WS_EVENTS.USER_FOLLOW_ROOM_CHANGE,
(followedBy: SocketId[]) => {
this.excalidrawAPI.updateScene({
appState: { followedBy: new Set(followedBy) },
});
this.followedBy = new Set(followedBy);
this.relayVisibleSceneBounds({ force: true });
},
@@ -870,26 +876,38 @@ class Collab extends PureComponent<CollabProps, CollabState> {
const collaborators: InstanceType<typeof Collab>["collaborators"] =
new Map();
for (const socketId of sockets) {
const isCurrentUser = socketId === this.portal.socket?.id;
collaborators.set(
socketId,
Object.assign({}, this.collaborators.get(socketId), {
isCurrentUser: socketId === this.portal.socket?.id,
}),
Object.assign(
// we never receive our own broadcasts, so we need to seed
// our own collaborator entry with the local username
isCurrentUser ? { username: this.state.username } : {},
this.collaborators.get(socketId),
{ isCurrentUser },
),
);
}
this.collaborators = collaborators;
this.excalidrawAPI.updateScene({ collaborators });
// unfollow if the followed user left the room
const userToFollow = appJotaiStore.get(userToFollowAtom);
if (userToFollow && !collaborators.has(userToFollow.socketId)) {
this.setUserToFollow(null);
}
}
updateCollaborator = (socketId: SocketId, updates: Partial<Collaborator>) => {
const isCurrentUser = socketId === this.portal.socket?.id;
const collaborators = new Map(this.collaborators);
const user: Mutable<Collaborator> = Object.assign(
{},
// we never receive our own broadcasts, so we need to seed
// our own collaborator entry with the local username
isCurrentUser ? { username: this.state.username } : {},
collaborators.get(socketId),
updates,
{
isCurrentUser: socketId === this.portal.socket?.id,
},
{ isCurrentUser },
);
collaborators.set(socketId, user);
this.collaborators = collaborators;
@@ -925,12 +943,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
);
relayVisibleSceneBounds = (props?: { force: boolean }) => {
const appState = this.excalidrawAPI.getAppState();
if (this.portal.socket && (appState.followedBy.size > 0 || props?.force)) {
if (this.portal.socket && (this.followedBy.size > 0 || props?.force)) {
this.portal.broadcastVisibleSceneBounds(
{
sceneBounds: getVisibleSceneBounds(appState),
sceneBounds: getVisibleSceneBounds(this.excalidrawAPI.getAppState()),
},
`follow@${this.portal.socket.id}`,
);
@@ -985,9 +1001,37 @@ class Collab extends PureComponent<CollabProps, CollabState> {
{ leading: false },
);
setUserToFollow = (userToFollow: UserToFollow | null) => {
const prev = appJotaiStore.get(userToFollowAtom) ?? null;
if (prev?.socketId !== userToFollow?.socketId && this.portal.socket) {
// leave the previous user's follow room before joining the next one
if (prev) {
this.portal.broadcastUserFollowed({
userToFollow: prev,
action: "UNFOLLOW",
});
}
if (userToFollow) {
this.portal.broadcastUserFollowed({
userToFollow,
action: "FOLLOW",
});
}
}
appJotaiStore.set(userToFollowAtom, userToFollow);
};
setUsername = (username: string) => {
this.setState({ username });
saveUsernameToLocalStorage(username);
// keep our own collaborator entry in sync
const socketId = this.portal.socket?.id as SocketId | undefined;
if (socketId && this.collaborators.has(socketId)) {
this.updateCollaborator(socketId, { username });
}
};
getUsername = () => this.state.username;
+62 -13
View File
@@ -1,14 +1,17 @@
import {
DiagramToCodePlugin,
exportToBlob,
getNonDeletedElements,
getTextFromElements,
MIME_TYPES,
parseSSEStream,
TTDDialog,
TTDStreamFetch,
} from "@excalidraw/excalidraw";
import { getDataURL } from "@excalidraw/excalidraw/data/blob";
import { safelyParseJSON } from "@excalidraw/common";
import type { StreamChunk } from "@excalidraw/excalidraw";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { TTDIndexedDBAdapter } from "../data/TTDStorage";
@@ -21,11 +24,18 @@ export const AIComponents = ({
return (
<>
<DiagramToCodePlugin
generate={async ({ frame, children }) => {
generate={async ({ frame, children, onPartial }) => {
const appState = excalidrawAPI.getAppState();
// SAFETY: This should never happen, but log it just in case
if (children.some((el) => el.isDeleted)) {
console.error(
"[NONDELETED][INVARIANT] Generated children elements should not be `isDeleted: true`",
);
}
const blob = await exportToBlob({
elements: children,
elements: getNonDeletedElements(children),
appState: {
...appState,
exportBackground: true,
@@ -43,11 +53,11 @@ export const AIComponents = ({
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/diagram-to-code/generate`,
}/v1/ai/diagram-to-code/generate-streaming`,
{
method: "POST",
headers: {
Accept: "application/json",
Accept: "text/event-stream",
"Content-Type": "application/json",
},
body: JSON.stringify({
@@ -86,18 +96,57 @@ export const AIComponents = ({
throw new Error(errorJSON.message || text);
}
try {
const { html } = await response.json();
const reader = response.body?.getReader();
if (!html) {
throw new Error("Generation failed (invalid response)");
}
return {
html,
};
} catch (error: any) {
if (!reader) {
throw new Error("Generation failed (invalid response)");
}
let html = "";
let streamError: Error | null = null;
for await (const data of parseSSEStream(reader)) {
if (data === "[DONE]") {
break;
}
const chunk = safelyParseJSON(data) as StreamChunk | null;
if (!chunk) {
continue;
}
switch (chunk.type) {
case "content": {
if (chunk.delta) {
html += chunk.delta;
onPartial?.(html);
}
break;
}
case "error": {
streamError = new Error(
chunk.error.message || "Generation failed",
);
break;
}
case "done": {
break;
}
}
}
if (streamError) {
throw streamError;
}
if (!html.trim()) {
throw new Error("Generation failed (invalid response)");
}
return {
html,
};
}}
/>
+4 -7
View File
@@ -3,6 +3,7 @@ import {
ExcalLogo,
eyeIcon,
} from "@excalidraw/excalidraw/components/icons";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import { MainMenu } from "@excalidraw/excalidraw/index";
import React from "react";
@@ -20,9 +21,9 @@ export const AppMainMenu: React.FC<{
isCollaborating: boolean;
isCollabEnabled: boolean;
theme: Theme | "system";
setTheme: (theme: Theme | "system") => void;
refresh: () => void;
}> = React.memo((props) => {
const { t } = useI18n();
return (
<MainMenu>
<MainMenu.DefaultItems.LoadScene />
@@ -57,7 +58,7 @@ export const AppMainMenu: React.FC<{
}?utm_source=signin&utm_medium=app&utm_content=hamburger`}
className="highlighted"
>
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
{isExcalidrawPlusSignedUser ? t("labels.signIn") : t("labels.signUp")}
</MainMenu.ItemLink>
{isDevEnv() && (
<MainMenu.Item
@@ -78,11 +79,7 @@ export const AppMainMenu: React.FC<{
)}
<MainMenu.Separator />
<MainMenu.DefaultItems.Preferences />
<MainMenu.DefaultItems.ToggleTheme
allowSystemTheme
theme={props.theme}
onSelect={props.setTheme}
/>
<MainMenu.DefaultItems.ToggleTheme allowSystemTheme theme={props.theme} />
<MainMenu.ItemCustom>
<LanguageList style={{ width: "100%" }} />
</MainMenu.ItemCustom>
+59 -3
View File
@@ -4,21 +4,32 @@
display: flex;
flex-direction: column;
text-align: center;
gap: 1rem;
gap: 1.5rem;
flex: 1 1 auto;
.link-button {
margin: 0 auto !important;
width: 100%;
button {
width: 100%;
}
}
}
.app-sidebar-promo-image {
margin: 1rem 0;
height: 16.25rem;
height: 295px;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
background-size: calc(
var(--right-sidebar-width, 100%) - var(--space-factor) * 2
);
background-image: radial-gradient(
circle,
transparent 60%,
transparent 0%,
var(--sidebar-bg-color) 100%
),
var(--image-source);
@@ -33,4 +44,49 @@
.link-button {
margin: 0 auto;
}
.app-sidebar-promo-text {
padding: 0 0.5rem;
font-size: 0.875rem;
font-weight: 700;
line-height: 1.4;
}
.app-sidebar-promo-illustration {
position: relative;
min-height: 7rem;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 0.125rem;
.app-sidebar-promo-heart {
width: 46px;
transform: translateX(-14px);
height: auto;
}
.app-sidebar-promo-trial-note {
position: absolute;
top: 2.75rem;
left: 50%;
transform: translateX(-56%) rotate(-7deg);
font-size: 1rem;
line-height: 1.15;
color: var(--color-primary);
white-space: nowrap;
}
.app-sidebar-promo-trial-arrow {
position: absolute;
top: 3.35rem;
left: 55%;
width: 4.5rem;
height: 3rem;
transform: translateX(1.75rem) rotate(10deg);
color: var(--color-primary);
overflow: visible;
}
}
}
+64 -12
View File
@@ -8,6 +8,63 @@ import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
import "./AppSidebar.scss";
type SidebarPromoCopyProps = {
text: string;
};
const SidebarPromoCopy = (props: SidebarPromoCopyProps) => {
return (
<div className="app-sidebar-promo-copy">
<div className="app-sidebar-promo-illustration" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 300 250"
className="app-sidebar-promo-heart"
>
<path
d="M 145 75
C 110 35, 60 55, 65 120
C 70 180, 140 190, 215 200
C 225 180, 260 110, 235 55
C 210 -5, 140 20, 160 105"
fill="none"
stroke="#D06B64"
strokeWidth="16"
strokeLinecap="round"
/>
</svg>
<div className="app-sidebar-promo-trial-note excalifont">
14 days of
<br />
free trial
</div>
<svg
className="app-sidebar-promo-trial-arrow"
viewBox="0 0 72 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 6C23 1 50 8 48 32"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M42 26L48 32L54 26"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<div className="app-sidebar-promo-text">{props.text}</div>
</div>
);
};
export const AppSidebar = () => {
const { theme, openSidebar } = useUIAppState();
@@ -32,15 +89,13 @@ export const AppSidebar = () => {
<div
className="app-sidebar-promo-image"
style={{
["--image-source" as any]: `url(/oss_promo_comments_${
["--image-source" as any]: `url(/sidebar-comments-promo-${
theme === THEME.DARK ? "dark" : "light"
}.jpg)`,
opacity: 0.7,
opacity: 0.9,
}}
/>
<div className="app-sidebar-promo-text">
Make comments with Excalidraw+
</div>
<SidebarPromoCopy text="Make comments with Excalidraw+" />
<LinkButton
href={`${
import.meta.env.VITE_APP_PLUS_LP
@@ -55,16 +110,13 @@ export const AppSidebar = () => {
<div
className="app-sidebar-promo-image"
style={{
["--image-source" as any]: `url(/oss_promo_presentations_${
["--image-source" as any]: `url(/sidebar-presentation-promo-${
theme === THEME.DARK ? "dark" : "light"
}.svg)`,
backgroundSize: "60%",
opacity: 0.4,
}.jpg)`,
opacity: 0.7,
}}
/>
<div className="app-sidebar-promo-text">
Create presentations with Excalidraw+
</div>
<SidebarPromoCopy text="Create presentation with Excalidraw+" />
<LinkButton
href={`${
import.meta.env.VITE_APP_PLUS_LP
@@ -72,7 +72,7 @@ export const AppWelcomeScreen: React.FC<{
shortcut={null}
icon={loginIcon}
>
Sign up
{t("labels.signUp")}
</WelcomeScreen.Center.MenuItemLink>
)}
</WelcomeScreen.Center.Menu>
@@ -1,8 +1,11 @@
import { useI18n } from "@excalidraw/excalidraw/i18n";
export const ExcalidrawPlusPromoBanner = ({
isSignedIn,
}: {
isSignedIn: boolean;
}) => {
const { t } = useI18n();
return (
<a
href={
@@ -16,7 +19,7 @@ export const ExcalidrawPlusPromoBanner = ({
rel="noopener"
className="plus-banner"
>
Excalidraw+
{isSignedIn ? t("labels.signIn") : t("labels.upgrade")}
</a>
);
};
@@ -5,7 +5,7 @@ import { nanoid } from "nanoid";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { Card } from "@excalidraw/excalidraw/components/Card";
import { ExcalidrawLogo } from "@excalidraw/excalidraw/components/ExcalidrawLogo";
import { ToolButton } from "@excalidraw/excalidraw/components/ToolButton";
import { IconButton } from "@excalidraw/excalidraw/components/IconButton";
import { MIME_TYPES, getFrame } from "@excalidraw/common";
import {
encryptData,
@@ -111,7 +111,7 @@ export const ExportToExcalidrawPlus: React.FC<{
<div className="Card-details">
{t("exportDialog.excalidrawplus_description")}
</div>
<ToolButton
<IconButton
className="Card-button"
type="button"
title={t("exportDialog.excalidrawplus_button")}
+1 -1
View File
@@ -26,7 +26,6 @@ import {
get,
} from "idb-keyval";
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
import { getNonDeletedElements } from "@excalidraw/element";
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
@@ -39,6 +38,7 @@ import type {
} from "@excalidraw/excalidraw/types";
import type { MaybePromise } from "@excalidraw/common/utility-types";
import { appJotaiStore, atom } from "../app-jotai";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager";
+74
View File
@@ -0,0 +1,74 @@
import { useEffect } from "react";
import { isDevEnv, toBrandedType } from "@excalidraw/common";
import type {
Collaborator,
ExcalidrawImperativeAPI,
SocketId,
} from "@excalidraw/excalidraw/types";
/**
* Dev-only debug helper: `?collaborators=<N>` on excalidraw-app populates
* the canvas with N static fake collaborators (random usernames, one of
* them marked as the current user) so avatar/UserList UI can be exercised
* without a real collab room or a second browser tab.
*
* Not meant to coexist with a real collab session — joining an actual room
* will overwrite these with the real roster (Collab owns `collaborators`
* from that point on).
*/
const getSimulatedCollaboratorCountFromUrl = (): number | null => {
const count = Number(
new URLSearchParams(window.location.search).get("collaborators"),
);
return Number.isFinite(count) && count > 0 ? Math.floor(count) : null;
};
const generateSimulatedCollaborators = async (
count: number,
): Promise<Map<SocketId, Collaborator>> => {
const { getRandomUsername } = await import("@excalidraw/random-username");
const collaborators = new Map<SocketId, Collaborator>();
for (let i = 0; i < count; i++) {
const socketId = toBrandedType<SocketId>(`debug-collaborator-${i}`);
collaborators.set(socketId, {
id: socketId,
socketId,
username: getRandomUsername(),
isCurrentUser: i === 0,
});
}
return collaborators;
};
export const useSimulatedCollaborators = (
excalidrawAPI: ExcalidrawImperativeAPI | null,
) => {
useEffect(() => {
if (!isDevEnv() || !excalidrawAPI) {
return;
}
const count = getSimulatedCollaboratorCountFromUrl();
if (!count) {
return;
}
let cancelled = false;
generateSimulatedCollaborators(count).then((collaborators) => {
if (!cancelled) {
excalidrawAPI.updateScene({ collaborators });
}
});
return () => {
cancelled = true;
};
}, [excalidrawAPI]);
};
-13
View File
@@ -1,4 +1,3 @@
import { getFeatureFlag } from "@excalidraw/common";
import * as Sentry from "@sentry/browser";
import callsites from "callsites";
@@ -34,7 +33,6 @@ Sentry.init({
Sentry.captureConsoleIntegration({
levels: ["error"],
}),
Sentry.featureFlagsIntegration(),
],
beforeSend(event) {
if (event.request?.url) {
@@ -81,14 +79,3 @@ Sentry.init({
return event;
},
});
const flagsIntegration =
Sentry.getClient()?.getIntegrationByName<Sentry.FeatureFlagsIntegration>(
"FeatureFlags",
);
if (flagsIntegration) {
flagsIntegration.addFeatureFlag(
"COMPLEX_BINDINGS",
getFeatureFlag("COMPLEX_BINDINGS"),
);
}
+30 -5
View File
@@ -1,4 +1,5 @@
import { defaultLang } from "@excalidraw/excalidraw/i18n";
import { Excalidraw, MainMenu } from "@excalidraw/excalidraw";
import { defaultLang, languages } from "@excalidraw/excalidraw/i18n";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
screen,
@@ -7,11 +8,35 @@ import {
render,
} from "@excalidraw/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App";
import { LanguageList } from "../app-language/LanguageList";
import { useAppLangCode } from "../app-language/language-state";
import { Provider } from "../app-jotai";
const TEST_LANG_CODE = "fr-FR";
const TestApp = () => {
const [langCode] = useAppLangCode();
return (
<Excalidraw langCode={langCode}>
<MainMenu>
<MainMenu.ItemCustom>
<LanguageList />
</MainMenu.ItemCustom>
</MainMenu>
</Excalidraw>
);
};
describe("Test LanguageList", () => {
it("rerenders UI on language change", async () => {
await render(<ExcalidrawApp />);
expect(languages.some((lang) => lang.code === TEST_LANG_CODE)).toBe(true);
await render(
<Provider>
<TestApp />
</Provider>,
);
// select rectangle tool to show properties menu
UI.clickTool("rectangle");
@@ -20,9 +45,9 @@ describe("Test LanguageList", () => {
fireEvent.click(document.querySelector(".dropdown-menu-button")!);
fireEvent.change(document.querySelector(".dropdown-select__language")!, {
target: { value: "de-DE" },
target: { value: TEST_LANG_CODE },
});
// switching to german, `thin` label should no longer exist
// switching to French, `thin` label should no longer exist
await waitFor(() => expect(screen.queryByTitle(/thin/i)).toBeNull());
// reset language
fireEvent.change(document.querySelector(".dropdown-select__language")!, {
+6 -3
View File
@@ -1,3 +1,4 @@
import { Excalidraw, WelcomeScreen } from "@excalidraw/excalidraw";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
mockBoundingClientRect,
@@ -5,8 +6,6 @@ import {
restoreOriginalGetBoundingClientRect,
} from "@excalidraw/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App";
describe("Test MobileMenu", () => {
const { h } = window;
const dimensions = { height: 400, width: 800 };
@@ -16,7 +15,11 @@ describe("Test MobileMenu", () => {
});
beforeEach(async () => {
await render(<ExcalidrawApp />);
await render(
<Excalidraw>
<WelcomeScreen />
</Excalidraw>,
);
h.app.refreshEditorInterface();
});
@@ -50,11 +50,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
<div
class="welcome-screen-center__heading welcome-screen-decor excalifont"
>
Your drawings are saved in your browser's storage.
<br />
Browser storage can be cleared unexpectedly.
<br />
Save your work to a file regularly to avoid losing it.
Diagrams. Made. Simple.
</div>
<div
class="welcome-screen-menu"
@@ -149,103 +145,6 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
?
</div>
</button>
<button
class="welcome-screen-menu-item "
type="button"
>
<div
class="welcome-screen-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
>
<g
stroke-width="1.5"
>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<circle
cx="9"
cy="7"
r="4"
/>
<path
d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"
/>
<path
d="M16 3.13a4 4 0 0 1 0 7.75"
/>
<path
d="M21 21v-2a4 4 0 0 0 -3 -3.85"
/>
</g>
</svg>
</div>
<div
class="welcome-screen-menu-item__text"
>
Live collaboration...
</div>
</button>
<a
class="welcome-screen-menu-item "
href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
rel="noopener"
target="_blank"
>
<div
class="welcome-screen-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
>
<g
stroke-width="1.5"
>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<path
d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2"
/>
<path
d="M21 12h-13l3 -3"
/>
<path
d="M11 15l-3 -3"
/>
</g>
</svg>
</div>
<div
class="welcome-screen-menu-item__text"
>
Sign up
</div>
</a>
</div>
</div>
`;
+1 -20
View File
@@ -1,5 +1,4 @@
import { THEME } from "@excalidraw/excalidraw";
import { EVENT, CODES, KEYS } from "@excalidraw/common";
import { useEffect, useLayoutEffect, useState } from "react";
import type { Theme } from "@excalidraw/element/types";
@@ -31,28 +30,10 @@ export const useHandleAppTheme = () => {
mediaQuery?.addEventListener("change", handleChange);
}
const handleKeydown = (event: KeyboardEvent) => {
if (
!event[KEYS.CTRL_OR_CMD] &&
event.altKey &&
event.shiftKey &&
event.code === CODES.D
) {
event.preventDefault();
event.stopImmediatePropagation();
setAppTheme(editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK);
}
};
document.addEventListener(EVENT.KEYDOWN, handleKeydown, { capture: true });
return () => {
mediaQuery?.removeEventListener("change", handleChange);
document.removeEventListener(EVENT.KEYDOWN, handleKeydown, {
capture: true,
});
};
}, [appTheme, editorTheme, setAppTheme]);
}, [appTheme]);
useLayoutEffect(() => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme);
+14
View File
@@ -75,6 +75,20 @@ export default defineConfig(({ mode }) => {
find: /^@excalidraw\/utils\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/utils/src/$1"),
},
{
find: /^@excalidraw\/fractional-indexing$/,
replacement: path.resolve(
__dirname,
"../packages/fractional-indexing/src/index.ts",
),
},
{
find: /^@excalidraw\/laser-pointer$/,
replacement: path.resolve(
__dirname,
"../packages/laser-pointer/src/index.ts",
),
},
],
},
build: {
+3 -1
View File
@@ -56,7 +56,9 @@
"build:element": "yarn --cwd ./packages/element build:esm",
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
"build:math": "yarn --cwd ./packages/math build:esm",
"build:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:fractional-indexing": "yarn --cwd ./packages/fractional-indexing build:esm",
"build:laser-pointer": "yarn --cwd ./packages/laser-pointer build:esm",
"build:packages": "yarn build:common && yarn build:fractional-indexing && yarn build:laser-pointer && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build",
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
+19
View File
@@ -1,6 +1,7 @@
import {
applyDarkModeFilter,
COLOR_PALETTE,
removeDarkModeFilter,
rgbToHex,
} from "@excalidraw/common";
@@ -215,6 +216,24 @@ describe("applyDarkModeFilter", () => {
});
});
describe("removeDarkModeFilter", () => {
it("restores black and white", () => {
expect(removeDarkModeFilter("#ededed")).toBe("#000000");
expect(removeDarkModeFilter("#121212")).toBe("#ffffff");
});
it.each([
COLOR_PALETTE.black,
COLOR_PALETTE.white,
COLOR_PALETTE.red[4],
COLOR_PALETTE.green[4],
COLOR_PALETTE.blue[4],
])("preserves the rendered dark-mode color for %s", (color) => {
const filtered = applyDarkModeFilter(color);
expect(applyDarkModeFilter(removeDarkModeFilter(filtered))).toBe(filtered);
});
});
describe("rgbToHex", () => {
describe("basic RGB conversion", () => {
it("converts black (0,0,0)", () => {
+109 -3
View File
@@ -13,6 +13,9 @@ import type { Degrees } from "@excalidraw/math";
const DARK_MODE_COLORS_CACHE: Map<string, string> | null =
typeof window !== "undefined" ? new Map() : null;
const DARK_MODE_FILTER_INVERT_PERCENT = 93;
const DARK_MODE_FILTER_HUE_ROTATE_DEGREES = 180 as Degrees;
function cssHueRotate(
red: number,
green: number,
@@ -80,7 +83,11 @@ const cssInvert = (
return { r: invertedR, g: invertedG, b: invertedB };
};
export const applyDarkModeFilter = (color: string): string => {
export const applyDarkModeFilter = (color: string, enable = true): string => {
if (!enable) {
return color;
}
const cached = DARK_MODE_COLORS_CACHE?.get(color);
if (cached) {
return cached;
@@ -92,12 +99,17 @@ export const applyDarkModeFilter = (color: string): string => {
// order of operations matters
// (corresponds to "filter: invert(invertPercent) hue-rotate(hueDegrees)" in css)
const rgb = tc.toRgb();
const inverted = cssInvert(rgb.r, rgb.g, rgb.b, 93);
const inverted = cssInvert(
rgb.r,
rgb.g,
rgb.b,
DARK_MODE_FILTER_INVERT_PERCENT,
);
const rotated = cssHueRotate(
inverted.r,
inverted.g,
inverted.b,
180 as Degrees,
DARK_MODE_FILTER_HUE_ROTATE_DEGREES,
);
const result = rgbToHex(rotated.r, rotated.g, rotated.b, alpha);
@@ -109,6 +121,44 @@ export const applyDarkModeFilter = (color: string): string => {
return result;
};
const _reverseDarkModeInvert = (
r: number,
g: number,
b: number,
): { r: number; g: number; b: number } => {
const p = DARK_MODE_FILTER_INVERT_PERCENT / 100;
const denominator = 1 - 2 * p;
const restore = (color: number) =>
Math.round(clamp((color - 255 * p) / denominator, 0, 255));
return {
r: restore(r),
g: restore(g),
b: restore(b),
};
};
export const removeDarkModeFilter = (color: string): string => {
const tc = tinycolor(color);
const alpha = tc.getAlpha();
const rgb = tc.toRgb();
// 180deg hue rotation is its own inverse, so undo it before the inversion.
const rotatedBack = cssHueRotate(
rgb.r,
rgb.g,
rgb.b,
DARK_MODE_FILTER_HUE_ROTATE_DEGREES,
);
const restored = _reverseDarkModeInvert(
rotatedBack.r,
rotatedBack.g,
rotatedBack.b,
);
return rgbToHex(restored.r, restored.g, restored.b, alpha);
};
// ---------------------------------------------------------------------------
// Color palette
// ---------------------------------------------------------------------------
@@ -180,6 +230,11 @@ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
// quick picks defaults
// -----------------------------------------------------------------------------
/** number of slots in the color-picker top-picks strip — the strip layout is
* sized for exactly this many swatches, and pick customization (replace /
* reorder) preserves it */
export const COLOR_TOP_PICKS_SLOTS = 5;
// ORDER matters for positioning in quick picker
export const DEFAULT_ELEMENT_STROKE_PICKS = [
COLOR_PALETTE.black,
@@ -198,6 +253,33 @@ export const DEFAULT_ELEMENT_BACKGROUND_PICKS = [
COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
] as ColorTuple;
// ORDER matters for positioning in quick picker.
// Same as element background picks, with `transparent` (a no-op fill for the
// bucket tool) swapped for white.
export const BUCKET_FILL_BACKGROUND_PICKS = [
COLOR_PALETTE.white,
COLOR_PALETTE.red[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
COLOR_PALETTE.green[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
COLOR_PALETTE.blue[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
] as ColorTuple;
// sticky notes are their own color domain (own defaults, own picks)
export const DEFAULT_STICKY_NOTE_BG = "#ffdf6b";
export const STICKY_NOTE_STROKE_PICKS: ColorTuple =
DEFAULT_ELEMENT_STROKE_PICKS;
// ORDER matters for positioning in quick picker. Classic note colors; the
// first one is `DEFAULT_STICKY_NOTE_BG`. Never transparent.
export const STICKY_NOTE_BACKGROUND_PICKS = [
DEFAULT_STICKY_NOTE_BG,
COLOR_PALETTE.pink[1],
COLOR_PALETTE.green[1],
COLOR_PALETTE.blue[1],
COLOR_PALETTE.orange[1],
] as ColorTuple;
// ORDER matters for positioning in quick picker
export const DEFAULT_CANVAS_BACKGROUND_PICKS = [
COLOR_PALETTE.white,
@@ -278,6 +360,19 @@ export const rgbToHex = (r: number, g: number, b: number, a?: number) => {
return hex6;
};
/**
* @returns the color with its alpha replaced by `alpha` (as #RRGGBBAA), or the
* input unchanged if it's not a valid color
*/
export const setColorAlpha = (color: string, alpha: number): string => {
const tc = tinycolor(color);
if (!tc.isValid()) {
return color;
}
const { r, g, b } = tc.toRgb();
return rgbToHex(r, g, b, alpha);
};
/**
* @returns #RRGGBB or #RRGGBBAA based on color containing non-opaque alpha,
* null if not valid color
@@ -295,6 +390,17 @@ export const isTransparent = (color: string) => {
return tinycolor(color).getAlpha() === 0;
};
/**
* Whether the color is fully opaque (alpha exactly 1).
*
* NOT the negation of `isTransparent`, which is true only at alpha 0 — a
* partial-alpha color (`#RRGGBBAA`, `rgba(…, 0.5)`) is neither transparent
* (it renders) nor opaque (what's beneath shows through it).
*/
export const isOpaqueColor = (color: string) => {
return tinycolor(color).getAlpha() === 1;
};
// -----------------------------------------------------------------------------
// color contract helpers
// -----------------------------------------------------------------------------
+119 -10
View File
@@ -2,10 +2,16 @@ import type {
ExcalidrawElement,
FontFamilyValues,
} from "@excalidraw/element/types";
import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
import type {
AppProps,
AppState,
NormalizedZoomValue,
} from "@excalidraw/excalidraw/types";
import { COLOR_PALETTE } from "./colors";
export { DEFAULT_STICKY_NOTE_BG } from "./colors";
export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window;
@@ -16,6 +22,12 @@ export const APP_NAME = "Excalidraw";
// don't unintentionally create text elements that are wrapped to a few chars
// (happens a lot with fast clicks with the text tool)
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
// room left at each side of the visible canvas when a text stops growing at
// its width, or is brought into view once it wraps
export const TEXT_VIEWPORT_PADDING = 20; // px
// the widest a typed or pasted text gets before it wraps (in scene units, so
// at any zoom), however wide the view
export const TEXT_MAX_WRAP_WIDTH = 800; // px
export const DRAGGING_THRESHOLD = 10; // px
export const MINIMUM_ARROW_SIZE = 20; // px
export const LINE_CONFIRM_THRESHOLD = 8; // px
@@ -69,6 +81,7 @@ export enum EVENT {
POINTER_MOVE = "pointermove",
POINTER_DOWN = "pointerdown",
POINTER_UP = "pointerup",
POINTER_CANCEL = "pointercancel",
STATE_CHANGE = "statechange",
WHEEL = "wheel",
TOUCH_START = "touchstart",
@@ -211,7 +224,56 @@ export const FRAME_STYLE = {
export const MIN_FONT_SIZE = 1;
export const DEFAULT_FONT_SIZE = 20;
export const STICKY_NOTE_MIN_FONT_SIZE = 16;
export const STICKY_NOTE_MAX_FONT_SIZE = 512;
export const STICKY_NOTE_FALLBACK_FONT_SIZE = 28;
export const STICKY_NOTE_FONT_STEP = 2;
export const STICKY_NOTE_PADDING = 16;
/**
* The creation-date footer: a 20px text row under the label body plus a 12px
* gap above it, inside the note's bottom padding. Reserved for every note —
* also when `created` is unknown — so geometry never depends on data
* availability. The label is chosen by width bucket, never measured, so
* painting stays measurement-free (the server has no text measurer):
* `minBodyWidthForYear` is the worst case ("30 May 2026") in the system sans
* stack at 12px, with margin for wider fallbacks such as DejaVu Sans.
*/
/**
* The creation-date footer of a sticky note. The label body ends `height`
* above the note's bottom padding, and the date's baseline sits
* `baselineFromBottom` above the note's bottom edge — so the 12px glyphs
* (~9px above the baseline, ~3px below) end up roughly 11px from the edge
* with a ~13px gap to the label body above them.
*/
export const STICKY_NOTE_FOOTER = {
height: 20,
fontSize: 12,
fontFamily: "Helvetica, Arial, sans-serif",
baselineFromBottom: 14,
opacity: 1,
minBodyWidthForYear: 80,
} as const;
/**
* outer height → label body height: top + bottom padding + footer. The body
* is what the label is fitted into; a middle-aligned label is still centered
* in the whole padded note when it fits (see `computeBoundTextPosition`).
*/
export const STICKY_NOTE_BODY_INSET_Y =
STICKY_NOTE_PADDING * 2 + STICKY_NOTE_FOOTER.height;
export const DEFAULT_STICKY_NOTE_SIZE = 250;
// floor for a finalized note's width and base height; the UI floor is font-aware
// on top of it (see `getStickyNoteMinSize`)
export const STICKY_NOTE_MIN_SIZE = 75;
export const STICKY_NOTE_SHADOW_OFFSET = 3;
export const STICKY_NOTE_SHADOW_OPACITY = 0.16;
export const STICKY_NOTE_EDGE_SHADOW_WIDTH = 0.5;
export const STICKY_NOTE_EDGE_SHADOW_OPACITY = 0.08;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;
/** number of slots in the font-picker top-picks strip — pick customization
* (replace / reorder) preserves it. Must equal `DEFAULT_FONTS.length` in
* `packages/excalidraw/components/FontPicker/FontPicker.tsx` (enforced by
* `fontTopPicks.test.ts`) */
export const FONT_TOP_PICKS_SLOTS = 3;
export const DEFAULT_TEXT_ALIGN = "left";
export const DEFAULT_VERTICAL_ALIGN = "top";
export const DEFAULT_VERSION = "{version}";
@@ -299,9 +361,14 @@ export const TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000;
export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 100;
export const ZEN_MODE_TRANSITION_DURATION = 250;
export const ZOOM_STEP = 0.1;
export const MIN_ZOOM = 0.1;
export const MAX_ZOOM = 30;
/** 100% zoom, for computations that have no editor zoom to go by */
export const DEFAULT_ZOOM: AppState["zoom"] = Object.freeze({
value: 1 as NormalizedZoomValue,
});
export const HYPERLINK_TOOLTIP_DELAY = 300;
// Report a user inactive after IDLE_THRESHOLD milliseconds
@@ -337,9 +404,10 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
export const DEFAULT_IMAGE_OPTIONS: AppProps["imageOptions"] = {
maxWidthOrHeight: 1440,
maxFileSizeBytes: 4 * 1024 * 1024,
};
export const SVG_NS = "http://www.w3.org/2000/svg";
export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?>
@@ -370,6 +438,7 @@ export const TEXT_ALIGN = {
};
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
export const ELEMENT_PENDING_DRAW_SHAPE_OPACITY = 70;
// Radius represented as 25% of element's largest side (width/height).
// Used for LEGACY and PROPORTIONAL_RADIUS algorithms, or when the element is
@@ -403,11 +472,47 @@ export const ROUGHNESS = {
cartoonist: 2,
} as const;
export const STROKE_WIDTH = {
export type StrokeWidthKey = "thin" | "medium" | "bold";
export const STROKE_WIDTH_KEYS: readonly StrokeWidthKey[] = [
"thin",
"medium",
"bold",
];
export const STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
thin: 1,
medium: 2,
bold: 4,
extraBold: 8, // unused (may be introduced in the future)
};
// freedraw schema 2.0 uses thinner stroke, but to maintain backwards and
// forwards compatibility, instead of changing the shape renderer, we scale
// the stroke width by 1/2 (previous, thin was 1, medium 2 etc.)
//
// note that in the UI, STROKE_WIDTH.thin == FREEDRAW_STROKE_WIDTH.thin still
export const FREEDRAW_STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
thin: 0.5,
medium: 1,
bold: 2,
extraBold: 4,
} as const;
extraBold: 4, // legacy (may be used again in the future)
};
export const getStrokeWidthByKey = (
elementType: ExcalidrawElement["type"],
strokeWidthKey: StrokeWidthKey,
): ExcalidrawElement["strokeWidth"] => {
return elementType === "freedraw"
? FREEDRAW_STROKE_WIDTH[strokeWidthKey]
: STROKE_WIDTH[strokeWidthKey];
};
export const DEFAULT_ELEMENT_STROKE_WIDTH_KEY: StrokeWidthKey = "medium";
export const DEFAULT_ELEMENT_PROPS: {
strokeColor: ExcalidrawElement["strokeColor"];
@@ -422,7 +527,7 @@ export const DEFAULT_ELEMENT_PROPS: {
strokeColor: COLOR_PALETTE.black,
backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "solid",
strokeWidth: 2,
strokeWidth: STROKE_WIDTH[DEFAULT_ELEMENT_STROKE_WIDTH_KEY],
strokeStyle: "solid",
roughness: ROUGHNESS.artist,
opacity: 100,
@@ -459,8 +564,11 @@ export const TOOL_TYPE = {
hand: "hand",
frame: "frame",
magicframe: "magicframe",
stickynote: "stickynote",
embeddable: "embeddable",
laser: "laser",
autoshape: "autoshape",
bucketfill: "bucketfill",
} as const;
export const EDITOR_LS_KEYS = {
@@ -507,9 +615,10 @@ 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)",
} as const;
export const DEFAULT_STROKE_STREAMLINE = 0.5;
export const DEFAULT_STROKE_STREAMLINE_PRECISE = 0.2;
+2
View File
@@ -51,6 +51,7 @@ export const KEYS = {
SLASH: "/",
A: "a",
B: "b",
C: "c",
D: "d",
E: "e",
@@ -59,6 +60,7 @@ export const KEYS = {
H: "h",
I: "i",
L: "l",
N: "n",
O: "o",
P: "p",
Q: "q",
+5 -4
View File
@@ -1,4 +1,5 @@
import {
pointFrom,
pointFromPair,
type GlobalPoint,
type LocalPoint,
@@ -69,12 +70,12 @@ export const getGridPoint = (
x: number,
y: number,
gridSize: NullableGridSize,
): [number, number] => {
): GlobalPoint => {
if (gridSize) {
return [
return pointFrom<GlobalPoint>(
Math.round(x / gridSize) * gridSize,
Math.round(y / gridSize) * gridSize,
];
);
}
return [x, y];
return pointFrom<GlobalPoint>(x, y);
};
+17
View File
@@ -14,3 +14,20 @@ export const reseed = (seed: number) => {
};
export const randomId = () => (isTestEnv() ? `id${testIdBase++}` : nanoid());
/**
* Deterministic PRNG (mulberry32) for a given seed: same seed, same
* sequence — used where a render must be stable across frames and clients
* (e.g. the sticky note's corner jitter) and roughjs's `Random` is
* unsuitable (it degenerates for seed 0). Returns numbers in [0, 1).
*/
export const seededRandom = (seed: number) => {
let value = seed >>> 0;
return () => {
value += 0x6d2b79f5;
let next = value;
next = Math.imul(next ^ (next >>> 15), next | 1);
next ^= next + Math.imul(next ^ (next >>> 7), next | 61);
return ((next ^ (next >>> 14)) >>> 0) / 4294967296;
};
};
+33
View File
@@ -1,5 +1,10 @@
import {
getNearestScrollableContainer,
isInputLike,
isInteractive,
isToolIcon,
isTransparent,
isWritableElement,
mapFind,
reduceToCommonValue,
} from "@excalidraw/common";
@@ -11,6 +16,34 @@ import { throttleRAF } from "./utils";
type RafCallback = FrameRequestCallback;
describe("@excalidraw/common/utils", () => {
describe("cross-document element guards", () => {
it("uses constructors from the target element's window", () => {
const iframe = document.createElement("iframe");
document.body.append(iframe);
const iframeDocument = iframe.contentDocument!;
const iframeWindow = iframe.contentWindow!;
const textarea = iframeDocument.createElement("textarea");
const button = iframeDocument.createElement("button");
const toolIcon = iframeDocument.createElement("div");
toolIcon.className = "ToolIcon__icon";
expect(textarea).not.toBeInstanceOf(HTMLTextAreaElement);
expect(textarea).toBeInstanceOf(
(iframeWindow as Window & typeof globalThis).HTMLTextAreaElement,
);
expect(isInputLike(textarea)).toBe(true);
expect(isWritableElement(textarea)).toBe(true);
expect(isInteractive(button)).toBe(true);
expect(isToolIcon(toolIcon)).toBe(true);
iframeDocument.body.append(textarea);
expect(getNearestScrollableContainer(textarea)).toBe(iframeDocument);
iframe.remove();
});
});
describe("isTransparent()", () => {
it("should return true when color is rgb transparent", () => {
expect(isTransparent("#ff00")).toEqual(true);
+64 -199
View File
@@ -48,10 +48,23 @@ export const getDateTime = () => {
export const capitalizeString = (str: string) =>
str.charAt(0).toUpperCase() + str.slice(1);
const getTargetWindow = (
target: Element | EventTarget | null,
): (Window & typeof globalThis) | null =>
(target as (EventTarget & { ownerDocument?: Document | null }) | null)
?.ownerDocument?.defaultView ??
(typeof window === "undefined" ? null : window);
export const isToolIcon = (
target: Element | EventTarget | null,
): target is HTMLElement =>
target instanceof HTMLElement && target.className.includes("ToolIcon");
): target is HTMLElement => {
const targetWindow = getTargetWindow(target);
return (
!!targetWindow &&
target instanceof targetWindow.HTMLElement &&
target.className.includes("ToolIcon")
);
};
export const isInputLike = (
target: Element | EventTarget | null,
@@ -60,17 +73,26 @@ export const isInputLike = (
| HTMLTextAreaElement
| HTMLSelectElement
| HTMLBRElement
| HTMLDivElement =>
(target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
target instanceof HTMLBRElement || // newline in wysiwyg
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement;
| HTMLDivElement => {
const targetWindow = getTargetWindow(target);
return (
!!targetWindow &&
((target instanceof targetWindow.HTMLElement &&
target.dataset.type === "wysiwyg") ||
target instanceof targetWindow.HTMLBRElement || // newline in wysiwyg
target instanceof targetWindow.HTMLInputElement ||
target instanceof targetWindow.HTMLTextAreaElement ||
target instanceof targetWindow.HTMLSelectElement)
);
};
export const isInteractive = (target: Element | EventTarget | null) => {
const targetWindow = getTargetWindow(target);
return (
isInputLike(target) ||
(target instanceof Element && !!target.closest("label, button"))
(!!targetWindow &&
target instanceof targetWindow.Element &&
!!target.closest("label, button"))
);
};
@@ -80,16 +102,23 @@ export const isWritableElement = (
| HTMLInputElement
| HTMLTextAreaElement
| HTMLBRElement
| HTMLDivElement =>
(target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
target instanceof HTMLBRElement || // newline in wysiwyg
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLInputElement &&
(target.type === "text" ||
target.type === "number" ||
target.type === "password" ||
target.type === "search")) ||
(target instanceof HTMLElement && target.closest(".cm-editor") !== null);
| HTMLDivElement => {
const targetWindow = getTargetWindow(target);
return (
!!targetWindow &&
((target instanceof targetWindow.HTMLElement &&
target.dataset.type === "wysiwyg") ||
target instanceof targetWindow.HTMLBRElement || // newline in wysiwyg
target instanceof targetWindow.HTMLTextAreaElement ||
(target instanceof targetWindow.HTMLInputElement &&
(target.type === "text" ||
target.type === "number" ||
target.type === "password" ||
target.type === "search")) ||
(target instanceof targetWindow.HTMLElement &&
target.closest(".cm-editor") !== null))
);
};
export const getFontFamilyString = ({
fontFamily,
@@ -204,135 +233,6 @@ export const easeOut = (k: number) => {
return 1 - Math.pow(1 - k, 4);
};
const easeOutInterpolate = (from: number, to: number, progress: number) => {
return (to - from) * easeOut(progress) + from;
};
/**
* Animates values from `fromValues` to `toValues` using the requestAnimationFrame API.
* Executes the `onStep` callback on each step with the interpolated values.
* Returns a function that can be called to cancel the animation.
*
* @example
* // Example usage:
* const fromValues = { x: 0, y: 0 };
* const toValues = { x: 100, y: 200 };
* const onStep = ({x, y}) => {
* setState(x, y)
* };
* const onCancel = () => {
* console.log("Animation canceled");
* };
*
* const cancelAnimation = easeToValuesRAF({
* fromValues,
* toValues,
* onStep,
* onCancel,
* });
*
* // To cancel the animation:
* cancelAnimation();
*/
export const easeToValuesRAF = <
T extends Record<keyof T, number>,
K extends keyof T,
>({
fromValues,
toValues,
onStep,
duration = 250,
interpolateValue,
onStart,
onEnd,
onCancel,
}: {
fromValues: T;
toValues: T;
/**
* Interpolate a single value.
* Return undefined to be handled by the default interpolator.
*/
interpolateValue?: (
fromValue: number,
toValue: number,
/** no easing applied */
progress: number,
key: K,
) => number | undefined;
onStep: (values: T) => void;
duration?: number;
onStart?: () => void;
onEnd?: () => void;
onCancel?: () => void;
}) => {
let canceled = false;
let frameId = 0;
let startTime: number;
function step(timestamp: number) {
if (canceled) {
return;
}
if (startTime === undefined) {
startTime = timestamp;
onStart?.();
}
const elapsed = Math.min(timestamp - startTime, duration);
const factor = easeOut(elapsed / duration);
const newValues = {} as T;
Object.keys(fromValues).forEach((key) => {
const _key = key as keyof T;
const result = ((toValues[_key] - fromValues[_key]) * factor +
fromValues[_key]) as T[keyof T];
newValues[_key] = result;
});
onStep(newValues);
if (elapsed < duration) {
const progress = elapsed / duration;
const newValues = {} as T;
Object.keys(fromValues).forEach((key) => {
const _key = key as K;
const startValue = fromValues[_key];
const endValue = toValues[_key];
let result;
result = interpolateValue
? interpolateValue(startValue, endValue, progress, _key)
: easeOutInterpolate(startValue, endValue, progress);
if (result == null) {
result = easeOutInterpolate(startValue, endValue, progress);
}
newValues[_key] = result as T[K];
});
onStep(newValues);
frameId = window.requestAnimationFrame(step);
} else {
onStep(toValues);
onEnd?.();
}
}
frameId = window.requestAnimationFrame(step);
return () => {
onCancel?.();
canceled = true;
window.cancelAnimationFrame(frameId);
};
};
// https://github.com/lodash/lodash/blob/es/chunk.js
export const chunk = <T extends any>(
array: readonly T[],
@@ -381,7 +281,7 @@ export const updateActiveTool = (
}
| { type: "custom"; customType: string }
) & { locked?: boolean; fromSelection?: boolean }) & {
lastActiveToolBeforeEraser?: ActiveTool | null;
lastActiveTool?: ActiveTool | null;
},
): AppState["activeTool"] => {
if (data.type === "custom") {
@@ -396,9 +296,9 @@ export const updateActiveTool = (
return {
...appState.activeTool,
lastActiveTool:
data.lastActiveToolBeforeEraser === undefined
data.lastActiveTool === undefined
? appState.activeTool.lastActiveTool
: data.lastActiveToolBeforeEraser,
: data.lastActiveTool,
type: data.type,
customType: null,
locked: data.locked ?? appState.activeTool.locked,
@@ -473,6 +373,13 @@ const RE_RTL_CHECK = new RegExp(`^[^${RS_LTR_CHARS}]*[${RS_RTL_CHARS}]`);
*/
export const isRTL = (text: string) => RE_RTL_CHECK.test(text);
const RE_RTL_CHARS = new RegExp(`[${RS_RTL_CHARS}]`);
/**
* Checks whether the text has any RTL character, i.e. whether it may be laid
* out bidirectionally.
*/
export const hasRTLChars = (text: string) => RE_RTL_CHARS.test(text);
export const tupleToCoors = (
xyTuple: readonly [number, number],
): { x: number; y: number } => {
@@ -604,12 +511,14 @@ export const supportsEmoji = () => {
export const getNearestScrollableContainer = (
element: HTMLElement,
): HTMLElement | Document => {
const ownerDocument = element.ownerDocument;
const ownerWindow = ownerDocument.defaultView ?? window;
let parent = element.parentElement;
while (parent) {
if (parent === document.body) {
return document;
if (parent === ownerDocument.body) {
return ownerDocument;
}
const { overflowY } = window.getComputedStyle(parent);
const { overflowY } = ownerWindow.getComputedStyle(parent);
const hasScrollableContent = parent.scrollHeight > parent.clientHeight;
if (
hasScrollableContent &&
@@ -621,7 +530,7 @@ export const getNearestScrollableContainer = (
}
parent = parent.parentElement;
}
return document;
return ownerDocument;
};
export const focusNearestParent = (element: HTMLInputElement) => {
@@ -1279,50 +1188,6 @@ export const reduceToCommonValue = <T, R = T>(
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 = <F extends keyof FEATURE_FLAGS>(
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 = <F extends keyof FEATURE_FLAGS>(
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);
}
};
export const oneOf = <N extends string | number | symbol | null, H extends N>(
needle: N,
haystack: readonly H[],
+109
View File
@@ -0,0 +1,109 @@
import { BinaryHeap } from "../src/binary-heap";
describe("BinaryHeap", () => {
const numberHeap = () => new BinaryHeap<number>((n) => n);
const drain = (heap: BinaryHeap<number>) => {
const out: number[] = [];
while (heap.size() > 0) {
out.push(heap.pop()!);
}
return out;
};
describe("empty heap", () => {
it("has size 0", () => {
expect(numberHeap().size()).toBe(0);
});
it("pop() returns null", () => {
expect(numberHeap().pop()).toBe(null);
});
it("remove() is a no-op and does not throw", () => {
const heap = numberHeap();
expect(() => heap.remove(1)).not.toThrow();
expect(heap.size()).toBe(0);
});
});
describe("push / pop", () => {
it("tracks size as items are added and removed", () => {
const heap = numberHeap();
[3, 1, 2].forEach((n) => heap.push(n));
expect(heap.size()).toBe(3);
heap.pop();
expect(heap.size()).toBe(2);
});
it("pops a single pushed element back out", () => {
const heap = numberHeap();
heap.push(42);
expect(heap.pop()).toBe(42);
expect(heap.pop()).toBe(null);
});
it("always pops the smallest score first", () => {
const heap = numberHeap();
[5, 3, 8, 1, 9, 2, 7].forEach((n) => heap.push(n));
expect(drain(heap)).toEqual([1, 2, 3, 5, 7, 8, 9]);
});
it("handles duplicate scores", () => {
const heap = numberHeap();
[4, 1, 4, 1, 2].forEach((n) => heap.push(n));
expect(drain(heap)).toEqual([1, 1, 2, 4, 4]);
});
it("maintains the heap invariant for a large adversarial (reverse-sorted) input", () => {
const heap = numberHeap();
// pushing in descending order forces a sift-up on every insert
const input = Array.from({ length: 1000 }, (_, i) => 1000 - i);
input.forEach((n) => heap.push(n));
expect(drain(heap)).toEqual([...input].sort((a, b) => a - b));
});
});
describe("remove", () => {
it("removes an interior element and keeps the rest ordered", () => {
const heap = numberHeap();
[5, 3, 8, 1, 9].forEach((n) => heap.push(n));
heap.remove(8);
expect(heap.size()).toBe(4);
expect(drain(heap)).toEqual([1, 3, 5, 9]);
});
it("can remove the current minimum", () => {
const heap = numberHeap();
[5, 3, 8, 1, 9].forEach((n) => heap.push(n));
heap.remove(1);
expect(heap.size()).toBe(4);
expect(heap.pop()).toBe(3);
});
});
describe("rescoreElement", () => {
type Node = { id: string; f: number };
it("re-sorts a node after its score is lowered", () => {
const heap = new BinaryHeap<Node>((node) => node.f);
const a = { id: "a", f: 10 };
const b = { id: "b", f: 20 };
const c = { id: "c", f: 30 };
[a, b, c].forEach((node) => heap.push(node));
c.f = 5;
heap.rescoreElement(c);
expect(heap.pop()).toBe(c);
expect(heap.pop()).toBe(a);
expect(heap.pop()).toBe(b);
});
});
});
+2 -1
View File
@@ -1,7 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/types"
"outDir": "./dist/types",
"rootDir": "../"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
+2 -1
View File
@@ -64,6 +64,7 @@
},
"dependencies": {
"@excalidraw/common": "0.18.0",
"@excalidraw/math": "0.18.0"
"@excalidraw/math": "0.18.0",
"@excalidraw/fractional-indexing": "3.3.0"
}
}
+14 -38
View File
@@ -51,7 +51,7 @@ const getNonDeletedElements = <T extends ExcalidrawElement>(
allElements: readonly T[],
) => {
const elementsMap = new Map() as NonDeletedSceneElementsMap;
const elements: T[] = [];
const elements: NonDeleted<T>[] = [];
for (const element of allElements) {
if (!element.isDeleted) {
elements.push(element as NonDeleted<T>);
@@ -187,7 +187,7 @@ export class Scene {
// selection-related options
includeBoundTextElement?: boolean;
includeElementsInFrames?: boolean;
}): NonDeleted<ExcalidrawElement>[] {
}): NonDeletedExcalidrawElement[] {
const hash = hashSelectionOpts(opts);
const elements = opts?.elements || this.nonDeletedElements;
@@ -338,29 +338,20 @@ export class Scene {
this.callbacks.clear();
}
insertElementAtIndex(element: ExcalidrawElement, index: number) {
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
);
}
const nextElements = [
...this.elements.slice(0, index),
element,
...this.elements.slice(index),
];
syncMovedIndices(nextElements, arrayToMap([element]));
this.replaceAllElements(nextElements);
}
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
/** low-level - generally use app.insertNewElements() */
insertElementsAtIndex(
elements: ExcalidrawElement[],
/** null indicates end of the array */
index: number | null,
) {
if (!elements.length) {
return;
}
if (index === null) {
index = this.elements.length;
}
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
@@ -378,24 +369,9 @@ export class Scene {
this.replaceAllElements(nextElements);
}
/** low-level - generally use app.insertNewElement() */
insertElement = (element: ExcalidrawElement) => {
const index = element.frameId
? this.getElementIndex(element.frameId)
: this.elements.length;
this.insertElementAtIndex(element, index);
};
insertElements = (elements: ExcalidrawElement[]) => {
if (!elements.length) {
return;
}
const index = elements[0]?.frameId
? this.getElementIndex(elements[0].frameId)
: this.elements.length;
this.insertElementsAtIndex(elements, index);
this.insertElementsAtIndex([element], null);
};
getElementIndex(elementId: string) {
@@ -14,6 +14,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"type": "arrow",
},
],
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -51,6 +52,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"type": "arrow",
},
],
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -83,6 +85,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"created": 1,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
@@ -144,6 +147,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"created": 1,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
@@ -210,6 +214,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"type": "arrow",
},
],
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -242,6 +247,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": [
{
"id": "id50",
@@ -249,6 +255,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
},
],
"containerId": null,
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -259,6 +266,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"id": Any<String>,
"index": "a0",
"isDeleted": false,
"labelPosition": null,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -288,6 +296,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": [
{
"id": "id50",
@@ -295,6 +304,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
},
],
"containerId": null,
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -305,6 +315,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"id": Any<String>,
"index": "a1",
"isDeleted": false,
"labelPosition": null,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -339,6 +350,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"type": "text",
},
],
"created": 1,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
@@ -400,8 +412,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": "id50",
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -412,6 +426,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"id": Any<String>,
"index": "a3",
"isDeleted": false,
"labelPosition": 0.5,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -446,6 +461,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"type": "text",
},
],
"created": 1,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
@@ -507,8 +523,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": "id39",
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -519,6 +537,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"id": Any<String>,
"index": "a1",
"isDeleted": false,
"labelPosition": 0.5,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -553,6 +572,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"type": "arrow",
},
],
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -590,6 +610,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"type": "arrow",
},
],
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -627,6 +648,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"type": "text",
},
],
"created": 1,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
@@ -688,8 +710,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": "id43",
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -700,6 +724,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"id": Any<String>,
"index": "a1",
"isDeleted": false,
"labelPosition": 0.5,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -729,6 +754,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": [
{
"id": "id43",
@@ -736,6 +762,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
},
],
"containerId": null,
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -746,6 +773,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"id": Any<String>,
"index": "a2",
"isDeleted": false,
"labelPosition": null,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -775,6 +803,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": [
{
"id": "id43",
@@ -782,6 +811,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
},
],
"containerId": null,
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -792,6 +822,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"id": Any<String>,
"index": "a3",
"isDeleted": false,
"labelPosition": null,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -821,6 +852,7 @@ exports[`Test Transform > should not allow duplicate ids 1`] = `
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -853,6 +885,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"created": 1,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
@@ -900,6 +933,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"created": 1,
"customData": undefined,
"elbowed": false,
"endArrowhead": "triangle",
@@ -947,6 +981,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"created": 1,
"customData": undefined,
"endArrowhead": null,
"endBinding": null,
@@ -994,6 +1029,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"created": 1,
"customData": undefined,
"endArrowhead": null,
"endBinding": null,
@@ -1041,6 +1077,7 @@ exports[`Test Transform > should transform regular shapes 1`] = `
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -1073,6 +1110,7 @@ exports[`Test Transform > should transform regular shapes 2`] = `
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -1105,6 +1143,7 @@ exports[`Test Transform > should transform regular shapes 3`] = `
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -1137,6 +1176,7 @@ exports[`Test Transform > should transform regular shapes 4`] = `
"angle": 0,
"backgroundColor": "#c0eb75",
"boundElements": null,
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -1169,6 +1209,7 @@ exports[`Test Transform > should transform regular shapes 5`] = `
"angle": 0,
"backgroundColor": "#ffc9c9",
"boundElements": null,
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -1201,6 +1242,7 @@ exports[`Test Transform > should transform regular shapes 6`] = `
"angle": 0,
"backgroundColor": "#a5d8ff",
"boundElements": null,
"created": 1,
"customData": undefined,
"fillStyle": "cross-hatch",
"frameId": null,
@@ -1233,8 +1275,10 @@ exports[`Test Transform > should transform text element 1`] = `
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": null,
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -1245,6 +1289,7 @@ exports[`Test Transform > should transform text element 1`] = `
"id": Any<String>,
"index": "a0",
"isDeleted": false,
"labelPosition": null,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -1274,8 +1319,10 @@ exports[`Test Transform > should transform text element 2`] = `
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": null,
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -1286,6 +1333,7 @@ exports[`Test Transform > should transform text element 2`] = `
"id": Any<String>,
"index": "a1",
"isDeleted": false,
"labelPosition": null,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -1324,6 +1372,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"type": "arrow",
},
],
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -1363,6 +1412,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"type": "text",
},
],
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -1406,6 +1456,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"type": "arrow",
},
],
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -1453,6 +1504,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"type": "arrow",
},
],
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -1492,6 +1544,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"type": "text",
},
],
"created": 1,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
@@ -1560,6 +1613,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"type": "text",
},
],
"created": 1,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
@@ -1619,8 +1673,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": "B",
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -1633,6 +1689,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"id": Any<String>,
"index": "a6",
"isDeleted": false,
"labelPosition": null,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -1662,8 +1719,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": "A",
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -1676,6 +1735,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"id": Any<String>,
"index": "a7",
"isDeleted": false,
"labelPosition": null,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -1705,8 +1765,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": "Alice",
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -1719,6 +1781,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"id": Any<String>,
"index": "a8",
"isDeleted": false,
"labelPosition": null,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -1748,8 +1811,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": "Bob",
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -1762,6 +1827,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"id": Any<String>,
"index": "a9",
"isDeleted": false,
"labelPosition": null,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -1791,8 +1857,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": "Bob_Alice",
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -1803,6 +1871,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"id": Any<String>,
"index": "aA",
"isDeleted": false,
"labelPosition": 0.5,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -1832,8 +1901,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": "Bob_B",
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -1844,6 +1915,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"id": Any<String>,
"index": "aB",
"isDeleted": false,
"labelPosition": 0.5,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -1859,12 +1931,12 @@ exports[`Test Transform > should transform the elements correctly when linear el
"textAlign": "center",
"type": "text",
"updated": 1,
"version": 3,
"version": 2,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 100,
"x": 27.016999999999996,
"y": 66.5,
"x": -50,
"y": -12.5,
}
`;
@@ -1878,6 +1950,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"type": "text",
},
],
"created": 1,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
@@ -1930,6 +2003,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"type": "text",
},
],
"created": 1,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
@@ -1982,6 +2056,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"type": "text",
},
],
"created": 1,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
@@ -2034,6 +2109,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"type": "text",
},
],
"created": 1,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
@@ -2081,8 +2157,10 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": "id25",
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -2093,6 +2171,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"id": Any<String>,
"index": "a4",
"isDeleted": false,
"labelPosition": 0.5,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -2122,8 +2201,10 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": "id26",
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -2134,6 +2215,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"id": Any<String>,
"index": "a5",
"isDeleted": false,
"labelPosition": 0.5,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -2163,8 +2245,10 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": "id27",
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -2175,6 +2259,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"id": Any<String>,
"index": "a6",
"isDeleted": false,
"labelPosition": 0.5,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -2205,8 +2290,10 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": "id28",
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -2217,6 +2304,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"id": Any<String>,
"index": "a7",
"isDeleted": false,
"labelPosition": 0.5,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -2252,6 +2340,7 @@ exports[`Test Transform > should transform to text containers when label provide
"type": "text",
},
],
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -2289,6 +2378,7 @@ exports[`Test Transform > should transform to text containers when label provide
"type": "text",
},
],
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -2326,6 +2416,7 @@ exports[`Test Transform > should transform to text containers when label provide
"type": "text",
},
],
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -2363,6 +2454,7 @@ exports[`Test Transform > should transform to text containers when label provide
"type": "text",
},
],
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -2400,6 +2492,7 @@ exports[`Test Transform > should transform to text containers when label provide
"type": "text",
},
],
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -2437,6 +2530,7 @@ exports[`Test Transform > should transform to text containers when label provide
"type": "text",
},
],
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
@@ -2469,8 +2563,10 @@ exports[`Test Transform > should transform to text containers when label provide
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": "id13",
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -2481,6 +2577,7 @@ exports[`Test Transform > should transform to text containers when label provide
"id": Any<String>,
"index": "a6",
"isDeleted": false,
"labelPosition": null,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -2510,8 +2607,10 @@ exports[`Test Transform > should transform to text containers when label provide
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": "id14",
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -2522,6 +2621,7 @@ exports[`Test Transform > should transform to text containers when label provide
"id": Any<String>,
"index": "a7",
"isDeleted": false,
"labelPosition": null,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -2552,8 +2652,10 @@ exports[`Test Transform > should transform to text containers when label provide
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": "id15",
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -2564,6 +2666,7 @@ exports[`Test Transform > should transform to text containers when label provide
"id": Any<String>,
"index": "a8",
"isDeleted": false,
"labelPosition": null,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -2596,8 +2699,10 @@ exports[`Test Transform > should transform to text containers when label provide
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": "id16",
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -2608,6 +2713,7 @@ exports[`Test Transform > should transform to text containers when label provide
"id": Any<String>,
"index": "a9",
"isDeleted": false,
"labelPosition": null,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -2638,8 +2744,10 @@ exports[`Test Transform > should transform to text containers when label provide
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": "id17",
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -2650,6 +2758,7 @@ exports[`Test Transform > should transform to text containers when label provide
"id": Any<String>,
"index": "aA",
"isDeleted": false,
"labelPosition": null,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -2681,8 +2790,10 @@ exports[`Test Transform > should transform to text containers when label provide
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"baseFontSize": null,
"boundElements": null,
"containerId": "id18",
"created": 1,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
@@ -2693,6 +2804,7 @@ exports[`Test Transform > should transform to text containers when label provide
"id": Any<String>,
"index": "aB",
"isDeleted": false,
"labelPosition": null,
"lineHeight": 1.25,
"link": null,
"locked": false,
@@ -0,0 +1,60 @@
import { ROUNDNESS } from "@excalidraw/common";
import type { AppState } from "@excalidraw/excalidraw/types";
import { Scene } from "../Scene";
import { addNewNodes } from "../flowchart";
import { newStickyNoteElement } from "../newElement";
import { isFlowchartNodeElement, isStickyNoteElement } from "../typeChecks";
describe("flowchart", () => {
it("creates connected sticky notes", () => {
const sticky = newStickyNoteElement({
type: "stickynote",
x: 100,
y: 100,
width: 240,
height: 260,
baseHeight: 220,
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
roughness: 2,
backgroundColor: "#ffec99",
strokeColor: "#1e1e1e",
strokeWidth: 2,
});
const scene = new Scene([sticky], { skipValidation: true });
const {
nodes: [nextNode, bindingArrow],
} = addNewNodes(
sticky,
{
currentItemEndArrowhead: "arrow",
} as AppState,
"right",
scene,
1,
);
expect(isFlowchartNodeElement(sticky)).toBe(true);
expect(isFlowchartNodeElement(nextNode)).toBe(true);
expect(isStickyNoteElement(nextNode)).toBe(true);
expect(nextNode).toMatchObject({
type: "stickynote",
x: sticky.x + sticky.width + 100,
y: sticky.y,
width: sticky.width,
height: sticky.height,
baseHeight: sticky.baseHeight,
roundness: sticky.roundness,
roughness: sticky.roughness,
backgroundColor: sticky.backgroundColor,
strokeColor: sticky.strokeColor,
strokeWidth: sticky.strokeWidth,
});
expect(bindingArrow).toMatchObject({
type: "arrow",
startBinding: { elementId: sticky.id },
endBinding: { elementId: nextNode.id },
});
});
});
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,30 @@
import {
DEFAULT_STICKY_NOTE_BG,
DEFAULT_STICKY_NOTE_SIZE,
STICKY_NOTE_MIN_FONT_SIZE,
STICKY_NOTE_MIN_SIZE,
getUpdatedTimestamp,
} from "@excalidraw/common";
import { pointFrom } from "@excalidraw/math";
import { vi } from "vitest";
import {
newElement,
newEmbeddableElement,
newFreeDrawElement,
newIframeElement,
} from "../newElement";
import {
convertToExcalidrawElements,
type ExcalidrawElementSkeleton,
} from "../transform";
import type { ExcalidrawArrowElement } from "../types";
import type {
ExcalidrawArrowElement,
ExcalidrawStickyNoteElement,
ExcalidrawTextElement,
} from "../types";
const opts = { regenerateIds: false };
@@ -965,4 +983,208 @@ describe("Test Transform", () => {
});
});
});
describe("creation timestamps", () => {
const createElements = () => [
{
...newElement({ type: "rectangle", x: 0, y: 0 }),
type: "rectangle" as const,
},
newFreeDrawElement({
type: "freedraw",
x: 0,
y: 0,
simulatePressure: false,
}),
newIframeElement({ type: "iframe", x: 0, y: 0 }),
newEmbeddableElement({ type: "embeddable", x: 0, y: 0 }),
];
it("assigns fresh timestamps when regenerating IDs without mutating the input", () => {
const elements = createElements().map((element) => ({
...element,
created: 123,
}));
const converted = convertToExcalidrawElements(elements);
expect(converted).toHaveLength(elements.length);
converted.forEach((element, index) => {
expect(element.id).not.toBe(elements[index].id);
expect(element.created).toBe(getUpdatedTimestamp());
expect(elements[index].created).toBe(123);
});
});
it.each([123, 0, null])(
"preserves an explicit timestamp of %s when retaining IDs",
(created) => {
const elements = createElements().map((element) => ({
...element,
created,
}));
const converted = convertToExcalidrawElements(elements, opts);
converted.forEach((element, index) => {
expect(element.id).toBe(elements[index].id);
expect(element.created).toBe(created);
});
},
);
it("defaults missing timestamps when retaining IDs, as every type goes through a constructor", () => {
// models skeletons persisted before `created` existed; the declared
// skeleton type is a complete element
const elements = createElements().map(
({ created, ...element }) => element,
) as unknown as ExcalidrawElementSkeleton[];
const converted = convertToExcalidrawElements(elements, opts);
converted.forEach((element, index) => {
expect(element.id).toBe(elements[index].id);
expect(element.created).toBe(getUpdatedTimestamp());
expect(elements[index]).not.toHaveProperty("created");
});
});
it("assigns fresh timestamps to generated labels and binding endpoints", () => {
const elements: ExcalidrawElementSkeleton[] = [
{
type: "arrow",
x: 100,
y: 100,
created: 123,
// fragments cannot carry a creation time; they are always new
label: { text: "label" },
start: { type: "rectangle" },
end: { type: "text", text: "end" },
},
];
const converted = convertToExcalidrawElements(elements);
expect(converted).toHaveLength(4);
expect(converted.map((element) => element.created)).toEqual(
Array(4).fill(getUpdatedTimestamp()),
);
});
});
describe("sticky notes", () => {
const find = <
T extends ExcalidrawStickyNoteElement | ExcalidrawTextElement,
>(
elements: readonly { type: string }[],
type: T["type"],
) => elements.find((element) => element.type === type) as T;
it("creates a finalized note with the sticky defaults", () => {
const elements = convertToExcalidrawElements(
[{ type: "stickynote", x: 100, y: 100 }],
opts,
);
const note = find<ExcalidrawStickyNoteElement>(elements, "stickynote");
expect(elements).toHaveLength(1);
expect(note.width).toBe(DEFAULT_STICKY_NOTE_SIZE);
expect(note.height).toBe(DEFAULT_STICKY_NOTE_SIZE);
expect(note.baseHeight).toBe(DEFAULT_STICKY_NOTE_SIZE);
expect(note.backgroundColor).toBe(DEFAULT_STICKY_NOTE_BG);
expect(note.fillStyle).toBe("solid");
});
it("enforces the note invariants on the given properties", () => {
const note = find<ExcalidrawStickyNoteElement>(
convertToExcalidrawElements(
[
{
type: "stickynote",
x: 0,
y: 0,
width: 10,
height: 300,
backgroundColor: "transparent",
strokeColor: "transparent",
},
],
opts,
),
"stickynote",
);
expect(note.width).toBe(STICKY_NOTE_MIN_SIZE);
expect(note.height).toBe(300);
expect(note.baseHeight).toBe(300);
expect(note.backgroundColor).toBe(DEFAULT_STICKY_NOTE_BG);
expect(note.strokeColor).not.toBe("transparent");
});
it("gives the note the color of a label that sets its own", () => {
const elements = convertToExcalidrawElements(
[
{
type: "stickynote",
x: 0,
y: 0,
strokeColor: "#1971c2",
label: { text: "hello", strokeColor: "#e03131" },
},
],
opts,
);
const note = find<ExcalidrawStickyNoteElement>(elements, "stickynote");
const label = find<ExcalidrawTextElement>(elements, "text");
expect(label.strokeColor).toBe("#e03131");
expect(note.strokeColor).toBe("#e03131");
});
it("binds a label whose font size becomes the note's ceiling", () => {
const elements = convertToExcalidrawElements(
[
{
type: "stickynote",
x: 100,
y: 100,
strokeColor: "#1971c2",
label: { text: "hello", fontSize: 28 },
},
],
opts,
);
const note = find<ExcalidrawStickyNoteElement>(elements, "stickynote");
const label = find<ExcalidrawTextElement>(elements, "text");
expect(elements).toHaveLength(2);
expect(label.containerId).toBe(note.id);
expect(note.boundElements).toEqual([{ type: "text", id: label.id }]);
expect(label.baseFontSize).toBe(28);
expect(label.fontSize).toBe(28);
// the label is the note's visible text: it takes the note's stroke
expect(label.strokeColor).toBe("#1971c2");
expect(note.height).toBe(DEFAULT_STICKY_NOTE_SIZE);
});
it("grows the note for a label that overflows at the minimum font size", () => {
const elements = convertToExcalidrawElements(
[
{
type: "stickynote",
x: 0,
y: 0,
label: {
text: Array(40).fill("abcdefghijklmnopqrstuvwx").join("\n"),
fontSize: 28,
},
},
],
opts,
);
const note = find<ExcalidrawStickyNoteElement>(elements, "stickynote");
const label = find<ExcalidrawTextElement>(elements, "text");
expect(label.fontSize).toBe(STICKY_NOTE_MIN_FONT_SIZE);
expect(label.baseFontSize).toBe(28);
expect(note.baseHeight).toBe(DEFAULT_STICKY_NOTE_SIZE);
expect(note.height).toBeGreaterThan(DEFAULT_STICKY_NOTE_SIZE);
});
});
});
+8 -6
View File
@@ -4,10 +4,12 @@ import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds";
import { getSelectedElementsByGroup } from "./groups";
import { getNonDeletedElements } from ".";
import type { Scene } from "./Scene";
import type { BoundingBox } from "./bounds";
import type { ExcalidrawElement } from "./types";
import type { ExcalidrawElement, NonDeletedExcalidrawElement } from "./types";
export interface Alignment {
position: "start" | "center" | "end";
@@ -15,16 +17,16 @@ export interface Alignment {
}
export const alignElements = (
selectedElements: ExcalidrawElement[],
selectedElements: NonDeletedExcalidrawElement[],
alignment: Alignment,
scene: Scene,
appState: Readonly<AppState>,
): ExcalidrawElement[] => {
const groups: ExcalidrawElement[][] = getSelectedElementsByGroup(
): NonDeletedExcalidrawElement[] => {
const groups = getSelectedElementsByGroup(
selectedElements,
scene.getNonDeletedElementsMap(),
appState,
);
).map(getNonDeletedElements); // Nothing to align on deleted elements
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
return groups.flatMap((group) => {
@@ -50,7 +52,7 @@ export const alignElements = (
};
const calculateTranslation = (
group: ExcalidrawElement[],
group: readonly ExcalidrawElement[],
selectionBoundingBox: BoundingBox,
{ axis, position }: Alignment,
): { x: number; y: number } => {
+288
View File
@@ -0,0 +1,288 @@
/**
* Text bound to an arrow endpoint — creating a text element that reads as a
* label for the tip of an arrow, placed and anchored so the arrow itself never
* moves.
*
* The App-side interaction (hover affordance, hit gating, creation/drag entry
* points) lives in `App.arrowText.ts`; the generic binding bookkeeping these
* helpers rely on stays in `binding.ts`.
*/
import { TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
import {
pointDistance,
pointFromVector,
pointsEqual,
vector,
vectorDot,
vectorFromPoint,
vectorNormalize,
vectorScale,
} from "@excalidraw/math";
import type { GlobalPoint } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
import { getBindingGap, normalizeFixedPoint } from "./binding";
import {
HEADING_DOWN,
HEADING_LEFT,
HEADING_RIGHT,
HEADING_UP,
compareHeading,
} from "./heading";
import { LinearElementEditor } from "./linearElementEditor";
import { getTextAnchorRatios } from "./newElement";
import { isArrowElement, isElbowArrow } from "./typeChecks";
import type { Heading } from "./heading";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawTextElement,
FixedPoint,
NonDeleted,
NonDeletedExcalidrawElement,
TextAlign,
VerticalAlign,
} from "./types";
export type ArrowEndpoint = {
arrow: NonDeleted<ExcalidrawArrowElement>;
startOrEnd: "start" | "end";
};
/**
* Finds a free (unbound) arrow endpoint under the cursor, front-most first.
* Used by the text tool to offer creating a text label anchored to the tip of
* an arrow.
*/
export const getUnboundArrowEndpointAtPoint = (
scenePointer: GlobalPoint,
elements: readonly NonDeletedExcalidrawElement[],
elementsMap: ElementsMap,
zoom: AppState["zoom"],
): ArrowEndpoint | null => {
// front to back, so the top-most arrow wins when endpoints overlap
for (let index = elements.length - 1; index >= 0; index--) {
const element = elements[index];
if (
!isArrowElement(element) ||
element.locked ||
element.points.length < 2
) {
continue;
}
// prefer the end (arrowhead) over the start when both are within range
for (const startOrEnd of ["end", "start"] as const) {
if (element[startOrEnd === "start" ? "startBinding" : "endBinding"]) {
continue;
}
// a zero-length tail has no direction to place a text along, so
// `getTextBindingForArrowEndpoint` would refuse the endpoint — don't
// offer it at all rather than highlight something a click won't honor
if (
pointsEqual(
element.points[
startOrEnd === "start" ? 0 : element.points.length - 1
],
element.points[
startOrEnd === "start" ? 1 : element.points.length - 2
],
)
) {
continue;
}
const point = LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
startOrEnd === "start" ? 0 : -1,
elementsMap,
);
// same reach as grabbing a point handle in the linear editor
// (`LinearElementEditor.getPointIndexUnderCursor`), which can't be
// reused here: it reports whichever point is nearest, with no way to
// skip an endpoint that is already bound and fall through to the other
if (
pointDistance(scenePointer, point) * zoom.value <
LinearElementEditor.POINT_HANDLE_SIZE + 1
) {
return { arrow: element, startOrEnd };
}
}
}
return null;
};
/**
* Describes how a text element should be created so that it reads as a label
* for the given arrow endpoint.
*
* The arrow is treated as fixed: rather than routing the arrow to the text, we
* pick the side of the text the arrow should attach to (the one it already
* points at), and place the text so that side's midpoint lands on the existing
* endpoint. The alignment returned pins that same midpoint while the text is
* typed, so the arrow doesn't swing around during editing.
*/
export const getTextBindingForArrowEndpoint = (
arrow: NonDeleted<ExcalidrawArrowElement>,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
/**
* stroke width the text will be created with — the binding gap is derived
* from the *bind target*, so using the arrow's would offset the anchor by
* half the difference between the two
*/
targetStrokeWidth: number,
): {
/** the text-local ratio the arrow binds to (a side midpoint) */
fixedPoint: FixedPoint;
textAlign: TextAlign;
verticalAlign: VerticalAlign;
/** scene position the text's bound side midpoint should sit at */
anchor: GlobalPoint;
} | null => {
const endpoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startOrEnd === "start" ? 0 : -1,
elementsMap,
);
const neighbor = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startOrEnd === "start" ? 1 : -2,
elementsMap,
);
if (pointsEqual(endpoint, neighbor)) {
return null;
}
// The direction the arrow travels as it reaches this endpoint, snapped to
// the dominant axis — i.e. the direction the text should extend away from
// the tip.
//
// Deliberately not `vectorToHeading`: its tie-break resolves an exact 45°
// vector to a direction the arrow isn't travelling in at all (a perfectly
// down-right vector yields UP), which would put the text on the wrong side.
// Here a tie picks the horizontal axis, always with the correct sign.
const direction = vectorFromPoint(endpoint, neighbor);
const heading: Heading =
Math.abs(direction[0]) >= Math.abs(direction[1])
? direction[0] >= 0
? HEADING_RIGHT
: HEADING_LEFT
: direction[1] >= 0
? HEADING_DOWN
: HEADING_UP;
// Keep the tip off the text's bounding box by the usual binding gap so the
// arrowhead doesn't collide with the glyphs. Must match the gap
// `updateBoundPoint` will resolve the binding with, or the first text update
// shifts the arrow. Elbow arrows terminate on the fixed point itself rather
// than gap-outside the outline, so offsetting there would just shift them.
const gap = isElbowArrow(arrow)
? 0
: getBindingGap({ strokeWidth: targetStrokeWidth });
// How far back along the arrow the bound side has to sit for the tip to
// stay exactly where it is.
//
// `updateBoundPoint` resolves an orbit binding by intersecting the arrow's
// own line with the target's outline grown by `gap`; it does not step along
// the bound side's normal. Offsetting the anchor perpendicular to the side
// would therefore leave the tip `gap * tan(angle)` off to one side on a
// diagonal arrow. Walking back along the arrow instead keeps the anchor both
// `gap` clear of the tip and collinear with the arrow, so the intersection
// lands back on the tip itself.
//
// `heading` is the dominant axis of the arrow direction, so it is never more
// than 45° away from it and the divisor stays >= cos(45°).
const awayFromTip = vectorNormalize(vectorFromPoint(neighbor, endpoint));
const alongHeading = Math.abs(
vectorDot(awayFromTip, vector(heading[0], heading[1])),
);
const anchor = pointFromVector(
vectorScale(awayFromTip, -gap / alongHeading),
endpoint,
);
if (compareHeading(heading, HEADING_RIGHT)) {
// text sits to the right of the tip -> bind its left side
return {
fixedPoint: normalizeFixedPoint([0, 0.5]),
anchor,
textAlign: TEXT_ALIGN.LEFT as TextAlign,
verticalAlign: VERTICAL_ALIGN.MIDDLE as VerticalAlign,
};
}
if (compareHeading(heading, HEADING_LEFT)) {
// text sits to the left of the tip -> bind its right side
return {
fixedPoint: normalizeFixedPoint([1, 0.5]),
anchor,
textAlign: TEXT_ALIGN.RIGHT as TextAlign,
verticalAlign: VERTICAL_ALIGN.MIDDLE as VerticalAlign,
};
}
if (compareHeading(heading, HEADING_DOWN)) {
// text sits below the tip -> bind its top side
return {
fixedPoint: normalizeFixedPoint([0.5, 0]),
anchor,
textAlign: TEXT_ALIGN.CENTER as TextAlign,
verticalAlign: VERTICAL_ALIGN.TOP as VerticalAlign,
};
}
// text sits above the tip -> bind its bottom side
return {
fixedPoint: normalizeFixedPoint([0.5, 1]),
anchor,
textAlign: TEXT_ALIGN.CENTER as TextAlign,
verticalAlign: VERTICAL_ALIGN.BOTTOM as VerticalAlign,
};
};
/**
* Whether an arrow endpoint is bound to this text — i.e. the text serves as an
* arrow-endpoint label. Checked on the arrows' own bindings: the text's
* `boundElements` only says an arrow relates to it, while the binding on the
* arrow is the authoritative side of the relationship.
*/
export const isEndpointBoundText = (
text: ExcalidrawTextElement,
elementsMap: ElementsMap,
): boolean =>
!!text.boundElements?.some(({ id, type }) => {
if (type !== "arrow") {
return false;
}
const arrow = elementsMap.get(id);
return (
isArrowElement(arrow) &&
(arrow.startBinding?.elementId === text.id ||
arrow.endBinding?.elementId === text.id)
);
});
/** the anchor a text bound to an arrow endpoint grows away from */
export const getEndpointBoundTextDragAnchor = (
newElement: ExcalidrawTextElement,
) => {
const anchorRatio = getTextAnchorRatios(newElement).x;
return {
anchorRatio,
anchorX: newElement.x + newElement.width * anchorRatio,
};
};
+26 -10
View File
@@ -10,7 +10,6 @@ import {
getBindingGap,
getGlobalFixedPointForBindableElement,
isBindingEnabled,
maxBindingDistance_simple,
unbindBindingElement,
updateBoundPoint,
} from "../binding";
@@ -20,13 +19,15 @@ import {
isElbowArrow,
} from "../typeChecks";
import { LinearElementEditor } from "../linearElementEditor";
import { getHoveredElementForFocusPoint, hitElementItself } from "../collision";
import { getHoveredElementForBinding, hitElementItself } from "../collision";
import { moveArrowAboveBindable } from "../zindex";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
NonDeleted,
FixedPointBinding,
NonDeletedSceneElementsMap,
PointsPositionUpdates,
} from "../types";
@@ -92,7 +93,7 @@ export const isFocusPointVisible = (
element: bindableElement,
elementsMap,
point: focusPoint,
threshold: getBindingGap(bindableElement, arrow),
threshold: getBindingGap(bindableElement),
overrideShouldTestInside: true,
})
);
@@ -100,7 +101,7 @@ export const isFocusPointVisible = (
// Updates the arrow endpoints in "orbit" configuration
const focusPointUpdate = (
arrow: ExcalidrawArrowElement,
arrow: NonDeleted<ExcalidrawArrowElement>,
bindableElement: ExcalidrawBindableElement | null,
isStartBinding: boolean,
elementsMap: NonDeletedSceneElementsMap,
@@ -110,10 +111,17 @@ const focusPointUpdate = (
) => {
const pointUpdates = new Map();
const originalAdjacentBinding =
appState.selectedLinearElement?.initialState
.arrowOtherEndpointInitialBinding;
const bindingField = isStartBinding ? "startBinding" : "endBinding";
const adjacentBindingField = isStartBinding ? "endBinding" : "startBinding";
let currentBinding = arrow[bindingField];
let adjacentBinding = arrow[adjacentBindingField];
let adjacentBinding =
originalAdjacentBinding?.mode === "orbit" &&
arrow[adjacentBindingField]?.mode === "inside"
? originalAdjacentBinding
: arrow[adjacentBindingField];
// Update the dragged focus point related end
if (currentBinding && bindableElement) {
@@ -163,7 +171,7 @@ const focusPointUpdate = (
// Same shape bound on both ends
const boundToSameElementAfterUpdate =
bindableElement && adjacentBinding.elementId === bindableElement.id;
if (switchToInsideBinding || boundToSameElementAfterUpdate) {
if (boundToSameElementAfterUpdate) {
adjacentBinding = {
...adjacentBinding,
mode: "inside",
@@ -212,7 +220,7 @@ export const handleFocusPointDrag = (
const arrow = LinearElementEditor.getElement(
linearElementEditor.elementId,
elementsMap,
) as any;
);
// Sanity checks
if (
@@ -234,12 +242,11 @@ export const handleFocusPointDrag = (
pointerCoords.y - offsetY,
);
const bindingField = isStartBinding ? "startBinding" : "endBinding";
const hit = getHoveredElementForFocusPoint(
const hit = getHoveredElementForBinding(
point,
arrow,
scene.getNonDeletedElements(),
elementsMap,
maxBindingDistance_simple(appState.zoom),
appState.zoom,
);
// Hovering a bindable element
@@ -270,6 +277,7 @@ export const handleFocusPointDrag = (
newMode || "orbit",
linearElementEditor.draggedFocusPointBinding,
scene,
appState.zoom,
point,
);
}
@@ -339,6 +347,7 @@ export const handleFocusPointPointerDown = (
): {
hitFocusPoint: "start" | "end" | null;
pointerOffset: { x: number; y: number };
arrowOtherEndpointInitialBinding: FixedPointBinding | null;
} => {
const pointerPos = pointFrom(
pointerDownState.origin.x,
@@ -376,6 +385,7 @@ export const handleFocusPointPointerDown = (
x: pointerPos[0] - focusPoint[0],
y: pointerPos[1] - focusPoint[1],
},
arrowOtherEndpointInitialBinding: arrow.endBinding,
};
}
}
@@ -411,6 +421,7 @@ export const handleFocusPointPointerDown = (
x: pointerPos[0] - focusPoint[0],
y: pointerPos[1] - focusPoint[1],
},
arrowOtherEndpointInitialBinding: arrow.startBinding,
};
}
}
@@ -419,6 +430,7 @@ export const handleFocusPointPointerDown = (
return {
hitFocusPoint: null,
pointerOffset: { x: 0, y: 0 },
arrowOtherEndpointInitialBinding: null,
};
};
@@ -483,6 +495,10 @@ export const handleFocusPointPointerUp = (
],
});
}
return {
arrowOtherEndpointInitialBinding: null,
};
};
export const handleFocusPointHover = (
File diff suppressed because it is too large Load Diff
+294 -37
View File
@@ -1,5 +1,4 @@
import rough from "roughjs/bin/rough";
import {
arrayToMap,
type Bounds,
@@ -7,18 +6,14 @@ import {
rescalePoints,
sizeOf,
} from "@excalidraw/common";
import {
degreesToRadians,
lineSegment,
pointDistance,
pointFrom,
pointFromArray,
pointRotateRads,
} from "@excalidraw/math";
import { getCurvePathOps } from "@excalidraw/utils/shape";
import { pointsOnBezierCurves } from "points-on-curve";
import type {
@@ -29,9 +24,7 @@ import type {
LocalPoint,
Radians,
} from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
import { generateRoughOptions } from "./shape";
@@ -41,18 +34,20 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
import {
isArrowElement,
isBoundToContainer,
isFrameLikeElement,
isFreeDrawElement,
isLinearElement,
isLineElement,
isTextElement,
isExcalidrawElement,
} from "./typeChecks";
import { getElementShape } from "./shape";
import {
deconstructDiamondElement,
deconstructRectanguloidElement,
} from "./utils";
import { intersectElementWithLineSegment } from "./collision";
import { elementOverlapsWithFrame, getContainingFrame } from "./frame";
import type { Drawable, Op } from "roughjs/bin/core";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
@@ -67,6 +62,7 @@ import type {
ExcalidrawRectanguloidElement,
ExcalidrawTextElementWithContainer,
NonDeleted,
NonDeletedExcalidrawElement,
} from "./types";
export type RectangleBox = {
@@ -424,6 +420,7 @@ const _isRectanguloidElement = (
return (
element != null &&
(element.type === "rectangle" ||
element.type === "stickynote" ||
element.type === "image" ||
element.type === "iframe" ||
element.type === "embeddable" ||
@@ -1032,7 +1029,7 @@ export const getCommonBounds = (
};
export const getDraggedElementsBounds = (
elements: ExcalidrawElement[],
elements: readonly NonDeletedExcalidrawElement[],
dragOffset: { x: number; y: number },
) => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
@@ -1117,33 +1114,6 @@ export const getElementPointsCoords = (
];
};
export const getClosestElementBounds = (
elements: readonly ExcalidrawElement[],
from: { x: number; y: number },
): Bounds => {
if (!elements.length) {
return [0, 0, 0, 0];
}
let minDistance = Infinity;
let closestElement = elements[0];
const elementsMap = arrayToMap(elements);
elements.forEach((element) => {
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
const distance = pointDistance(
pointFrom((x1 + x2) / 2, (y1 + y2) / 2),
pointFrom(from.x, from.y),
);
if (distance < minDistance) {
minDistance = distance;
closestElement = element;
}
});
return getElementBounds(closestElement, elementsMap);
};
export interface BoundingBox {
minX: number;
minY: number;
@@ -1295,6 +1265,293 @@ export const boundsContainBounds = (outerBounds: Bounds, innerBounds: Bounds) =>
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[3]),
].every((point) => pointInsideBoundsInclusive(point, outerBounds));
/**
* High level helper to get elements overlapping a bounding box.
* It can be used to get elements overlapping a selection box, for example.
*
*/
export const elementsOverlappingBBox = <T extends ExcalidrawElement>({
elements,
elementsMap,
bounds,
type,
excludeElementsInFrames,
shouldIgnoreElementFromSelection,
}: {
elements: readonly T[];
elementsMap?: ElementsMap;
bounds: Bounds | ExcalidrawElement;
/**
* - overlap: elements overlapping or inside bounds
* - contain: elements inside bounds
**/
type: "contain" | "overlap";
excludeElementsInFrames?: boolean;
shouldIgnoreElementFromSelection?: (element: T) => boolean;
}) => {
if (!elementsMap) {
elementsMap = arrayToMap(elements) as ElementsMap;
}
const selectionBounds = isExcalidrawElement(bounds)
? getElementBounds(bounds, elementsMap)
: bounds;
const [selectionX1, selectionY1, selectionX2, selectionY2] = selectionBounds;
const selectionEdges = [
lineSegment<GlobalPoint>(
pointFrom(selectionX1, selectionY1),
pointFrom(selectionX2, selectionY1),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX2, selectionY1),
pointFrom(selectionX2, selectionY2),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX2, selectionY2),
pointFrom(selectionX1, selectionY2),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX1, selectionY2),
pointFrom(selectionX1, selectionY1),
),
];
const framesInSelection = excludeElementsInFrames
? new Set<ExcalidrawElement["id"]>()
: null;
const groups: Record<string, T[]> = {};
const elementsInSelection: Set<T> = new Set();
for (const element of elements) {
if (shouldIgnoreElementFromSelection?.(element)) {
continue;
}
// Track only selectable top-level group members, so ignored elements such
// as bound text and locked elements don't affect group selection.
const groupId = element.groupIds.at(-1);
if (groupId) {
if (!groups[groupId]) {
groups[groupId] = [];
}
groups[groupId].push(element);
}
const strokeWidth = element.strokeWidth;
let labelAABB: Bounds | null = null;
let elementAABB = getElementBounds(element, elementsMap);
elementAABB = [
elementAABB[0] - strokeWidth / 2,
elementAABB[1] - strokeWidth / 2,
elementAABB[2] + strokeWidth / 2,
elementAABB[3] + strokeWidth / 2,
] as Bounds;
// Whether the element bounds should include the bound text element bounds
const boundTextElement =
isArrowElement(element) && getBoundTextElement(element, elementsMap);
if (boundTextElement) {
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
elementsMap,
);
labelAABB = [
x,
y,
x + boundTextElement.width,
y + boundTextElement.height,
] as Bounds;
}
// Clip element bounds by its containing frame (if any), since only the
// visible (frame-clipped) portion of the element is relevant for selection.
const associatedFrame = getContainingFrame(element, elementsMap);
if (
associatedFrame &&
elementOverlapsWithFrame(element, associatedFrame, elementsMap)
) {
const frameAABB = getElementBounds(associatedFrame, elementsMap);
elementAABB = [
Math.max(elementAABB[0], frameAABB[0]),
Math.max(elementAABB[1], frameAABB[1]),
Math.min(elementAABB[2], frameAABB[2]),
Math.min(elementAABB[3], frameAABB[3]),
] as Bounds;
labelAABB = labelAABB
? ([
Math.max(labelAABB[0], frameAABB[0]),
Math.max(labelAABB[1], frameAABB[1]),
Math.min(labelAABB[2], frameAABB[2]),
Math.min(labelAABB[3], frameAABB[3]),
] as Bounds)
: null;
}
const commonAABB = labelAABB
? ([
Math.min(labelAABB[0], elementAABB[0]),
Math.min(labelAABB[1], elementAABB[1]),
Math.max(labelAABB[2], elementAABB[2]),
Math.max(labelAABB[3], elementAABB[3]),
] as Bounds)
: elementAABB;
// ============== Evaluation ==============
// 1. If the selection box WRAPs the element's AABB, then add it to the
// selection and move on, regardless of the selection mode.
//
// PERF: This trick only works with axis-aligned box selection and the
// current convex element shapes!
if (boundsContainBounds(selectionBounds, commonAABB)) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
elementsInSelection.add(element);
continue;
}
// 2. Handle the case where the label is overlapped by the selection box
if (
type === "overlap" &&
labelAABB &&
doBoundsIntersect(selectionBounds, labelAABB)
) {
elementsInSelection.add(element);
continue;
}
// 3. Handle the case where the selection is not wrapping the element, but
// it does intersect the element's outline (non-AABB).
if (type === "overlap" && doBoundsIntersect(selectionBounds, elementAABB)) {
let hasIntersection = false;
// Preliminary check potential intersection imprecision
if (isLinearElement(element) || isFreeDrawElement(element)) {
const center = elementCenterPoint(element, elementsMap);
hasIntersection = element.points.some((point) => {
const rotatedPoint = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return pointInsideBounds(rotatedPoint, selectionBounds);
});
} else {
const nonRotatedElementBounds = getElementBounds(
element,
elementsMap,
true,
);
const center = elementCenterPoint(element, elementsMap);
hasIntersection = [
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[2],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[0],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
].some((point) => {
return pointInsideBounds(
pointRotateRads(point, center, element.angle),
selectionBounds,
);
});
}
if (!hasIntersection) {
hasIntersection = selectionEdges.some(
(selectionEdge) =>
intersectElementWithLineSegment(
element,
elementsMap,
selectionEdge,
strokeWidth / 2,
true, // Stop at first hit for better performance
).length > 0,
);
}
if (hasIntersection) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
elementsInSelection.add(element);
continue;
}
}
// 4. We don't need to handle when the selection is inside the element
// as it is separately handled in App.
}
if (framesInSelection) {
elementsInSelection.forEach((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
elementsInSelection.delete(element);
}
});
}
if (type === "overlap") {
Array.from(elementsInSelection).forEach((element) => {
const groupId = element.groupIds.at(-1);
const group = groupId ? groups[groupId] : null;
group?.forEach((groupElement) => elementsInSelection.add(groupElement));
});
} else if (type === "contain") {
elementsInSelection.forEach((element) => {
// note: currently we only support top-level group handling since
// we don't support box selecting while editing the group/subgroup
// see https://github.com/excalidraw/excalidraw/pull/11234#issuecomment-4387654451
const groupId = element.groupIds.at(-1);
const group = groupId ? groups[groupId] : null;
if (
group &&
!group.every((groupElement) => elementsInSelection.has(groupElement))
) {
elementsInSelection.delete(element);
}
});
}
// to maintain original order elements (namely for group selection)
return elements.filter((element) => elementsInSelection.has(element));
};
export const elementCenterPoint = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
File diff suppressed because it is too large Load Diff
+229 -203
View File
@@ -8,6 +8,7 @@ import {
pointFromVector,
pointRotateRads,
pointsEqual,
polygonIncludesPoint,
vectorFromPoint,
vectorNormalize,
vectorScale,
@@ -22,10 +23,11 @@ import type {
Curve,
GlobalPoint,
LineSegment,
LocalPoint,
Radians,
} from "@excalidraw/math";
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import type { AppState, FrameNameBounds } from "@excalidraw/excalidraw/types";
import { isPathALoop } from "./utils";
import {
@@ -33,13 +35,13 @@ import {
elementCenterPoint,
getCenterForBounds,
getCubicBezierCurveBound,
getDiamondPoints,
getElementBounds,
pointInsideBounds,
} from "./bounds";
import {
hasBoundTextElement,
isBindableElement,
isBoundToContainer,
isFrameLikeElement,
isFreeDrawElement,
isIframeLikeElement,
@@ -53,17 +55,21 @@ import {
deconstructRectanguloidElement,
} from "./utils";
import { getBoundTextElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import {
getBoundTextElement,
getTextElementWithAccuratePosition,
} from "./textElement";
import { distanceToElement } from "./distance";
import { getBindingGap } from "./binding";
import { maxBindingDistance } from "./binding";
import { hasBackground } from "./comparisons";
import { getFreedrawFillPolygon, getFreedrawMaxStrokeRadius } from "./shape";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawDiamondElement,
ExcalidrawElement,
@@ -83,7 +89,7 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
}
const isDraggableFromInside =
!isTransparent(element.backgroundColor) ||
(hasBackground(element.type) && !isTransparent(element.backgroundColor)) ||
hasBoundTextElement(element) ||
isIframeLikeElement(element) ||
isTextElement(element);
@@ -113,6 +119,19 @@ let cachedElement: WeakRef<ExcalidrawElement> | null = null;
let cachedThreshold: number = Infinity;
let cachedHit: boolean = false;
let cachedOverrideShouldTestInside = false;
let cachedFrameNameBound: FrameNameBounds | null = null;
const frameNameBoundsEqual = (
a: FrameNameBounds | null,
b: FrameNameBounds | null,
) =>
a === b ||
(!!a &&
!!b &&
a.x === b.x &&
a.y === b.y &&
a.width === b.width &&
a.height === b.height);
export const hitElementItself = ({
point,
@@ -122,12 +141,19 @@ export const hitElementItself = ({
frameNameBound = null,
overrideShouldTestInside = false,
}: HitTestArgs) => {
// Return cached result if the same point and element version is tested again
// Return cached result if the same point and element version is tested again.
// A cached hit stays valid for any larger threshold, while a cached miss
// stays valid only for a threshold no larger than the cached one (a larger
// threshold could turn a miss into a hit).
// Skipped for container-bound labels — their position can change without
// their version being bumped (see ElementBounds.getBounds).
if (
!isBoundToContainer(element) &&
cachedPoint &&
pointsEqual(point, cachedPoint) &&
cachedThreshold <= threshold &&
overrideShouldTestInside === cachedOverrideShouldTestInside
(cachedHit ? cachedThreshold <= threshold : cachedThreshold >= threshold) &&
overrideShouldTestInside === cachedOverrideShouldTestInside &&
frameNameBoundsEqual(frameNameBound, cachedFrameNameBound)
) {
const derefElement = cachedElement?.deref();
if (
@@ -158,7 +184,10 @@ export const hitElementItself = ({
point,
bounds,
element.angle,
threshold,
// Freedraw bounds follow the centerline points, but the ink extends past them
isFreeDrawElement(element)
? threshold + getFreedrawMaxStrokeRadius(element)
: threshold,
);
// PERF: Bail out early if the point is not even in the
@@ -167,6 +196,15 @@ export const hitElementItself = ({
return false;
}
// an arrow label's stored coords can be stale. The bounds test above
// already accounts for it (getElementBounds derives the position
// internally), but the precise test below reads the element coords
// directly, so substitute them. Done after the early bail so the common
// miss path stays allocation-free.
if (isTextElement(element)) {
element = getTextElementWithAccuratePosition(element, elementsMap);
}
// Do the precise (and relatively costly) hit test
const hitElement = (
overrideShouldTestInside ? true : shouldTestInside(element)
@@ -184,6 +222,7 @@ export const hitElementItself = ({
cachedElement = new WeakRef(element);
cachedThreshold = threshold;
cachedOverrideShouldTestInside = overrideShouldTestInside;
cachedFrameNameBound = frameNameBound;
cachedHit = result;
return result;
@@ -236,78 +275,102 @@ export const hitElementBoundText = (
if (!boundTextElementCandidate) {
return false;
}
const boundTextElement = isLinearElement(element)
? {
...boundTextElementCandidate,
// arrow's bound text accurate position is not stored in the element's property
// but rather calculated and returned from the following static method
...LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElementCandidate,
elementsMap,
),
}
: boundTextElementCandidate;
const boundTextElement = getTextElementWithAccuratePosition(
boundTextElementCandidate,
elementsMap,
);
return isPointInElement(point, boundTextElement, elementsMap);
};
const bindingBorderTest = (
element: NonDeleted<ExcalidrawBindableElement>,
[x, y]: Readonly<GlobalPoint>,
elementsMap: NonDeletedSceneElementsMap,
tolerance: number = 0,
): boolean => {
const p = pointFrom<GlobalPoint>(x, y);
const shouldTestInside =
// disable fullshape snapping for frame elements so we
// can bind to frame children
!isFrameLikeElement(element);
// Frame children are clipped to their enclosing frame, so a point outside
// the frame cannot hit them
const isPointClippedByEnclosingFrame = (
element: ExcalidrawElement,
point: Readonly<GlobalPoint>,
elementsMap: ElementsMap,
) => {
if (!element.frameId) {
return false;
}
const enclosingFrame = elementsMap.get(element.frameId);
return (
!!enclosingFrame &&
isFrameLikeElement(enclosingFrame) &&
!pointInsideBounds(point, getElementBounds(enclosingFrame, elementsMap))
);
};
const bindableElementBorderDistanceIfClose = (
element: NonDeleted<ExcalidrawBindableElement>,
point: GlobalPoint,
elementsMap: ElementsMap,
tolerance: number = 0,
) => {
// PERF: Run a cheap test to see if the binding element
// is even close to the element
const [x, y] = point;
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;
return -Infinity;
}
// 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;
}
}
if (isPointClippedByEnclosingFrame(element, point, elementsMap)) {
return -Infinity;
}
// 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);
const isInside = isPointInElement(point, element, elementsMap);
// frames are only bindable from the outside, so arrows ending inside
// a frame can bind to its children (or stay unbound)
if (isInside && isFrameLikeElement(element)) {
return -Infinity;
}
return shouldTestInside
? intersections.length === 0 || distance <= tolerance
: intersections.length > 0 && distance <= t;
const distance = distanceToElement(element, elementsMap, point);
if (isInside) {
return distance;
}
return distance > tolerance ? -Infinity : -distance;
};
export const getAllHoveredElementAtPoint = (
type BindingCandidate = {
element: NonDeleted<ExcalidrawBindableElement>;
/** distance to the outline: positive inside, negative outside */
distance: number;
};
/**
* Whether the element hides what is behind it from binding — an arrow's or a
* text label's. Images count as opaque, and frames once they support a
* background.
*/
export const isOpaqueForBinding = (element: ExcalidrawElement) =>
isImageElement(element) ||
(hasBackground(element.type) && !isTransparent(element.backgroundColor));
/**
* Bindable elements within binding distance of the point, front to back.
* Stops at the first opaque element containing the point, since it hides
* everything behind it. Locked elements can't be bound to, but still hide
* what is behind them.
*/
const getBindingCandidates = (
point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
tolerance?: number,
): NonDeleted<ExcalidrawBindableElement>[] => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array)
zoom: AppState["zoom"],
): BindingCandidate[] => {
const maxDistance = maxBindingDistance(zoom);
const candidates: BindingCandidate[] = [];
// A frame's children sit just below it in z-order, so a frame's background
// can't end the search: it only hides the non-children behind it
let occludingFrameId: ExcalidrawElement["id"] | null = null;
// We need to do 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) {
@@ -315,101 +378,95 @@ export const getAllHoveredElementAtPoint = (
invariant(
!element.isDeleted,
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
"Elements passed to binding hit tests should not contain deleted elements",
);
if (
isBindableElement(element, false) &&
bindingBorderTest(element, point, elementsMap, tolerance)
) {
candidateElements.push(element);
if (occludingFrameId && element.frameId !== occludingFrameId) {
continue;
}
if (!isTransparent(element.backgroundColor)) {
if (!isBindableElement(element)) {
continue;
}
if (isFrameLikeElement(element)) {
if (
isOpaqueForBinding(element) &&
isPointInElement(point, element, elementsMap)
) {
occludingFrameId = element.id;
}
}
const distance = bindableElementBorderDistanceIfClose(
element,
point,
elementsMap,
maxDistance,
);
if (distance > -maxDistance) {
if (!element.locked) {
candidates.push({ element, distance });
}
if (distance >= 0 && isOpaqueForBinding(element)) {
break;
}
}
}
return candidateElements;
return candidates;
};
export const getHoveredElementForBinding = (
point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
tolerance?: number,
zoom: AppState["zoom"],
): NonDeleted<ExcalidrawBindableElement> | null => {
const candidateElements = getAllHoveredElementAtPoint(
point,
elements,
elementsMap,
tolerance,
const candidates = getBindingCandidates(point, elements, elementsMap, zoom);
if (candidates.length === 0) {
return null;
}
if (candidates.length === 1) {
return candidates[0].element;
}
const closestElements = candidates.sort(
(a, b) => Math.abs(a.distance) - Math.abs(b.distance),
);
if (!candidateElements || candidateElements.length === 0) {
return null;
}
const candidate = closestElements[0];
const [cx1, cy1, cx2, cy2] = getElementBounds(candidate.element, elementsMap);
const candidateArea = Math.max(
0.00001,
Math.abs(cx2 - cx1) * Math.abs(cy2 - cy1),
);
// A smaller element overlapping the closest one takes precedence, but only
// when the point is inside it: otherwise the closest outline wins, e.g. an
// arrow ending just inside a container's edge next to a nested element
const overlaps = closestElements
.filter((c) => c.element !== candidate.element && c.distance >= 0)
.map((c) => {
const [x1, y1, x2, y2] = getElementBounds(c.element, elementsMap);
const overlapWidth = Math.max(0, Math.min(x2, cx2) - Math.max(x1, cx1));
const overlapHeight = Math.max(0, Math.min(y2, cy2) - Math.max(y1, cy1));
const area = Math.max(0.00001, Math.abs(x2 - x1) * Math.abs(y2 - y1));
if (candidateElements.length === 1) {
return candidateElements[0];
}
return {
...c,
overlapPercent: (overlapHeight * overlapWidth) / area,
relativeArea: area / candidateArea,
};
})
.filter((c) => c.overlapPercent > 0.25 && c.relativeArea < 0.75);
// Prefer smaller shapes
return candidateElements
.sort(
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
)
.pop() as NonDeleted<ExcalidrawBindableElement>;
};
export const getHoveredElementForFocusPoint = (
point: GlobalPoint,
arrow: ExcalidrawArrowElement,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
tolerance?: number,
): ExcalidrawBindableElement | null => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
invariant(
!element.isDeleted,
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
);
if (
isBindableElement(element, false) &&
bindingBorderTest(element, point, elementsMap, tolerance)
) {
candidateElements.push(element);
}
}
if (!candidateElements || candidateElements.length === 0) {
return null;
}
if (candidateElements.length === 1) {
return candidateElements[0];
}
const distanceFilteredCandidateElements = candidateElements
// Resolve by distance
.filter(
(el) =>
distanceToElement(el, elementsMap, point) <= getBindingGap(el, arrow) ||
isPointInElement(point, el, elementsMap),
);
if (distanceFilteredCandidateElements.length === 0) {
return null;
}
return distanceFilteredCandidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
return candidate.distance >= 0 && overlaps.length > 0
? overlaps[0].element
: candidate.element;
};
/**
@@ -444,6 +501,7 @@ export const intersectElementWithLineSegment = (
// Do the actual intersection test against the element's shape
switch (element.type) {
case "rectangle":
case "stickynote":
case "image":
case "text":
case "iframe":
@@ -485,6 +543,8 @@ export const intersectElementWithLineSegment = (
}
};
const CURVE_BOUNDS_EPSILON = 1e-6;
const curveIntersections = (
curves: Curve<GlobalPoint>[],
segment: LineSegment<GlobalPoint>,
@@ -493,15 +553,19 @@ const curveIntersections = (
angle: Radians,
onlyFirst = false,
) => {
// Pad the segment bounds so an axis-aligned segment passing exactly through
// the joint of two curves still overlaps their bounds, as
// `doBoundsIntersect` treats bounds that only touch as not intersecting
const b2 = [
Math.min(segment[0][0], segment[1][0]) - CURVE_BOUNDS_EPSILON,
Math.min(segment[0][1], segment[1][1]) - CURVE_BOUNDS_EPSILON,
Math.max(segment[0][0], segment[1][0]) + CURVE_BOUNDS_EPSILON,
Math.max(segment[0][1], segment[1][1]) + CURVE_BOUNDS_EPSILON,
] as Bounds;
for (const c of curves) {
// Optimize by doing a cheap bounding box check first
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
const b2 = [
Math.min(segment[0][0], segment[1][0]),
Math.min(segment[0][1], segment[1][1]),
Math.max(segment[0][0], segment[1][0]),
Math.max(segment[0][1], segment[1][1]),
] as Bounds;
if (!doBoundsIntersect(b1, b2)) {
continue;
@@ -761,6 +825,26 @@ export const isPointInElement = (
return false;
}
if (isFreeDrawElement(element)) {
// Test before the rotated-bounds check below: the smoothed fill can bulge
// past the recorded points, and how much of it those bounds would clip
// depends on the angle. An asymmetric loop's rotated bounds center is also
// not its rotation pivot.
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap, true);
const [px, py] = pointRotateRads(
point,
pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2),
-element.angle as Radians,
);
// The fill follows the centerline rather than the stroke outline, and uses
// the same even-odd rule as RoughJS's filled curves.
return polygonIncludesPoint(
pointFrom<LocalPoint>(px - element.x, py - element.y),
getFreedrawFillPolygon(element),
);
}
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
if (!isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2))) {
@@ -784,61 +868,3 @@ 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),
);
};
+21 -4
View File
@@ -2,22 +2,31 @@ import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
export const hasBackground = (type: ElementOrToolType) =>
type === "rectangle" ||
type === "stickynote" ||
type === "iframe" ||
type === "embeddable" ||
type === "ellipse" ||
type === "diamond" ||
type === "line" ||
type === "freedraw";
type === "freedraw" ||
type === "autoshape" ||
// tool-only type; makes the `G` background shortcut work for bucket fill
type === "bucketfill";
export const hasFillStyle = (type: ElementOrToolType) =>
hasBackground(type) && type !== "stickynote";
export const hasStrokeColor = (type: ElementOrToolType) =>
type === "rectangle" ||
type === "stickynote" ||
type === "ellipse" ||
type === "diamond" ||
type === "freedraw" ||
type === "arrow" ||
type === "line" ||
type === "text" ||
type === "embeddable";
type === "embeddable" ||
type === "autoshape";
export const hasStrokeWidth = (type: ElementOrToolType) =>
type === "rectangle" ||
@@ -27,7 +36,8 @@ export const hasStrokeWidth = (type: ElementOrToolType) =>
type === "diamond" ||
type === "freedraw" ||
type === "arrow" ||
type === "line";
type === "line" ||
type === "autoshape";
export const hasStrokeStyle = (type: ElementOrToolType) =>
type === "rectangle" ||
@@ -36,7 +46,13 @@ export const hasStrokeStyle = (type: ElementOrToolType) =>
type === "ellipse" ||
type === "diamond" ||
type === "arrow" ||
type === "line";
type === "line" ||
type === "autoshape";
export const hasRoughness = (type: ElementOrToolType) =>
hasStrokeStyle(type) || type === "stickynote";
export const hasFreedrawMode = (type: ElementOrToolType) => type === "freedraw";
export const canChangeRoundness = (type: ElementOrToolType) =>
type === "rectangle" ||
@@ -44,6 +60,7 @@ export const canChangeRoundness = (type: ElementOrToolType) =>
type === "embeddable" ||
type === "line" ||
type === "diamond" ||
type === "stickynote" ||
type === "image";
export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
+821
View File
@@ -0,0 +1,821 @@
import {
getBoundsFromPoints,
getElementAbsoluteCoords,
} from "@excalidraw/element/bounds";
import {
newArrowElement,
newElement,
newLinearElement,
} from "@excalidraw/element/newElement";
import {
ELEMENT_PENDING_DRAW_SHAPE_OPACITY,
getStrokeWidthByKey,
ROUNDNESS,
} from "@excalidraw/common";
import {
convexHull,
distanceToLineSegment,
elongation,
kurtosis,
lineSegment,
orientPrincipalAxes,
pointFrom,
polygonArea,
principalAxes,
principalCoords,
skewness,
} from "@excalidraw/math";
import type { App, AppState } from "@excalidraw/excalidraw/types";
import type { LocalPoint, GlobalPoint, Radians } from "@excalidraw/math";
import type { Bounds } from "@excalidraw/common";
import type {
ExcalidrawArrowElement,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawFrameLikeElement,
ExcalidrawRectangleElement,
ElementsMap,
ExcalidrawNonSelectionElement,
NonDeletedExcalidrawElement,
NonDeleted,
ExcalidrawLineElement,
} from "@excalidraw/element/types";
import { getFrameLikeElements } from "./frame";
import {
isLinearElement,
isLineElement,
isUsingAdaptiveRadius,
} from "./typeChecks";
import { LinearElementEditor } from "./linearElementEditor";
declare global {
interface Window {
LAST_POINTS?: readonly GlobalPoint[];
}
}
// A moment-based shape recognizer.
//
// NOTE: Rectangle vs. diamond is deliberately *not* rotation invariant. A 45
// rectangle = diamond.
type NonDeletedRecognizedShapeElement = NonDeleted<
| ExcalidrawRectangleElement
| ExcalidrawEllipseElement
| ExcalidrawDiamondElement
| ExcalidrawArrowElement
| ExcalidrawLineElement
| ExcalidrawFreeDrawElement
>;
type Shape = NonDeletedRecognizedShapeElement["type"];
interface ShapeRecognitionResult<P extends LocalPoint | GlobalPoint> {
// The type of the recognized shape, or "freedraw" if no good match was found
type: Shape;
// The original input points (not simplified)
points: readonly P[];
// Bounding box of the original input points, used for positioning the
// converted shape
boundingBox: Bounds;
}
// Number of points every stroke is resampled to before feature extraction.
const RESAMPLE_N = 64;
// Minimum apparent (on-screen) size of a stroke's larger bounding-box
// dimension, in pixels, for recognition to run — below this the stroke reads
// as an accidental scribble. Compared against `sceneSize * zoom` so the same
// physical gesture behaves the same at every zoom level.
//
// Maximum not required. Runtime is O(point count), points come in at roughly
// one per frame.
const RECOGNITION_MIN_SCREEN_SIZE = 25;
// A stroke whose endpoints are farther apart than this fraction of its own
// path length is treated as open (a line or an arrow) rather than closed (a
// rectangle, diamond or ellipse).
const CLOSED_GAP_MAX_RATIO = 0.15;
// Maximum elongation for an open stroke to count as straight.
const LINEAR_MAX_ELONGATION = 0.25;
// Fraction of the start→tip distance around the tip inside which an arrowhead
// may live: points there are exempt from the shaft straightness check.
const ARROWHEAD_ZONE_RATIO = 0.5;
// Maximum distance any point outside the arrowhead zone may stray from the
// start→tip chord, as a fraction of the chord length. Loose enough for a lazy
// bow or a small squiggle, tight enough to reject elbows, arcs and sawtooths
// that only *statistically* look straight (their point-cloud elongation
// passes, their geometry doesn't).
const LINEAR_MAX_SHAFT_DEVIATION = 0.15;
// Minimum |skew| along the major axis for an open, straight stroke to be an
// arrow rather than a plain line. Arrowhead add "mass" -> skews that way.
const ARROW_MIN_SKEW = 0.3;
// Maximum feature-space distance to a shape prototype, in units of the
// per-feature tolerances in CLOSED_SHAPE_PROTOTYPES. Beyond this the stroke is
// not enough like any known shape and stays freedraw.
const CLOSED_SHAPE_MAX_DISTANCE = 1.5;
// Half-width, in resampled points, of the chord window used to measure local
// turning along the stroke. Wide enough to smooth pen wobble, narrow enough
// that adjacent corners of a small quadrilateral stay separate peaks.
const TURN_WINDOW = 3;
// =============================================================================
// Feature extraction
// =============================================================================
// Resample `pts` to exactly `n` evenly-spaced points along the stroke path.
function resample<P extends LocalPoint | GlobalPoint>(
pts: readonly P[],
n: number,
): P[] {
let totalLen = 0;
for (let i = 1; i < pts.length; i++) {
totalLen += Math.hypot(
pts[i][0] - pts[i - 1][0],
pts[i][1] - pts[i - 1][1],
);
}
const interval = totalLen / (n - 1);
let accumulated = 0;
const result: P[] = [pts[0]];
let prev = pts[0];
for (let i = 1; i < pts.length; i++) {
const curr = pts[i];
const segLen = Math.hypot(curr[0] - prev[0], curr[1] - prev[1]);
if (accumulated + segLen >= interval) {
// Insert interpolated points within this segment
let remaining = interval - accumulated;
while (remaining <= segLen + 1e-10) {
const t = remaining / segLen;
const newPt: P = [
prev[0] + t * (curr[0] - prev[0]),
prev[1] + t * (curr[1] - prev[1]),
] as P;
result.push(newPt);
if (result.length === n) {
return result;
}
prev = newPt;
accumulated = 0;
remaining += interval;
}
accumulated = segLen - (remaining - interval);
} else {
accumulated += segLen;
}
prev = curr;
}
// Fill any remaining slots with the last point (rounding errors)
while (result.length < n) {
result.push(pts[pts.length - 1]);
}
return result;
}
// Statistical description of a stroke, in terms that don't depend on where,
// how big, or (except where noted) how rotated it was drawn.
interface StrokeFeatures {
// Straight-line distance between the endpoints over the stroke's own path
// length. ~0 when the stroke returns to its start.
gapRatio: number;
// Point cloud elongation along the major axis.
elongation: number;
// Skew of the points projected onto the major axis. Signed so that it is
// <= 0 by construction.
majorSkew: number;
// Convex hull area over axis-aligned bounding box area, in the screen
// frame: ~1 for a rectangle, ~PI/4 for an ellipse, ~1/2 for a diamond.
hullFillRatio: number;
// Share of the stroke's total turning concentrated in its 4 strongest
// corner loci: ~1 for any quadrilateral — however tapered or slanted, all
// turning happens at the corners — vs. ~0.5 for an ellipse, whose turning
// is spread along the whole outline. This is what tells a sloppy,
// trapezoid-ish rectangle (hull fill ≈ PI/4, same as an ellipse's) from an
// actual ellipse: sharp corners.
cornerTurnShare: number;
// kurtosis along x times kurtosis along y, in the screen frame. The two
// factors each vary with the aspect ratio, but their product barely does:
// ~1.83 for any rectangle, 2.25 for any ellipse, 3.24 for any diamond.
kurtosisProduct: number;
// How far the stroke strays from the straight chord between its start and
// its farthest point ("tip"), as a fraction of that chord's length — points
// within ARROWHEAD_ZONE_RATIO of the tip are exempt, so an arrowhead
// doesn't count as straying. ~0 for a line or arrow however wobbly, large
// for elbows, arcs and sawtooths, whose point-cloud statistics alone can
// still look line-like.
shaftDeviationRatio: number;
}
// See StrokeFeatures.shaftDeviationRatio.
function shaftDeviationRatio<P extends LocalPoint | GlobalPoint>(
pts: readonly P[],
): number {
const start = pts[0];
let tip = start;
let tipDistance = 0;
for (const point of pts) {
const distance = Math.hypot(point[0] - start[0], point[1] - start[1]);
if (distance > tipDistance) {
tipDistance = distance;
tip = point;
}
}
if (tipDistance === 0) {
return 0;
}
const chord = lineSegment(start, tip);
let maxDeviation = 0;
for (const point of pts) {
const tipDist = Math.hypot(point[0] - tip[0], point[1] - tip[1]);
if (tipDist <= ARROWHEAD_ZONE_RATIO * tipDistance) {
continue;
}
maxDeviation = Math.max(maxDeviation, distanceToLineSegment(point, chord));
}
return maxDeviation / tipDistance;
}
// The turn angle at every resampled point, measured between the incoming and
// outgoing chords `TURN_WINDOW` points away. The stroke is deliberately NOT
// treated as a closed loop: wrapping around would let the closure artifacts of
// a hand-drawn outline (overshooting or undershooting the starting point)
// fabricate a sharp corner that was never drawn.
function windowedTurns<P extends LocalPoint | GlobalPoint>(
pts: readonly P[],
): number[] {
const turns: number[] = [];
for (let i = TURN_WINDOW; i < pts.length - TURN_WINDOW; i++) {
const [ax, ay] = pts[i - TURN_WINDOW];
const [bx, by] = pts[i];
const [cx, cy] = pts[i + TURN_WINDOW];
const v1x = bx - ax;
const v1y = by - ay;
const v2x = cx - bx;
const v2y = cy - by;
turns.push(
Math.abs(Math.atan2(v1x * v2y - v1y * v2x, v1x * v2x + v1y * v2y)),
);
}
return turns;
}
// See StrokeFeatures.cornerTurnShare. Greedily picks the 4 strongest turn
// peaks, each absorbing its ±TURN_WINDOW neighborhood so one physical corner
// (whose turn smears across the window) counts once.
function cornerTurnShare<P extends LocalPoint | GlobalPoint>(
pts: readonly P[],
): number {
const turns = windowedTurns(pts);
const total = turns.reduce((sum, turn) => sum + turn, 0);
if (total === 0) {
return 0;
}
const taken = new Array<boolean>(turns.length).fill(false);
let top4 = 0;
for (let corner = 0; corner < 4; corner++) {
let peak = -1;
let peakTurn = 0;
for (let i = 0; i < turns.length; i++) {
if (!taken[i] && turns[i] > peakTurn) {
peakTurn = turns[i];
peak = i;
}
}
if (peak < 0) {
break;
}
for (let i = peak - TURN_WINDOW; i <= peak + TURN_WINDOW; i++) {
if (i >= 0 && i < turns.length && !taken[i]) {
top4 += turns[i];
taken[i] = true;
}
}
}
return top4 / total;
}
function extractFeatures<P extends LocalPoint | GlobalPoint>(
points: readonly P[],
): StrokeFeatures {
// Resample first: features are moments of the point set, so they would
// otherwise be biased by pointer speed, which crowds samples wherever the
// user drew slowly.
const pts = resample(points, RESAMPLE_N);
let pathLength = 0;
for (let i = 1; i < pts.length; i++) {
pathLength += Math.hypot(
pts[i][0] - pts[i - 1][0],
pts[i][1] - pts[i - 1][1],
);
}
const gap = Math.hypot(
pts[pts.length - 1][0] - pts[0][0],
pts[pts.length - 1][1] - pts[0][1],
);
const axes = orientPrincipalAxes(pts, principalAxes(pts));
const majorProjection = principalCoords(pts, axes).map(([u]) => u);
const hull = convexHull(pts);
const [minX, minY, maxX, maxY] = getBoundsFromPoints(pts);
const boxArea = (maxX - minX) * (maxY - minY);
return {
gapRatio: pathLength > 0 ? gap / pathLength : 0,
elongation: elongation(axes),
majorSkew: skewness(majorProjection),
hullFillRatio: boxArea > 0 ? polygonArea(hull) / boxArea : 0,
cornerTurnShare: cornerTurnShare(pts),
kurtosisProduct:
kurtosis(pts.map(([x]) => x)) * kurtosis(pts.map(([, y]) => y)),
shaftDeviationRatio: shaftDeviationRatio(pts),
};
}
// =============================================================================
// Classification
// =============================================================================
const CLOSED_SHAPE_PROTOTYPES: readonly {
type: Shape;
hullFillRatio: number;
cornerTurnShare: number;
kurtosisProduct: number;
}[] = [
{
type: "rectangle",
hullFillRatio: 1,
cornerTurnShare: 0.95,
kurtosisProduct: 1.83,
},
{
type: "diamond",
hullFillRatio: 0.5,
cornerTurnShare: 0.95,
kurtosisProduct: 3.24,
},
{
type: "ellipse",
hullFillRatio: Math.PI / 4,
cornerTurnShare: 0.55,
kurtosisProduct: 2.25,
},
];
// Hull fill no longer has to separate rectangles from ellipses on its own
// (cornerTurnShare carries that), so it can afford the slack that lets a
// tapered, hand-drawn quadrilateral (fill ≈ 0.8) still read as a rectangle.
const HULL_FILL_RATIO_TOLERANCE = 0.2;
const CORNER_TURN_SHARE_TOLERANCE = 0.2;
const KURTOSIS_PRODUCT_TOLERANCE = 0.7;
// Pick the closed shape whose prototype the stroke's features sit closest to,
// or freedraw when even the nearest one is too far away.
function classifyClosedStroke(features: StrokeFeatures): Shape {
let best: Shape = "freedraw";
let bestDistance = CLOSED_SHAPE_MAX_DISTANCE;
for (const prototype of CLOSED_SHAPE_PROTOTYPES) {
const distance = Math.hypot(
(features.hullFillRatio - prototype.hullFillRatio) /
HULL_FILL_RATIO_TOLERANCE,
(features.cornerTurnShare - prototype.cornerTurnShare) /
CORNER_TURN_SHARE_TOLERANCE,
(features.kurtosisProduct - prototype.kurtosisProduct) /
KURTOSIS_PRODUCT_TOLERANCE,
);
if (distance < bestDistance) {
bestDistance = distance;
best = prototype.type;
}
}
return best;
}
// An open stroke is a line or an arrow, provided it is straight enough; the
// arrowhead is what makes the point distribution lopsided.
function classifyOpenStroke(features: StrokeFeatures): Shape {
if (
features.elongation > LINEAR_MAX_ELONGATION ||
features.shaftDeviationRatio > LINEAR_MAX_SHAFT_DEVIATION
) {
return "freedraw";
}
return Math.abs(features.majorSkew) >= ARROW_MIN_SKEW ? "arrow" : "line";
}
function classify(features: StrokeFeatures): Shape {
return features.gapRatio > CLOSED_GAP_MAX_RATIO
? classifyOpenStroke(features)
: classifyClosedStroke(features);
}
// Arrow endpoint selection
// When an arrow is recognized, the original input's last point may not
// correspond to the actual arrow tip. The tip must lie on the bounding-box
// perimeter and is the perimeter point farthest from the start (first input
// point). We find the closest original input point to that ideal tip.
function getArrowEndpoint<P extends LocalPoint | GlobalPoint>(
points: readonly P[],
boundingBox: Bounds,
startPoint: P,
): P {
const [minX, minY, maxX, maxY] = boundingBox;
const w = maxX - minX;
const h = maxY - minY;
// Degenerate bounding box – fall back to last point
if (w === 0 && h === 0) {
return points[points.length - 1];
}
// Perimeter corners / edge points to check (4 corners + 4 edge midpoints
// give good coverage of the perimeter).
const perimeterPoints: LocalPoint[] = [
pointFrom(minX, minY),
pointFrom((minX + maxX) / 2, minY),
pointFrom(maxX, minY),
pointFrom(maxX, (minY + maxY) / 2),
pointFrom(maxX, maxY),
pointFrom((minX + maxX) / 2, maxY),
pointFrom(minX, maxY),
pointFrom(minX, (minY + maxY) / 2),
];
// Ideal endpoint = perimeter point farthest from start.
let idealDist = -1;
let idealEndpoint: LocalPoint = pointFrom(maxX, maxY);
for (const pp of perimeterPoints) {
const d = Math.hypot(pp[0] - startPoint[0], pp[1] - startPoint[1]);
if (d > idealDist) {
idealDist = d;
idealEndpoint = pp;
}
}
// Find the original input point closest to the ideal endpoint.
let bestDist = Infinity;
let bestPoint: P = points[points.length - 1];
for (const pt of points) {
const d = Math.hypot(pt[0] - idealEndpoint[0], pt[1] - idealEndpoint[1]);
if (d < bestDist) {
bestDist = d;
bestPoint = pt;
}
}
return bestPoint;
}
// =============================================================================
// Public API
// =============================================================================
// Recognizes common shapes from free-draw input points
export const recognizeShape = <P extends LocalPoint | GlobalPoint>(
points: P[],
previousElement: ExcalidrawElement | null,
zoom: number = 1,
): ShapeRecognitionResult<P> => {
// DEBUG (will be removed lated)
if (typeof window !== "undefined") {
window.LAST_POINTS = points.map(([x, y]) => pointFrom<GlobalPoint>(x, y));
}
const boundingBox = getBoundsFromPoints(points);
const [minX, minY, maxX, maxY] = boundingBox;
const maxDim = Math.max(maxX - minX, maxY - minY);
if (points.length < 3 || maxDim * zoom < RECOGNITION_MIN_SCREEN_SIZE) {
return { type: "freedraw", points, boundingBox };
}
const recognized = classify(extractFeatures(points));
const type =
previousElement?.type === "arrow" && recognized !== "arrow"
? "freedraw"
: recognized;
return { type, points, boundingBox };
};
// Converts a freedraw element to the detected shape.
//
// `frames` lets callers pass the scene's cached frame-like elements (e.g.
// scene.getNonDeletedFramesLikes()) to avoid spreading and re-filtering the
// whole element map on every invocation; when omitted it is derived from
// `elementsMap`.
export const convertToShape = (
points: GlobalPoint[],
appState: AppState,
elementsMap: ElementsMap,
previousElement: ExcalidrawElement | null,
frames?: readonly ExcalidrawFrameLikeElement[],
): NonDeletedRecognizedShapeElement | undefined => {
const recognizedShape = recognizeShape(
points,
previousElement,
appState.zoom.value,
);
const boundingBox = recognizedShape.boundingBox;
const [minX, minY, maxX, maxY] = boundingBox;
const frameLikeElements =
frames ?? getFrameLikeElements([...elementsMap.values()]);
const frameId =
frameLikeElements.find((frame) => {
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame, elementsMap);
return fx1 <= minX && fy1 <= minY && fx2 >= maxX && fy2 >= maxY;
})?.id ?? null;
const roundness =
appState.currentItemRoundness === "round"
? {
type: isUsingAdaptiveRadius(recognizedShape.type)
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null;
switch (recognizedShape.type) {
case "rectangle":
case "diamond":
case "ellipse": {
return newElement({
type: recognizedShape.type,
x: recognizedShape.boundingBox[0],
y: recognizedShape.boundingBox[1],
width: recognizedShape.boundingBox[2] - recognizedShape.boundingBox[0],
height: recognizedShape.boundingBox[3] - recognizedShape.boundingBox[1],
groupIds: [],
angle: 0 as Radians,
frameId,
roundness,
roughness: appState.currentItemRoughness,
backgroundColor: appState.currentItemBackgroundColor,
strokeColor: appState.currentItemStrokeColor,
fillStyle: appState.currentItemFillStyle,
opacity: appState.currentItemOpacity,
strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: getStrokeWidthByKey(
recognizedShape.type,
appState.currentItemStrokeWidthKey,
),
}) as NonDeletedRecognizedShapeElement;
}
case "arrow": {
const [arrowX, arrowY] = recognizedShape.points[0];
const [globalEndX, globalEndY] = getArrowEndpoint(
recognizedShape.points,
recognizedShape.boundingBox,
recognizedShape.points[0],
);
const endPoint: LocalPoint = pointFrom<LocalPoint>(
globalEndX - arrowX,
globalEndY - arrowY,
);
const arrowLen = Math.hypot(
globalEndX - recognizedShape.points[0][0],
globalEndY - recognizedShape.points[0][1],
);
if (arrowLen < 60) {
const tempElement = newLinearElement({
type: "line",
x: arrowX,
y: arrowY,
points: [
pointFrom<LocalPoint>(
recognizedShape.points[0][0] - arrowX,
recognizedShape.points[0][1] - arrowY,
),
endPoint,
],
groupIds: [],
frameId,
locked: false,
angle: 0 as Radians,
roundness,
roughness: appState.currentItemRoughness,
backgroundColor: appState.currentItemBackgroundColor,
strokeColor: appState.currentItemStrokeColor,
fillStyle: appState.currentItemFillStyle,
opacity: appState.currentItemOpacity,
strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: getStrokeWidthByKey(
recognizedShape.type,
appState.currentItemStrokeWidthKey,
),
});
const normalized =
LinearElementEditor.getNormalizeElementPointsAndCoords(tempElement);
return newLinearElement({
...tempElement,
...normalized,
}) as NonDeletedRecognizedShapeElement;
}
const tempElement = newArrowElement({
type: "arrow",
x: arrowX,
y: arrowY,
startArrowhead: appState.currentItemStartArrowhead,
endArrowhead: appState.currentItemEndArrowhead,
points: [
pointFrom<LocalPoint>(
recognizedShape.points[0][0] - arrowX,
recognizedShape.points[0][1] - arrowY,
),
endPoint,
],
groupIds: [],
frameId,
locked: false,
angle: 0 as Radians,
roundness,
roughness: appState.currentItemRoughness,
backgroundColor: appState.currentItemBackgroundColor,
strokeColor: appState.currentItemStrokeColor,
fillStyle: appState.currentItemFillStyle,
opacity: appState.currentItemOpacity,
strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: getStrokeWidthByKey(
recognizedShape.type,
appState.currentItemStrokeWidthKey,
),
});
const normalized =
LinearElementEditor.getNormalizeElementPointsAndCoords(tempElement);
return newArrowElement({
...tempElement,
...normalized,
});
}
case "line": {
const [lineX, lineY] = recognizedShape.points[0];
const endPoint =
recognizedShape.points[recognizedShape.points.length - 1];
const tempElement = newLinearElement({
type: recognizedShape.type,
x: lineX,
y: lineY,
points: [
pointFrom<LocalPoint>(
recognizedShape.points[0][0] - lineX,
recognizedShape.points[0][1] - lineY,
),
pointFrom<LocalPoint>(endPoint[0] - lineX, endPoint[1] - lineY),
],
groupIds: [],
frameId,
locked: false,
angle: 0 as Radians,
roundness,
roughness: appState.currentItemRoughness,
backgroundColor: appState.currentItemBackgroundColor,
strokeColor: appState.currentItemStrokeColor,
fillStyle: appState.currentItemFillStyle,
opacity: appState.currentItemOpacity,
strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: getStrokeWidthByKey(
recognizedShape.type,
appState.currentItemStrokeWidthKey,
),
});
const normalized =
LinearElementEditor.getNormalizeElementPointsAndCoords(tempElement);
return newLinearElement({
...tempElement,
...normalized,
}) as NonDeletedRecognizedShapeElement;
}
}
return undefined;
};
// Whether two drawShape preview elements are visually identical, so a redundant
// preview recompute on pointermove can skip setState (avoiding a no-op React
// re-render and roughjs shape regeneration).
const isDrawShapePreviewEqual = (
a: NonDeletedExcalidrawElement | null,
b: NonDeletedExcalidrawElement,
): boolean => {
if (
!a ||
a.type !== b.type ||
a.x !== b.x ||
a.y !== b.y ||
a.width !== b.width ||
a.height !== b.height ||
a.angle !== b.angle
) {
return false;
}
if (isLinearElement(a) && isLinearElement(b)) {
if (a.points.length !== b.points.length) {
return false;
}
for (let i = 0; i < a.points.length; i++) {
if (
a.points[i][0] !== b.points[i][0] ||
a.points[i][1] !== b.points[i][1]
) {
return false;
}
}
}
return true;
};
// Append to the trail and recompute the shape preview. This runs inside
// withBatchedUpdatesThrottled (throttleRAF), so it fires at most once per
// animation frame regardless of raw pointermove rate.
export const convertToShapeHandlePointerMoveFromPointerDown = (
app: App,
pointerCoords: { x: number; y: number },
) => {
if (app.state.activeTool.type === "autoshape") {
app.drawShape.trail.addPointToPath(pointerCoords.x, pointerCoords.y);
const drawShapeTrailPoints = app.drawShape.trail.getCurrentPoints();
// note: size gate inside recognizeShape
if (drawShapeTrailPoints.length >= 3) {
const shapePreview = convertToShape(
drawShapeTrailPoints,
app.state,
app.scene.getNonDeletedElementsMap(),
app.state.newElement,
app.scene.getNonDeletedFramesLikes(),
) as ExcalidrawNonSelectionElement | undefined;
// Lines get no live preview — nearly every stroke reads as a line at
// some early point, so previewing them is mostly flicker. Releasing
// still commits the line: finalize() re-recognizes from the trail when
// no preview exists.
if (shapePreview && !isLineElement(shapePreview)) {
const prevPreview = app.state.newElement;
const nextPreview = {
...shapePreview,
// Keep a stable id across the gesture while the recognized type
// is unchanged, so the preview element's identity doesn't churn
// frame to frame.
id:
prevPreview?.type === shapePreview.type
? prevPreview.id
: shapePreview.id,
created:
prevPreview?.type === shapePreview.type
? prevPreview.created
: shapePreview.created,
seed: 1,
opacity: ELEMENT_PENDING_DRAW_SHAPE_OPACITY,
isDeleted: false as const,
} as NonDeletedRecognizedShapeElement;
if (!isDrawShapePreviewEqual(prevPreview, nextPreview)) {
app.setState({ newElement: nextPreview });
}
} else if (app.state.newElement !== null) {
app.setState({ newElement: null });
}
}
return true;
}
return false;
};
+1 -2
View File
@@ -26,7 +26,6 @@ import type {
ExcalidrawElement,
ExcalidrawImageElement,
ImageCrop,
NonDeleted,
} from "./types";
export const MINIMAL_CROP_SIZE = 10;
@@ -406,7 +405,7 @@ export const cropElement = (
};
const recomputeOrigin = (
stateAtCropStart: NonDeleted<ExcalidrawElement>,
stateAtCropStart: ExcalidrawElement,
transformHandle: TransformHandleType,
width: number,
height: number,
+185 -38
View File
@@ -14,6 +14,7 @@ import type {
ExcalidrawLinearElement,
ExcalidrawTextElement,
NonDeleted,
NonDeletedExcalidrawElement,
Ordered,
OrderedExcalidrawElement,
SceneElementsMap,
@@ -46,6 +47,7 @@ import { mutateElement, newElementWith } from "./mutateElement";
import { getBoundTextElementId, redrawTextBoundingBox } from "./textElement";
import {
hasBoundTextElement,
isArrowElement,
isBindableElement,
isBoundToContainer,
isTextElement,
@@ -1441,6 +1443,48 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
}
try {
// Whether this delta results in a visible change is decided from the
// *applied* result, not from the property patches: binding repair and
// layout (text bounding boxes, bound arrows) run below and can move
// elements the delta never named, and an `isDeleted` flip or a
// `boundElements` edit is not visible by itself — deleting a note's
// empty label is the canonical case. History relies on this answer to
// skip such entries on undo/redo (see `History`'s apply loop).
//
// `idsToCheck` is every element whose "before" and "after" get
// compared: the changed elements plus what layout can move for them —
// a text's container, and everything in `boundElements` (a container's
// label and arrows). The set is walked as it grows, so an arrow added
// for its container brings in its own label in turn. The dev/test
// assertion after the redraw verifies the set is complete.
const idsToCheck = new Set(changedElements.keys());
// Unchanged elements are shared between `elements` and `nextElements`,
// and layout mutates them in place — their "before" must be a shallow
// copy taken now. Changed elements already have a fresh instance in
// `nextElements`, so the instance in `elements` is their "before" as
// is. Only `idsToCheck` is saved; the whole scene is never copied.
const previousElements = new Map<string, OrderedExcalidrawElement>();
for (const id of idsToCheck) {
const previous = elements.get(id);
const next = nextElements.get(id);
for (const element of [previous, next]) {
if (element) {
if (isTextElement(element) && element.containerId) {
idsToCheck.add(element.containerId);
}
for (const binding of element.boundElements ?? []) {
idsToCheck.add(binding.id);
}
}
}
if (previous) {
previousElements.set(
id,
previous === next ? { ...previous } : previous,
);
}
}
// the following reorder performs mutations, but only on new instances of changed elements,
// unless something goes really bad and it fallbacks to fixing all invalid indices
nextElements = ElementsDelta.reorderElements(
@@ -1449,8 +1493,53 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
flags,
);
// Guard for `idsToCheck`: layout bumps the version of everything it
// mutates, so an element whose version moved during the redraw but was
// never collected is a layout dependency the expansion above does not
// know about — and a visible change that undo would silently skip.
// Fail loudly instead (dev and tests only; this is a scene-wide pass).
const versionsBeforeRedraw =
isTestEnv() || isDevEnv()
? new Map(
Array.from(nextElements, ([id, element]) => [
id,
element.version,
]),
)
: undefined;
ElementsDelta.redrawElements(nextElements, changedElements);
if (versionsBeforeRedraw) {
for (const [id, element] of nextElements) {
if (
versionsBeforeRedraw.get(id) !== element.version &&
!idsToCheck.has(id)
) {
throw new Error(
`Redrawn element "${id}" is missing from idsToCheck`,
);
}
}
}
if (!flags.containsVisibleDifference) {
for (const id of idsToCheck) {
if (
ElementsDelta.checkForVisibleDifference(
previousElements.get(id),
nextElements.get(id),
previousElements,
nextElements,
)
) {
flags.containsVisibleDifference = true;
break;
}
}
}
} catch (e) {
flags.containsVisibleDifference = true;
console.error(
`Couldn't mutate elements after applying elements change`,
e,
@@ -1459,9 +1548,9 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
if (isTestEnv() || isDevEnv()) {
throw e;
}
} finally {
return [nextElements, flags.containsVisibleDifference];
}
return [nextElements, flags.containsVisibleDifference];
}
public squash(delta: ElementsDelta): this {
@@ -1624,12 +1713,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
if (element) {
// as the element was brought from the snapshot, it automatically results in a possible zindex difference
// (whether it is *visible* is decided in `applyTo`, from the applied result)
flags.containsZindexDifference = true;
// as the element was force deleted, we need to check if adding it back results in a visible change
if (!partial.isDeleted || (partial.isDeleted && !element.isDeleted)) {
flags.containsVisibleDifference = true;
}
} else {
// not in elements, not in snapshot? element might have been added remotely!
element = newElementWith(
@@ -1686,17 +1771,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
});
}
if (!flags.containsVisibleDifference) {
// strip away fractional index, as even if it would be different, it doesn't have to result in visible change
const { index, ...rest } = directlyApplicablePartial;
const containsVisibleDifference = ElementsDelta.checkForVisibleDifference(
element,
rest,
);
flags.containsVisibleDifference = containsVisibleDifference;
}
// visibility is not decided here: `applyTo` compares the applied result
// once layout has run, so that its effects count too
if (!flags.containsZindexDifference) {
flags.containsZindexDifference =
delta.deleted.index !== delta.inserted.index;
@@ -1706,29 +1782,96 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
}
/**
* Check for visible changes regardless of whether they were removed, added or updated.
* Whether an element looks different after the delta — and the layout that
* followed it — was applied. The input to history's "skip entries with no
* visible change" loop.
*
* Both sides are first reduced to what actually paints:
* - a missing or deleted element paints nothing;
* - an empty text element paints nothing either — unless it carries arrow
* bindings (an arrow may end at it) or sits on an arrow (an empty label
* still cuts the arrow's gap);
* so an empty label's deletion or restoration compares as nothing against
* nothing. Two painting elements are then compared property by property:
* - `version`, `versionNonce`, `updated`, `created` are metadata, and
* `index` is covered by the separate z-order check (`reorderElements`);
* - `boundElements` is compared as `type:id` references with references to
* empty labels dropped, so adding or removing an empty label's reference
* is not visible on its own, and binding repair that rebuilt equal
* objects is not either;
* - everything else counts. Nested arrays (`points`, binding fixed points)
* compare by reference, which can only over-report a change — the safe
* direction, where undo stops one entry early.
*
* `previousElements` holds only `idsToCheck` (see `applyTo`); the label
* lookups it serves are for elements whose labels are in that set.
*/
private static checkForVisibleDifference(
element: OrderedExcalidrawElement,
partial: ElementPartial,
previous: OrderedExcalidrawElement | undefined,
next: OrderedExcalidrawElement | undefined,
previousElements: ReadonlyMap<string, OrderedExcalidrawElement>,
nextElements: ReadonlyMap<string, OrderedExcalidrawElement>,
) {
if (element.isDeleted && partial.isDeleted !== false) {
// when it's deleted and partial is not false, it cannot end up with a visible change
return false;
const visibleElement = (
element: OrderedExcalidrawElement | undefined,
elements: ReadonlyMap<string, OrderedExcalidrawElement>,
) => {
if (!element || element.isDeleted) {
return null;
}
if (
isTextElement(element) &&
!element.text.trim() &&
!element.boundElements?.length &&
!isArrowElement(elements.get(element.containerId ?? ""))
) {
return null;
}
return element;
};
const before = visibleElement(previous, previousElements);
const after = visibleElement(next, nextElements);
if (!before || !after) {
return before !== after;
}
if (element.isDeleted && partial.isDeleted === false) {
// when we add an element, it results in a visible change
return true;
}
const visibleBindings = (
element: OrderedExcalidrawElement,
elements: ReadonlyMap<string, OrderedExcalidrawElement>,
) =>
(element.boundElements ?? [])
.filter((binding) => {
const label = elements.get(binding.id);
return (
binding.type !== "text" ||
isArrowElement(element) ||
!label ||
!isTextElement(label) ||
!!label.text.trim()
);
})
.map(({ id, type }) => `${type}:${id}`);
if (element.isDeleted === false && partial.isDeleted) {
// when we remove an element, it results in a visible change
return true;
}
// check for any difference on a visible element
return Delta.isRightDifferent(element, partial);
return Delta.getDifferences(before, after).some((key) => {
switch (key) {
// Ordering is checked separately; the remaining fields are metadata.
case "index":
case "version":
case "versionNonce":
case "updated":
case "created":
return false;
case "boundElements":
return !isShallowEqual(
visibleBindings(before, previousElements),
visibleBindings(after, nextElements),
);
default:
return true;
}
});
}
/**
@@ -1968,9 +2111,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
for (const element of changed.values()) {
if (!element.isDeleted && isBindableElement(element)) {
// TODO: with precise bindings this is quite expensive, so consider optimisation so it's only triggered when the arrow does not intersect (imprecise) element bounds
updateBoundElements(element, scene, {
changedElements: changed,
});
updateBoundElements(
element as NonDeletedExcalidrawElement, // NOTE: Assumed correct, no runtime check for isDeleted due to performance reasons
scene,
{
changedElements: changed,
},
);
}
}
}
+33 -7
View File
@@ -2,6 +2,7 @@ import {
curvePointDistance,
distanceToLineSegment,
pointRotateRads,
polygonIncludesPointNonZero,
} from "@excalidraw/math";
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
@@ -34,6 +35,7 @@ export const distanceToElement = (
switch (element.type) {
case "selection":
case "rectangle":
case "stickynote":
case "image":
case "text":
case "iframe":
@@ -47,8 +49,9 @@ export const distanceToElement = (
return distanceToEllipseElement(element, elementsMap, p);
case "line":
case "arrow":
case "freedraw":
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
case "freedraw":
return distanceToFreeDrawElement(element, elementsMap, p);
}
};
@@ -75,9 +78,7 @@ const distanceToRectanguloidElement = (
return Math.min(
...sides.map((s) => distanceToLineSegment(rotatedPoint, s)),
...corners
.map((a) => curvePointDistance(a, rotatedPoint))
.filter((d): d is number => d !== null),
...corners.map((a) => curvePointDistance(a, rotatedPoint)),
);
};
@@ -104,9 +105,7 @@ const distanceToDiamondElement = (
return Math.min(
...sides.map((s) => distanceToLineSegment(rotatedPoint, s)),
...curves
.map((a) => curvePointDistance(a, rotatedPoint))
.filter((d): d is number => d !== null),
...curves.map((a) => curvePointDistance(a, rotatedPoint)),
);
};
@@ -145,3 +144,30 @@ const distanceToLinearOrFreeDraElement = (
...curves.map((a) => curvePointDistance(a, p)),
);
};
/**
* Returns the distance of a point to a freedraw element.
*
* @param element The freedraw element
* @param p The point to consider
* @returns 0 if the point is within the inked area, otherwise the euclidean
* distance to the stroke outline
*/
const distanceToFreeDrawElement = (
element: ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
p: GlobalPoint,
) => {
const [lines] = deconstructLinearOrFreeDrawElement(element, elementsMap);
if (lines.length === 0) {
return Infinity;
}
const polygon = lines.map((line) => line[0]);
if (polygonIncludesPointNonZero(p, polygon)) {
return 0;
}
return Math.min(...lines.map((s) => distanceToLineSegment(p, s)));
};
+6 -3
View File
@@ -5,9 +5,11 @@ import { getCommonBoundingBox } from "./bounds";
import { getSelectedElementsByGroup } from "./groups";
import { getNonDeletedElements } from ".";
import type { Scene } from "./Scene";
import type { ElementsMap, ExcalidrawElement } from "./types";
import type { ElementsMap, NonDeletedExcalidrawElement } from "./types";
export interface Distribution {
space: "between";
@@ -15,12 +17,12 @@ export interface Distribution {
}
export const distributeElements = (
selectedElements: ExcalidrawElement[],
selectedElements: NonDeletedExcalidrawElement[],
elementsMap: ElementsMap,
distribution: Distribution,
appState: Readonly<AppState>,
scene: Scene,
): ExcalidrawElement[] => {
): NonDeletedExcalidrawElement[] => {
const [start, mid, end, extent] =
distribution.axis === "x"
? (["minX", "midX", "maxX", "width"] as const)
@@ -32,6 +34,7 @@ export const distributeElements = (
elementsMap,
appState,
)
.map(getNonDeletedElements) // Nothing to distribute on deleted elements
.map((group) => [group, getCommonBoundingBox(group)] as const)
.sort((a, b) => a[1][mid] - b[1][mid]);
+101 -48
View File
@@ -15,7 +15,11 @@ import type {
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { unbindBindingElement, updateBoundElements } from "./binding";
import {
getSimultaneouslyUpdatedElementIds,
unbindBindingElement,
updateBoundElements,
} from "./binding";
import { getCommonBounds } from "./bounds";
import { getPerfectElementSize } from "./sizeHelpers";
import { getBoundTextElement } from "./textElement";
@@ -30,7 +34,7 @@ import {
import type { Scene } from "./Scene";
import type { ExcalidrawElement } from "./types";
import type { ExcalidrawElement, ExcalidrawTextElement } from "./types";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
@@ -103,23 +107,14 @@ export const dragSelectedElements = (
gridSize,
);
const elementsToUpdateIds = new Set(
Array.from(elementsToUpdate, (el) => el.id),
// one array for the whole update, so that every element's bound-arrow
// update and the unbinding checks share its cached id set
const elementsToUpdateArray = Array.from(elementsToUpdate);
const elementsToUpdateIds = getSimultaneouslyUpdatedElementIds(
elementsToUpdateArray,
);
elementsToUpdate.forEach((element) => {
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);
@@ -137,13 +132,12 @@ export const dragSelectedElements = (
);
}
updateBoundElements(element, scene, {
simultaneouslyUpdated: Array.from(elementsToUpdate),
simultaneouslyUpdated: elementsToUpdateArray,
});
} 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 ||
@@ -151,9 +145,12 @@ export const dragSelectedElements = (
) {
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
const shouldUnbindStart =
element.startBinding && !isStartBoundElementSelected;
const shouldUnbindEnd = element.endBinding && !isEndBoundElementSelected;
const shouldUnbindStart = element.startBinding
? !elementsToUpdateIds.has(element.startBinding.elementId)
: true;
const shouldUnbindEnd = element.endBinding
? !elementsToUpdateIds.has(element.endBinding.elementId)
: true;
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
@@ -203,7 +200,7 @@ const calculateOffset = (
const updateElementCoords = (
pointerDownState: PointerDownState,
element: NonDeletedExcalidrawElement,
element: ExcalidrawElement,
scene: Scene,
dragOffset: { x: number; y: number },
) => {
@@ -228,6 +225,72 @@ export const getDragOffsetXY = (
return [x - x1, y - y1];
};
/**
* Sizes a text element as it is dragged out.
*
* A dragged text pins one point and grows away from it; `anchorRatio` says
* where along the box that point sits — 0 for its left edge, 1 for its right,
* 0.5 for its centre.
*
* A free text pins the point the drag started from and takes the ratio from
* the drag direction, so it can be pulled either way. A text bound to an arrow
* endpoint instead pins whatever the binding placed it against and takes the
* ratio from its alignment — which is also what keeps it from growing back
* over the arrow, since dragging that way makes no progress rather than
* flipping the box around.
*/
export const dragNewTextElement = ({
newElement,
anchorX,
anchorRatio,
pointerX,
nextY,
zoom,
scene,
informMutation = true,
}: {
newElement: ExcalidrawTextElement;
anchorX: number;
/** 0 = anchored by its left edge, 1 = by its right, 0.5 = by its centre */
anchorRatio: number;
pointerX: number;
/** free text re-tops itself to the drag origin; a bound one must not move */
nextY?: number;
zoom: NormalizedZoomValue;
scene: Scene;
informMutation?: boolean;
}) => {
const offset = pointerX - anchorX;
// how far the pointer has travelled away from the anchor along the direction
// the box may grow — negative once it heads back the other way
const reach =
anchorRatio === 0 ? offset : anchorRatio === 1 ? -offset : Math.abs(offset);
const width = Math.max(
// a centred box grows on both sides, so it widens at twice the reach
anchorRatio === 0.5 ? reach * 2 : reach,
getMinTextElementWidth(
getFontString({
fontSize: newElement.fontSize,
fontFamily: newElement.fontFamily,
}),
newElement.lineHeight,
),
);
scene.mutateElement(
newElement,
{
x: anchorX - width * anchorRatio,
...(nextY === undefined ? {} : { y: nextY }),
width,
...(reach > TEXT_AUTOWRAP_THRESHOLD / zoom ? { autoResize: false } : {}),
},
{ informMutation, isDragging: false },
);
};
export const dragNewElement = ({
newElement,
elementType,
@@ -293,6 +356,22 @@ export const dragNewElement = ({
}
}
if (isTextElement(newElement)) {
// a text is only ever sized horizontally — its height follows the wrapped
// content — so it grows away from the point the drag started at
dragNewTextElement({
newElement,
anchorX: originX + (originOffset?.x ?? 0),
anchorRatio: shouldResizeFromCenter ? 0.5 : x < originX ? 1 : 0,
pointerX: x,
nextY: originY + (originOffset?.y ?? 0),
zoom,
scene,
informMutation,
});
return;
}
let newX = x < originX ? originX - width : originX;
let newY = y < originY ? originY - height : originY;
@@ -303,31 +382,6 @@ export const dragNewElement = ({
newY = originY - height / 2;
}
let textAutoResize = null;
if (isTextElement(newElement)) {
height = newElement.height;
const minWidth = getMinTextElementWidth(
getFontString({
fontSize: newElement.fontSize,
fontFamily: newElement.fontFamily,
}),
newElement.lineHeight,
);
width = Math.max(width, minWidth);
if (Math.abs(x - originX) > TEXT_AUTOWRAP_THRESHOLD / zoom) {
textAutoResize = {
autoResize: false,
};
}
newY = originY;
if (shouldResizeFromCenter) {
newX = originX - width / 2;
}
}
if (width !== 0 && height !== 0) {
let imageInitialDimension = null;
if (isImageElement(newElement)) {
@@ -344,7 +398,6 @@ export const dragNewElement = ({
y: newY + (originOffset?.y ?? 0),
width,
height,
...textAutoResize,
...imageInitialDimension,
},
{ informMutation, isDragging: false },
+223 -9
View File
@@ -30,6 +30,7 @@ import { bumpVersion } from "./mutateElement";
import {
hasBoundTextElement,
isArrowElement,
isBoundToContainer,
isFrameLikeElement,
} from "./typeChecks";
@@ -38,13 +39,54 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
import { fixDuplicatedBindingsAfterDuplication } from "./binding";
import { ShapeCache } from "./shape";
import { isNonDeletedElement } from ".";
import type { ElementUpdate } from "./mutateElement";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawElement,
GroupId,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "./types";
/**
* Lookups supplied to the host's `props.onDuplicate`, covering just the
* elements taking part in the duplication.
*/
export type OnDuplicateData = {
/** the duplicates, by their id */
duplicateElements: ReadonlyMap<ExcalidrawElement["id"], ExcalidrawElement>;
/**
* The elements the duplicates were made from, by their id.
*
* On paste and library insert these are the inserted elements, which aren't
* part of the scene (though they may share ids with the scene elements they
* were copied from).
*/
originalElements: ReadonlyMap<ExcalidrawElement["id"], ExcalidrawElement>;
/**
* id of an original -> id of its duplicate (e.g. to remap element ids you
* keep in `customData`, which the duplicate copied from its original)
*/
origIdToDuplicateId: ReadonlyMap<
ExcalidrawElement["id"],
ExcalidrawElement["id"]
>;
/**
* id of a duplicate -> id of its original (e.g. to look up the original in
* `originalElements` while modifying the duplicate)
*/
duplicateIdToOrigId: ReadonlyMap<
ExcalidrawElement["id"],
ExcalidrawElement["id"]
>;
};
/**
* Duplicate an element, often used in the alt-drag operation.
* Note that this method has gotten a bit complicated since the
@@ -72,6 +114,7 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
copy.id = randomId();
copy.updated = getUpdatedTimestamp();
copy.created = copy.updated;
if (randomizeSeed) {
copy.seed = randomInteger();
bumpVersion(copy);
@@ -111,6 +154,9 @@ export const duplicateElements = (
* user interaction.
*/
type: "everything";
// TODO remove/review this once we add frame children order migration
// and invariant checks
preserveFrameChildrenOrder?: boolean;
}
| {
/**
@@ -154,22 +200,25 @@ export const duplicateElements = (
// loop over them.
const processedIds = new Map<ExcalidrawElement["id"], true>();
const groupIdMap = new Map();
const duplicatedElements: ExcalidrawElement[] = [];
const duplicatedElements: NonDeletedExcalidrawElement[] = [];
const origElements: ExcalidrawElement[] = [];
const origIdToDuplicateId = new Map<
ExcalidrawElement["id"],
ExcalidrawElement["id"]
>();
const duplicateIdToOrigElement = new Map<
const duplicateIdToOrigId = new Map<
ExcalidrawElement["id"],
ExcalidrawElement
ExcalidrawElement["id"]
>();
const duplicateElementsMap = new Map<string, ExcalidrawElement>();
const duplicateElementsMap = new Map<string, NonDeletedExcalidrawElement>();
const origElementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
const elementsMap = arrayToMap(elements) as ElementsMap;
const _idsOfElementsToDuplicate =
opts.type === "in-place"
? opts.idsOfElementsToDuplicate
: new Map(elements.map((el) => [el.id, el]));
const preserveFrameChildrenOrder =
opts.type === "everything" && opts.preserveFrameChildrenOrder;
// For sanity
if (opts.type === "in-place") {
@@ -204,18 +253,27 @@ export const duplicateElements = (
processedIds.set(element.id, true);
// SAFETY: this should never happen, but we
// want to make sure we log it if it does
if (!isNonDeletedElement(element)) {
console.error(
"[NONDELETED][INVARIANT] Element to duplicate should be non-deleted",
);
}
const newElement = duplicateElement(
appState.editingGroupId,
groupIdMap,
element,
opts.randomizeSeed,
);
) as NonDeletedExcalidrawElement;
processedIds.set(newElement.id, true);
duplicateElementsMap.set(newElement.id, newElement);
origElementsMap.set(element.id, element);
origIdToDuplicateId.set(element.id, newElement.id);
duplicateIdToOrigElement.set(newElement.id, element);
duplicateIdToOrigId.set(newElement.id, element.id);
origElements.push(element);
duplicatedElements.push(newElement);
@@ -250,6 +308,9 @@ export const duplicateElements = (
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
};
// main
// ---------------------------------------------------------------------------
const frameIdsToDuplicate = new Set(
elements
.filter(
@@ -274,7 +335,7 @@ export const duplicateElements = (
if (groupId) {
const groupElements = getElementsInGroup(elements, groupId).flatMap(
(element) =>
isFrameLikeElement(element)
isFrameLikeElement(element) && !preserveFrameChildrenOrder
? [...getFrameChildren(elements, element.id), element]
: [element],
);
@@ -290,13 +351,25 @@ export const duplicateElements = (
// frame duplication
// -------------------------------------------------------------------------
if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
if (
!preserveFrameChildrenOrder &&
element.frameId &&
frameIdsToDuplicate.has(element.frameId)
) {
continue;
}
if (isFrameLikeElement(element)) {
const frameId = element.id;
if (preserveFrameChildrenOrder) {
insertBeforeOrAfterIndex(
findLastIndex(elementsWithDuplicates, (el) => el.id === frameId),
copyElements(element),
);
continue;
}
const frameChildren = getFrameChildren(elements, frameId);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
@@ -379,7 +452,9 @@ export const duplicateElements = (
if (opts.overrides) {
for (const duplicateElement of duplicatedElements) {
const origElement = duplicateIdToOrigElement.get(duplicateElement.id);
const origElement = origElementsMap.get(
duplicateIdToOrigId.get(duplicateElement.id)!,
);
if (origElement) {
Object.assign(
duplicateElement,
@@ -396,11 +471,150 @@ export const duplicateElements = (
return {
duplicatedElements,
duplicateElementsMap,
origElementsMap,
elementsWithDuplicates,
origIdToDuplicateId,
duplicateIdToOrigId,
};
};
/**
* Folds the elements returned by the host (`props.onDuplicate`) back into the
* duplicates the editor created, so that everything that follows (frame
* assignment, bound text redraw, selection, alt-drag handover) can keep
* working with the editor's own objects, whether the host mutated the
* duplicates or returned new objects for them.
*
* - A returned element with a duplicate's id is shallow-merged into that
* duplicate, which takes its place in the returned array. Properties the
* host omits are kept, so that a partial element can't invalidate the
* duplicate. Safe only because the duplicates are fresh (not in the scene or
* the store snapshot yet), which is why nothing but the passed duplicates is
* ever merged into. Since the merge goes around `mutateElement`, what may
* have been cached for the duplicate by then is invalidated here.
* - A duplicate missing from the returned array (or returned as deleted) is
* vetoed. So is the bound text of a vetoed container. What the remaining
* duplicates reference of the vetoed ones is cleared, as if those were never
* part of the duplication (see `fixDuplicatedBindingsAfterDuplication`).
* - Any other returned element is used as is (existing elements must not be
* mutated, so the host replaces them).
* - `false` vetoes all the duplicates.
*
* @returns next elements, and the duplicates that weren't vetoed
*/
export const reconcileDuplicatedElements = <
TDuplicate extends ExcalidrawElement,
>(
/** what the host returned from `props.onDuplicate`, if anything */
hostElements: readonly ExcalidrawElement[] | void | false,
/** elements that were passed to the host */
nextElements: ExcalidrawElement[],
duplicatedElements: TDuplicate[],
): {
elements: ExcalidrawElement[];
duplicatedElements: TDuplicate[];
} => {
if (hostElements === false) {
return { elements: nextElements, duplicatedElements: [] };
}
if (!hostElements) {
return { elements: nextElements, duplicatedElements };
}
const duplicatesMap = arrayToMap(duplicatedElements);
// (if a duplicate is returned more than once, the last one wins)
const hostDuplicates = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
for (const element of hostElements) {
if (duplicatesMap.has(element.id)) {
hostDuplicates.set(element.id, element);
}
}
// merge first, so that everything below sees the duplicates as the host
// wants them (what the host returned may be partial)
const survivedIds = new Set<ExcalidrawElement["id"]>();
for (const [id, element] of hostDuplicates) {
const duplicate = duplicatesMap.get(id)!;
if (element !== duplicate) {
Object.assign(duplicate, element);
// The duplicate may have been measured already (e.g. to resolve the
// frame it's pasted into), and we're going around `mutateElement`.
// The shape is cached by identity, the bounds by version.
ShapeCache.delete(duplicate);
bumpVersion(duplicate);
}
if (!duplicate.isDeleted) {
survivedIds.add(id);
}
}
const isVetoed = (id: ExcalidrawElement["id"]) =>
duplicatesMap.has(id) && !survivedIds.has(id);
for (const id of survivedIds) {
const duplicate = duplicatesMap.get(id)!;
if (isBoundToContainer(duplicate) && isVetoed(duplicate.containerId)) {
survivedIds.delete(id);
}
}
const elements: ExcalidrawElement[] = [];
for (const element of hostElements) {
const duplicate = duplicatesMap.get(element.id);
if (!duplicate) {
elements.push(element);
} else if (
survivedIds.has(element.id) &&
hostDuplicates.get(element.id) === element
) {
elements.push(duplicate);
}
}
if (survivedIds.size === duplicatedElements.length) {
return { elements, duplicatedElements };
}
const survivedDuplicates = duplicatedElements.filter((duplicate) =>
survivedIds.has(duplicate.id),
);
for (const duplicate of survivedDuplicates) {
const updates: Mutable<ElementUpdate<ExcalidrawArrowElement>> = {};
if (duplicate.boundElements?.some((binding) => isVetoed(binding.id))) {
updates.boundElements = duplicate.boundElements.filter(
(binding) => !isVetoed(binding.id),
);
}
if (duplicate.frameId && isVetoed(duplicate.frameId)) {
updates.frameId = null;
}
if (isArrowElement(duplicate)) {
if (
duplicate.startBinding &&
isVetoed(duplicate.startBinding.elementId)
) {
updates.startBinding = null;
}
if (duplicate.endBinding && isVetoed(duplicate.endBinding.elementId)) {
updates.endBinding = null;
}
}
Object.assign(duplicate, updates);
}
return { elements, duplicatedElements: survivedDuplicates };
};
// Simplified deep clone for the purpose of cloning ExcalidrawElement.
//
// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
+28 -33
View File
@@ -21,6 +21,7 @@ import {
getSizeFromPoints,
isDevEnv,
arrayToMap,
DEFAULT_ZOOM,
} from "@excalidraw/common";
import type { AppState } from "@excalidraw/excalidraw/types";
@@ -30,8 +31,7 @@ import {
getHeadingForElbowArrowSnap,
getGlobalFixedPointForBindableElement,
getBindingGap,
maxBindingDistance_simple,
BASE_BINDING_GAP_ELBOW,
BASE_BINDING_GAP,
} from "./binding";
import { distanceToElement } from "./distance";
import {
@@ -1217,24 +1217,20 @@ const getElbowArrowData = (
GlobalPoint
>(nextPoints[nextPoints.length - 1], vector(arrow.x, arrow.y));
// NOTE: updates coming through `mutateElement` don't carry the zoom, so
// those fall back to the zoom=1 binding distance
const zoom = options?.zoom ?? DEFAULT_ZOOM;
let hoveredStartElement = null;
let hoveredEndElement = null;
if (options?.isDragging && options?.isBindingEnabled !== false) {
const elements = Array.from(elementsMap.values());
hoveredStartElement =
getHoveredElement(
origStartGlobalPoint,
elementsMap,
elements,
options?.zoom,
) || null;
getHoveredElement(origStartGlobalPoint, elementsMap, elements, zoom) ||
null;
hoveredEndElement =
getHoveredElement(
origEndGlobalPoint,
elementsMap,
elements,
options?.zoom,
) || null;
getHoveredElement(origEndGlobalPoint, elementsMap, elements, zoom) ||
null;
} else {
hoveredStartElement = arrow.startBinding
? getBindableElementForId(arrow.startBinding.elementId, elementsMap) ||
@@ -1256,6 +1252,7 @@ const getElbowArrowData = (
"start",
arrow.startBinding?.fixedPoint,
origStartGlobalPoint,
zoom,
hoveredStartElement,
elementsMap,
options?.isDragging,
@@ -1273,6 +1270,7 @@ const getElbowArrowData = (
"end",
arrow.endBinding?.fixedPoint,
origEndGlobalPoint,
zoom,
hoveredEndElement,
elementsMap,
options?.isDragging,
@@ -1285,7 +1283,7 @@ const getElbowArrowData = (
hoveredStartElement,
origStartGlobalPoint,
elementsMap,
options?.zoom,
zoom,
);
const endHeading = getBindPointHeading(
endGlobalPoint,
@@ -1293,7 +1291,7 @@ const getElbowArrowData = (
hoveredEndElement,
origEndGlobalPoint,
elementsMap,
options?.zoom,
zoom,
);
const startPointBounds = [
startGlobalPoint[0] - 2,
@@ -1314,8 +1312,8 @@ const getElbowArrowData = (
offsetFromHeading(
startHeading,
arrow.startArrowhead
? getBindingGap(hoveredStartElement, { elbowed: true }) * 6
: getBindingGap(hoveredStartElement, { elbowed: true }) * 2,
? getBindingGap(hoveredStartElement) * 6
: getBindingGap(hoveredStartElement) * 2,
1,
),
)
@@ -1327,8 +1325,8 @@ const getElbowArrowData = (
offsetFromHeading(
endHeading,
arrow.endArrowhead
? getBindingGap(hoveredEndElement, { elbowed: true }) * 6
: getBindingGap(hoveredEndElement, { elbowed: true }) * 2,
? getBindingGap(hoveredEndElement) * 6
: getBindingGap(hoveredEndElement) * 2,
1,
),
)
@@ -1375,8 +1373,8 @@ const getElbowArrowData = (
? 0
: BASE_PADDING -
(arrow.startArrowhead
? BASE_BINDING_GAP_ELBOW * 6
: BASE_BINDING_GAP_ELBOW * 2),
? BASE_BINDING_GAP * 6
: BASE_BINDING_GAP * 2),
BASE_PADDING,
),
boundsOverlap
@@ -1391,8 +1389,8 @@ const getElbowArrowData = (
? 0
: BASE_PADDING -
(arrow.endArrowhead
? BASE_BINDING_GAP_ELBOW * 6
: BASE_BINDING_GAP_ELBOW * 2),
? BASE_BINDING_GAP * 6
: BASE_BINDING_GAP * 2),
BASE_PADDING,
),
boundsOverlap,
@@ -2124,8 +2122,8 @@ const normalizeArrowElementUpdate = (
offsetY < -MAX_POS ||
offsetY > MAX_POS ||
offsetX + points[points.length - 1][0] < -MAX_POS ||
offsetY + points[points.length - 1][0] > MAX_POS ||
offsetX + points[points.length - 1][1] < -MAX_POS ||
offsetX + points[points.length - 1][0] > MAX_POS ||
offsetY + points[points.length - 1][1] < -MAX_POS ||
offsetY + points[points.length - 1][1] > MAX_POS
) {
console.error(
@@ -2218,6 +2216,7 @@ const getGlobalPoint = (
startOrEnd: "start" | "end",
fixedPointRatio: [number, number] | undefined | null,
initialPoint: GlobalPoint,
zoom: AppState["zoom"],
element?: ExcalidrawBindableElement | null,
elementsMap?: ElementsMap,
isDragging?: boolean,
@@ -2231,6 +2230,7 @@ const getGlobalPoint = (
element,
startOrEnd,
elementsMap,
zoom,
undefined,
isMidpointSnappingEnabled,
);
@@ -2282,14 +2282,9 @@ const getHoveredElement = (
origPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
zoom?: AppState["zoom"],
zoom: AppState["zoom"],
) => {
return getHoveredElementForBinding(
origPoint,
elements,
elementsMap,
maxBindingDistance_simple(zoom),
);
return getHoveredElementForBinding(origPoint, elements, elementsMap, zoom);
};
const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
+3 -3
View File
@@ -13,9 +13,9 @@ import { wrapText } from "./textWrapping";
import { isIframeElement } from "./typeChecks";
import type {
ExcalidrawElement,
ExcalidrawIframeLikeElement,
IframeData,
NonDeletedExcalidrawElement,
} from "./types";
type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
@@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
const RE_YOUTUBE =
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/;
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|live\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/;
const RE_VIMEO =
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
@@ -401,7 +401,7 @@ export const getEmbedLink = (
export const createPlaceholderEmbeddableLabel = (
element: ExcalidrawIframeLikeElement,
): ExcalidrawElement => {
): NonDeletedExcalidrawElement => {
let text: string;
if (isIframeElement(element)) {
text = "IFrame element";
+362 -316
View File
@@ -1,6 +1,11 @@
import { KEYS, invariant, toBrandedType } from "@excalidraw/common";
import { invariant, toBrandedType, type Bounds } from "@excalidraw/common";
import { type GlobalPoint, pointFrom, type LocalPoint } from "@excalidraw/math";
import {
clamp,
type GlobalPoint,
pointFrom,
type LocalPoint,
} from "@excalidraw/math";
import type {
AppState,
@@ -20,7 +25,11 @@ import {
} from "./heading";
import { LinearElementEditor } from "./linearElementEditor";
import { mutateElement } from "./mutateElement";
import { newArrowElement, newElement } from "./newElement";
import {
newArrowElement,
newElement,
newStickyNoteElement,
} from "./newElement";
import { aabbForElement } from "./bounds";
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
import {
@@ -30,10 +39,12 @@ import {
isFlowchartNodeElement,
} from "./typeChecks";
import {
type NonDeleted,
type ElementsMap,
type ExcalidrawBindableElement,
type ExcalidrawElement,
type ExcalidrawFlowchartNodeElement,
type NonDeletedExcalidrawElement,
type NonDeletedSceneElementsMap,
type Ordered,
type OrderedExcalidrawElement,
@@ -41,318 +52,247 @@ import {
import type { Scene } from "./Scene";
type LinkDirection = "up" | "right" | "down" | "left";
export type LinkDirection = "up" | "right" | "down" | "left";
const VERTICAL_OFFSET = 100;
const HORIZONTAL_OFFSET = 100;
export const getLinkDirectionFromKey = (key: string): LinkDirection => {
switch (key) {
case KEYS.ARROW_UP:
return "up";
case KEYS.ARROW_DOWN:
return "down";
case KEYS.ARROW_RIGHT:
return "right";
case KEYS.ARROW_LEFT:
return "left";
default:
return "right";
type Interval = { start: number; end: number };
const mergeIntervals = (intervals: Interval[]): Interval[] => {
const sorted = [...intervals].sort((a, b) => a.start - b.start);
const merged: Interval[] = [];
for (const interval of sorted) {
const last = merged[merged.length - 1];
if (last && interval.start <= last.end) {
last.end = Math.max(last.end, interval.end);
} else {
merged.push({ ...interval });
}
}
return merged;
};
const getNodeRelatives = (
type: "predecessors" | "successors",
const intervalIsFree = (start: number, size: number, occupied: Interval[]) =>
occupied.every((o) => start + size <= o.start || start >= o.end);
// Nearest `start` for a segment of `size` avoiding every occupied interval,
// searching both sides of `ideal`; a tie resolves toward the positive side.
const findNearestFreeSlot = (
ideal: number,
size: number,
occupied: Interval[],
): number => {
if (intervalIsFree(ideal, size, occupied)) {
return ideal;
}
const gapStarts = [-Infinity, ...occupied.map((o) => o.end)];
const gapEnds = [...occupied.map((o) => o.start), Infinity];
let best = ideal;
let bestDistance = Infinity;
for (let i = 0; i < gapStarts.length; i++) {
if (gapEnds[i] - gapStarts[i] < size) {
continue;
}
const start = clamp(ideal, gapStarts[i], gapEnds[i] - size);
const distance = Math.abs(start - ideal);
if (distance <= bestDistance) {
best = start;
bestDistance = distance;
}
}
return best;
};
// Walk the arrow bindings to collect every node that belongs to the same
// flowchart as `node` — the whole connected component acts as the obstacle
// set during placement (#8518).
const getConnectedFlowchartNodes = (
node: ExcalidrawBindableElement,
elementsMap: ElementsMap,
direction: LinkDirection,
) => {
const items = [...elementsMap.values()].reduce(
(acc: { relative: ExcalidrawBindableElement; heading: Heading }[], el) => {
let oppositeBinding;
if (
isElbowArrow(el) &&
// we want check existence of the opposite binding, in the direction
// we're interested in
(oppositeBinding =
el[type === "predecessors" ? "startBinding" : "endBinding"]) &&
// similarly, we need to filter only arrows bound to target node
el[type === "predecessors" ? "endBinding" : "startBinding"]
?.elementId === node.id
) {
const relative = elementsMap.get(oppositeBinding.elementId);
): ExcalidrawBindableElement[] => {
const arrows = [...elementsMap.values()].filter(isElbowArrow);
const visited = new Set<string>([node.id]);
const queue: string[] = [node.id];
const connected: ExcalidrawBindableElement[] = [];
if (!relative) {
return acc;
}
while (queue.length > 0) {
const currentId = queue.shift()!;
for (const arrow of arrows) {
const startId = arrow.startBinding?.elementId;
const endId = arrow.endBinding?.elementId;
invariant(
isBindableElement(relative),
"not an ExcalidrawBindableElement",
);
const edgePoint = (
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
) as Readonly<LocalPoint>;
const heading = headingForPointFromElement(
node,
aabbForElement(node, elementsMap),
[edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
);
acc.push({
relative,
heading,
});
let neighborId: string | undefined;
if (startId === currentId) {
neighborId = endId;
} else if (endId === currentId) {
neighborId = startId;
}
return acc;
},
[],
if (!neighborId || visited.has(neighborId)) {
continue;
}
visited.add(neighborId);
const neighbor = elementsMap.get(neighborId);
if (neighbor && isBindableElement(neighbor)) {
connected.push(neighbor);
queue.push(neighborId);
}
}
}
return connected;
};
// Place a cluster of `count` equally-sized nodes next to `parent`:
// - the primary axis (the creation direction) is fixed at exactly one gap
// away from the parent, forming a search band the cluster will occupy
// - along the cross axis the cluster slides into the free slot nearest the
// parent-centered ideal, treating band obstacles as immovable
// - `stickyCrossStart` anchors an already-visible pending cluster: growing it
// extends it at either end so the existing pending nodes keep their
// positions, unless the grown cluster no longer fits there
const placeCluster = (
parent: ExcalidrawFlowchartNodeElement,
direction: LinkDirection,
count: number,
obstacles: readonly Bounds[],
stickyCrossStart: number | null,
): { positions: { x: number; y: number }[]; crossStart: number } => {
const horizontal = direction === "left" || direction === "right";
// INSIGHT: new nodes copy the parent's dimensions
const nodePrimarySize = horizontal ? parent.width : parent.height;
const nodeCrossSize = horizontal ? parent.height : parent.width;
const primaryGap = horizontal ? HORIZONTAL_OFFSET : VERTICAL_OFFSET;
const crossGap = horizontal ? VERTICAL_OFFSET : HORIZONTAL_OFFSET;
const parentPrimaryStart = horizontal ? parent.x : parent.y;
const parentCrossCenter = horizontal
? parent.y + parent.height / 2
: parent.x + parent.width / 2;
const primaryStart =
direction === "right" || direction === "down"
? parentPrimaryStart + nodePrimarySize + primaryGap
: parentPrimaryStart - primaryGap - nodePrimarySize;
// cross-axis intervals of the obstacles sharing the band, inflated so the
// cluster keeps at least one gap of clearance
const occupied = mergeIntervals(
obstacles
.filter((bounds) => {
const start = horizontal ? bounds[0] : bounds[1];
const end = horizontal ? bounds[2] : bounds[3];
return start < primaryStart + nodePrimarySize && end > primaryStart;
})
.map((bounds) => ({
start: (horizontal ? bounds[1] : bounds[0]) - crossGap,
end: (horizontal ? bounds[3] : bounds[2]) + crossGap,
})),
);
switch (direction) {
case "up":
return items
.filter((item) => compareHeading(item.heading, HEADING_UP))
.map((item) => item.relative);
case "down":
return items
.filter((item) => compareHeading(item.heading, HEADING_DOWN))
.map((item) => item.relative);
case "right":
return items
.filter((item) => compareHeading(item.heading, HEADING_RIGHT))
.map((item) => item.relative);
case "left":
return items
.filter((item) => compareHeading(item.heading, HEADING_LEFT))
.map((item) => item.relative);
}
};
const step = nodeCrossSize + crossGap;
const clusterCrossSize = count * nodeCrossSize + (count - 1) * crossGap;
const getSuccessors = (
node: ExcalidrawBindableElement,
elementsMap: ElementsMap,
direction: LinkDirection,
) => {
return getNodeRelatives("successors", node, elementsMap, direction);
};
const anchoredStart =
stickyCrossStart === null
? null
: [stickyCrossStart, stickyCrossStart - step]
.filter((start) => intervalIsFree(start, clusterCrossSize, occupied))
.sort(
(a, b) =>
Math.abs(a + clusterCrossSize / 2 - parentCrossCenter) -
Math.abs(b + clusterCrossSize / 2 - parentCrossCenter),
)[0] ?? null;
export const getPredecessors = (
node: ExcalidrawBindableElement,
elementsMap: ElementsMap,
direction: LinkDirection,
) => {
return getNodeRelatives("predecessors", node, elementsMap, direction);
};
const crossStart =
anchoredStart ??
findNearestFreeSlot(
parentCrossCenter - clusterCrossSize / 2,
clusterCrossSize,
occupied,
);
const getOffsets = (
element: ExcalidrawFlowchartNodeElement,
linkedNodes: ExcalidrawElement[],
direction: LinkDirection,
) => {
const _HORIZONTAL_OFFSET = HORIZONTAL_OFFSET + element.width;
// check if vertical space or horizontal space is available first
if (direction === "up" || direction === "down") {
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
// check vertical space
const minX = element.x;
const maxX = element.x + element.width;
// vertical space is available
if (
linkedNodes.every(
(linkedNode) =>
linkedNode.x + linkedNode.width < minX || linkedNode.x > maxX,
)
) {
return {
x: 0,
y: _VERTICAL_OFFSET * (direction === "up" ? -1 : 1),
};
}
} else if (direction === "right" || direction === "left") {
const minY = element.y;
const maxY = element.y + element.height;
if (
linkedNodes.every(
(linkedNode) =>
linkedNode.y + linkedNode.height < minY || linkedNode.y > maxY,
)
) {
return {
x:
(HORIZONTAL_OFFSET + element.width) * (direction === "left" ? -1 : 1),
y: 0,
};
}
}
if (direction === "up" || direction === "down") {
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
const y = linkedNodes.length === 0 ? _VERTICAL_OFFSET : _VERTICAL_OFFSET;
const x =
linkedNodes.length === 0
? 0
: (linkedNodes.length + 1) % 2 === 0
? ((linkedNodes.length + 1) / 2) * _HORIZONTAL_OFFSET
: (linkedNodes.length / 2) * _HORIZONTAL_OFFSET * -1;
if (direction === "up") {
return {
x,
y: y * -1,
};
}
return {
x,
y,
};
}
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
const x =
(linkedNodes.length === 0 ? HORIZONTAL_OFFSET : HORIZONTAL_OFFSET) +
element.width;
const y =
linkedNodes.length === 0
? 0
: (linkedNodes.length + 1) % 2 === 0
? ((linkedNodes.length + 1) / 2) * _VERTICAL_OFFSET
: (linkedNodes.length / 2) * _VERTICAL_OFFSET * -1;
if (direction === "left") {
return {
x: x * -1,
y,
};
}
return {
x,
y,
};
};
const addNewNode = (
element: ExcalidrawFlowchartNodeElement,
appState: AppState,
direction: LinkDirection,
scene: Scene,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
const successors = getSuccessors(element, elementsMap, direction);
const predeccessors = getPredecessors(element, elementsMap, direction);
const offsets = getOffsets(
element,
[...successors, ...predeccessors],
direction,
);
const nextNode = newElement({
type: element.type,
x: element.x + offsets.x,
y: element.y + offsets.y,
// TODO: extract this to a util
width: element.width,
height: element.height,
roundness: element.roundness,
roughness: element.roughness,
backgroundColor: element.backgroundColor,
strokeColor: element.strokeColor,
strokeWidth: element.strokeWidth,
opacity: element.opacity,
fillStyle: element.fillStyle,
strokeStyle: element.strokeStyle,
const positions = Array.from({ length: count }, (_, index) => {
const cross = crossStart + index * step;
return horizontal
? { x: primaryStart, y: cross }
: { x: cross, y: primaryStart };
});
return { positions, crossStart };
};
const cloneFlowchartNode = (
template: ExcalidrawFlowchartNodeElement,
x: number,
y: number,
) => {
const commonNodeProps = {
x,
y,
width: template.width,
height: template.height,
roundness: template.roundness,
roughness: template.roughness,
backgroundColor: template.backgroundColor,
strokeColor: template.strokeColor,
strokeWidth: template.strokeWidth,
opacity: template.opacity,
fillStyle: template.fillStyle,
strokeStyle: template.strokeStyle,
};
const node =
template.type === "stickynote"
? newStickyNoteElement({
type: "stickynote",
...commonNodeProps,
baseHeight: template.baseHeight,
})
: newElement({
type: template.type,
...commonNodeProps,
});
invariant(
isFlowchartNodeElement(nextNode),
isFlowchartNodeElement(node),
"not an ExcalidrawFlowchartNodeElement",
);
const bindingArrow = createBindingArrow(
element,
nextNode,
direction,
appState,
scene,
);
return {
nextNode,
bindingArrow,
};
return node;
};
export const addNewNodes = (
startNode: ExcalidrawFlowchartNodeElement,
startNode: NonDeleted<ExcalidrawFlowchartNodeElement>,
appState: AppState,
direction: LinkDirection,
scene: Scene,
numberOfNodes: number,
stickyCrossStart: number | null = null,
) => {
// always start from 0 and distribute evenly
const newNodes: ExcalidrawElement[] = [];
const elementsMap = scene.getNonDeletedElementsMap();
const obstacles = getConnectedFlowchartNodes(startNode, elementsMap).map(
(node) => aabbForElement(node, elementsMap),
);
for (let i = 0; i < numberOfNodes; i++) {
let nextX: number;
let nextY: number;
if (direction === "left" || direction === "right") {
const totalHeight =
VERTICAL_OFFSET * (numberOfNodes - 1) +
numberOfNodes * startNode.height;
const startY = startNode.y + startNode.height / 2 - totalHeight / 2;
let offsetX = HORIZONTAL_OFFSET + startNode.width;
if (direction === "left") {
offsetX *= -1;
}
nextX = startNode.x + offsetX;
const offsetY = (VERTICAL_OFFSET + startNode.height) * i;
nextY = startY + offsetY;
} else {
const totalWidth =
HORIZONTAL_OFFSET * (numberOfNodes - 1) +
numberOfNodes * startNode.width;
const startX = startNode.x + startNode.width / 2 - totalWidth / 2;
let offsetY = VERTICAL_OFFSET + startNode.height;
if (direction === "up") {
offsetY *= -1;
}
nextY = startNode.y + offsetY;
const offsetX = (HORIZONTAL_OFFSET + startNode.width) * i;
nextX = startX + offsetX;
}
const nextNode = newElement({
type: startNode.type,
x: nextX,
y: nextY,
// TODO: extract this to a util
width: startNode.width,
height: startNode.height,
roundness: startNode.roundness,
roughness: startNode.roughness,
backgroundColor: startNode.backgroundColor,
strokeColor: startNode.strokeColor,
strokeWidth: startNode.strokeWidth,
opacity: startNode.opacity,
fillStyle: startNode.fillStyle,
strokeStyle: startNode.strokeStyle,
});
invariant(
isFlowchartNodeElement(nextNode),
"not an ExcalidrawFlowchartNodeElement",
);
const { positions, crossStart } = placeCluster(
startNode,
direction,
numberOfNodes,
obstacles,
stickyCrossStart,
);
const nodes: NonDeletedExcalidrawElement[] = [];
for (const position of positions) {
const nextNode = cloneFlowchartNode(startNode, position.x, position.y);
const bindingArrow = createBindingArrow(
startNode,
nextNode,
@@ -361,16 +301,15 @@ export const addNewNodes = (
scene,
);
newNodes.push(nextNode);
newNodes.push(bindingArrow);
nodes.push(nextNode, bindingArrow);
}
return newNodes;
return { nodes, crossStart };
};
const createBindingArrow = (
startBindingElement: ExcalidrawFlowchartNodeElement,
endBindingElement: ExcalidrawFlowchartNodeElement,
startBindingElement: NonDeleted<ExcalidrawFlowchartNodeElement>,
endBindingElement: NonDeleted<ExcalidrawFlowchartNodeElement>,
direction: LinkDirection,
appState: AppState,
scene: Scene,
@@ -452,8 +391,16 @@ const createBindingArrow = (
"orbit",
"start",
scene,
appState.zoom,
);
bindBindingElement(
bindingArrow,
endBindingElement,
"orbit",
"end",
scene,
appState.zoom,
);
bindBindingElement(bindingArrow, endBindingElement, "orbit", "end", scene);
const changedElements = new Map<string, OrderedExcalidrawElement>();
changedElements.set(
@@ -490,7 +437,7 @@ const createBindingArrow = (
[startBindingElement.id, startBindingElement],
[endBindingElement.id, endBindingElement],
[bindingArrow.id, bindingArrow],
] as [string, Ordered<ExcalidrawElement>][]),
] as [string, Ordered<NonDeletedExcalidrawElement>][]),
),
{ points: bindingArrow.points },
);
@@ -498,6 +445,7 @@ const createBindingArrow = (
return {
...bindingArrow,
...update,
isDeleted: bindingArrow.isDeleted,
};
};
@@ -562,8 +510,8 @@ export class FlowChartNavigator {
}
const nodes = [
...getSuccessors(element, elementsMap, direction),
...getPredecessors(element, elementsMap, direction),
...FlowChartNavigator.getSuccessors(element, elementsMap, direction),
...FlowChartNavigator.getPredecessors(element, elementsMap, direction),
];
/**
@@ -612,8 +560,8 @@ export class FlowChartNavigator {
const otherLinkedNodes = otherDirections
.map((dir) => [
...getSuccessors(element, elementsMap, dir),
...getPredecessors(element, elementsMap, dir),
...FlowChartNavigator.getSuccessors(element, elementsMap, dir),
...FlowChartNavigator.getPredecessors(element, elementsMap, dir),
])
.flat()
.filter((linkedNode) => !this.visitedNodes.has(linkedNode.id));
@@ -630,48 +578,145 @@ export class FlowChartNavigator {
return null;
}
private static getNodeRelatives(
type: "predecessors" | "successors",
node: ExcalidrawBindableElement,
elementsMap: ElementsMap,
direction: LinkDirection,
) {
const items = [...elementsMap.values()].reduce(
(
acc: { relative: ExcalidrawBindableElement; heading: Heading }[],
el,
) => {
let oppositeBinding;
if (
isElbowArrow(el) &&
// we want check existence of the opposite binding, in the direction
// we're interested in
(oppositeBinding =
el[type === "predecessors" ? "startBinding" : "endBinding"]) &&
// similarly, we need to filter only arrows bound to target node
el[type === "predecessors" ? "endBinding" : "startBinding"]
?.elementId === node.id
) {
const relative = elementsMap.get(oppositeBinding.elementId);
if (!relative) {
return acc;
}
invariant(
isBindableElement(relative),
"not an ExcalidrawBindableElement",
);
const edgePoint = (
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
) as Readonly<LocalPoint>;
const heading = headingForPointFromElement(
node,
aabbForElement(node, elementsMap),
[edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
);
acc.push({
relative,
heading,
});
}
return acc;
},
[],
);
switch (direction) {
case "up":
return items
.filter((item) => compareHeading(item.heading, HEADING_UP))
.map((item) => item.relative);
case "down":
return items
.filter((item) => compareHeading(item.heading, HEADING_DOWN))
.map((item) => item.relative);
case "right":
return items
.filter((item) => compareHeading(item.heading, HEADING_RIGHT))
.map((item) => item.relative);
case "left":
return items
.filter((item) => compareHeading(item.heading, HEADING_LEFT))
.map((item) => item.relative);
}
}
private static getSuccessors(
node: ExcalidrawBindableElement,
elementsMap: ElementsMap,
direction: LinkDirection,
) {
return FlowChartNavigator.getNodeRelatives(
"successors",
node,
elementsMap,
direction,
);
}
private static getPredecessors(
node: ExcalidrawBindableElement,
elementsMap: ElementsMap,
direction: LinkDirection,
) {
return FlowChartNavigator.getNodeRelatives(
"predecessors",
node,
elementsMap,
direction,
);
}
}
export class FlowChartCreator {
isCreatingChart: boolean = false;
private numberOfNodes: number = 0;
private direction: LinkDirection | null = "right";
private direction: LinkDirection | null = null;
// cross-axis anchor of the pending cluster, so growing it keeps the
// already-visible pending nodes in place
private clusterCrossStart: number | null = null;
pendingNodes: PendingExcalidrawElements | null = null;
createNodes(
startNode: ExcalidrawFlowchartNodeElement,
startNode: NonDeleted<ExcalidrawFlowchartNodeElement>,
appState: AppState,
direction: LinkDirection,
scene: Scene,
) {
const elementsMap = scene.getNonDeletedElementsMap();
if (direction !== this.direction) {
const { nextNode, bindingArrow } = addNewNode(
startNode,
appState,
direction,
scene,
);
if (direction !== this.direction) {
this.numberOfNodes = 1;
this.isCreatingChart = true;
this.direction = direction;
this.pendingNodes = [nextNode, bindingArrow];
this.clusterCrossStart = null;
} else {
this.numberOfNodes += 1;
const newNodes = addNewNodes(
startNode,
appState,
direction,
scene,
this.numberOfNodes,
);
this.isCreatingChart = true;
this.direction = direction;
this.pendingNodes = newNodes;
}
const { nodes, crossStart } = addNewNodes(
startNode,
appState,
direction,
scene,
this.numberOfNodes,
this.clusterCrossStart,
);
this.isCreatingChart = true;
this.direction = direction;
this.clusterCrossStart = crossStart;
this.pendingNodes = nodes;
// add pending nodes to the same frame as the start node
// if every pending node is at least intersecting with the frame
if (startNode.frameId) {
@@ -704,6 +749,7 @@ export class FlowChartCreator {
this.pendingNodes = null;
this.direction = null;
this.numberOfNodes = 0;
this.clusterCrossStart = null;
}
}
+37 -10
View File
@@ -1,15 +1,21 @@
import { generateNKeysBetween } from "fractional-indexing";
import { arrayToMap } from "@excalidraw/common";
import {
validateOrderKey,
generateNKeysBetween,
} from "@excalidraw/fractional-indexing";
import { mutateElement, newElementWith } from "./mutateElement";
import { getBoundTextElement } from "./textElement";
import { getBoundTextElementId } from "./textElement";
import { hasBoundTextElement } from "./typeChecks";
import { isNonDeletedElement } from ".";
import type {
ElementsMap,
ExcalidrawElement,
FractionalIndex,
Ordered,
OrderedExcalidrawElement,
SceneElementsMap,
} from "./types";
@@ -61,6 +67,8 @@ export const validateFractionalIndices = (
const stringifyElement = (element: ExcalidrawElement | void) =>
`${element?.index}:${element?.id}:${element?.type}:${element?.isDeleted}:${element?.version}:${element?.versionNonce}`;
const elementsMap = includeBoundTextValidation ? arrayToMap(elements) : null;
const indices = elements.map((x) => x.index);
for (const [i, index] of indices.entries()) {
const predecessorIndex = indices[i - 1];
@@ -77,11 +85,23 @@ export const validateFractionalIndices = (
}
// disabled by default, as we don't fix it
if (includeBoundTextValidation && hasBoundTextElement(elements[i])) {
if (
includeBoundTextValidation &&
elementsMap &&
hasBoundTextElement(elements[i]) &&
isNonDeletedElement(elements[i])
) {
const container = elements[i];
const text = getBoundTextElement(container, arrayToMap(elements));
const boundTextElementId = getBoundTextElementId(container);
const text = boundTextElementId
? elementsMap.get(boundTextElementId)
: null;
if (text && text.index! <= container.index!) {
if (
text &&
isNonDeletedElement(text) &&
text.index! <= container.index!
) {
errorMessages.push(
`Fractional indices invariant for bound elements has been compromised: "${stringifyElement(
text,
@@ -200,9 +220,9 @@ export const syncMovedIndices = (
*
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
*/
export const syncInvalidIndices = (
elements: readonly ExcalidrawElement[],
): OrderedExcalidrawElement[] => {
export const syncInvalidIndices = <T extends ExcalidrawElement>(
elements: readonly T[],
): Ordered<T>[] => {
const elementsMap = arrayToMap(elements);
const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups);
@@ -211,7 +231,7 @@ export const syncInvalidIndices = (
mutateElement(element, elementsMap, { index });
}
return elements as OrderedExcalidrawElement[];
return elements as Ordered<T>[];
};
/**
@@ -382,6 +402,13 @@ const isValidFractionalIndex = (
return false;
}
try {
// Format validation
validateOrderKey(index);
} catch {
return false;
}
if (predecessor && successor) {
return predecessor < index && index < successor;
}
+114 -52
View File
@@ -1,6 +1,9 @@
import { arrayToMap } from "@excalidraw/common";
import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
import {
isPointWithinBounds,
pointFrom,
segmentsIntersectAt,
} from "@excalidraw/math";
import type {
AppClassProperties,
@@ -19,15 +22,19 @@ import {
getElementAbsoluteCoords,
doBoundsIntersect,
getElementBounds,
boundsContainBounds,
} from "./bounds";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { syncMovedIndices } from "./fractionalIndex";
import {
isFrameElement,
isFrameLikeElement,
isTextElement,
} from "./typeChecks";
import { getNonDeletedElements } from ".";
import type { ExcalidrawElementsIncludingDeleted } from "./Scene";
import type {
@@ -76,7 +83,7 @@ export function isElementIntersectingFrame(
const intersecting = frameLineSegments.some((frameLineSegment) =>
elementLineSegments.some((elementLineSegment) =>
doLineSegmentsIntersect(frameLineSegment, elementLineSegment),
segmentsIntersectAt(frameLineSegment, elementLineSegment),
),
);
@@ -101,8 +108,9 @@ export const isElementContainingFrame = (
frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => {
return getElementsWithinSelection([frame], element, elementsMap).some(
(e) => e.id === frame.id,
return boundsContainBounds(
getElementBounds(element, elementsMap),
getElementBounds(frame, elementsMap),
);
};
@@ -260,8 +268,8 @@ export const getFrameLikeElements = (
*
* Considers non-frame bound elements (container or arrow labels) as root.
*/
export const getRootElements = (
allElements: ExcalidrawElementsIncludingDeleted,
export const getRootElements = <T extends ExcalidrawElement>(
allElements: readonly T[],
) => {
const frameElements = arrayToMap(getFrameLikeElements(allElements));
return allElements.filter(
@@ -342,9 +350,12 @@ export const getElementsInResizingFrame = (
const newGroupElementsCompletelyInFrame = Array.from(
elementsCompletelyInFrame,
).filter((element) => element.groupIds.length > 0);
const nonDeletedNewGroupElementsCompletelyInFrame = getNonDeletedElements(
newGroupElementsCompletelyInFrame,
);
const groupIds = selectGroupsFromGivenElements(
newGroupElementsCompletelyInFrame,
nonDeletedNewGroupElementsCompletelyInFrame,
appState,
);
@@ -489,30 +500,56 @@ export const filterElementsEligibleAsFrameChildren = (
return eligibleElements;
};
export const getCommonFrameId = (elements: readonly ExcalidrawElement[]) => {
let commonFrameId: ExcalidrawElement["frameId"] | undefined;
for (const element of elements) {
if (isFrameLikeElement(element) || !element.frameId) {
return null;
}
if (commonFrameId === undefined) {
commonFrameId = element.frameId;
} else if (commonFrameId !== element.frameId) {
return null;
}
}
return commonFrameId ?? null;
};
export const getFrameChildrenInsertionIndex = (
elements: readonly ExcalidrawElement[],
frameId: ExcalidrawFrameLikeElement["id"],
): number | null => {
for (let index = elements.length - 1; index >= 0; index--) {
const element = elements[index];
if (element.id === frameId) {
return index;
} else if (element.frameId === frameId) {
return index + 1;
}
}
return null;
};
/**
* Retains (or repairs for target frame) the ordering invriant where children
* elements come right before the parent frame:
* [el, el, child, child, frame, el]
* Adds elements and their bound elements to frame. Reorders added elements to
* be just below frame, or just above its highest child (whichever is higher).
*
* @returns mutated allElements (same data structure)
*/
export const addElementsToFrame = <T extends ElementsMapOrArray>(
allElements: T,
elementsToAdd: NonDeletedExcalidrawElement[],
elementsToAdd: ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
appState: AppState,
): T => {
const elementsMap = arrayToMap(allElements);
const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
for (const element of allElements.values()) {
if (element.frameId === frame.id) {
currTargetFrameChildrenMap.set(element.id, true);
}
}
const commonFrameId = getCommonFrameId(elementsToAdd);
const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
const finalElementsToAdd: ExcalidrawElement[] = [];
const finalElementsToAdd = new Set<ExcalidrawElement>();
const otherFrames = new Set<ExcalidrawFrameLikeElement["id"]>();
@@ -523,7 +560,8 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
}
// - add bound text elements if not already in the array
// - filter out elements that are already in the frame
// - keep elements already in the frame so mixed selections can be reordered
// together
for (const element of omitGroupsContainingFrameLikes(
allElements,
elementsToAdd,
@@ -536,42 +574,68 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
continue;
}
// if the element is already in another frame (which is also in elementsToAdd),
// it means that frame and children are selected at the same time
// => keep original frame membership, do not add to the target frame
if (
element.frameId &&
appState.selectedElementIds[element.id] &&
appState.selectedElementIds[element.frameId]
) {
continue;
}
if (!currTargetFrameChildrenMap.has(element.id)) {
finalElementsToAdd.push(element);
}
finalElementsToAdd.add(element);
const boundTextElement = getBoundTextElement(element, elementsMap);
if (
boundTextElement &&
!suppliedElementsToAddSet.has(boundTextElement.id) &&
!currTargetFrameChildrenMap.has(boundTextElement.id)
) {
finalElementsToAdd.push(boundTextElement);
if (boundTextElement && !finalElementsToAdd.has(boundTextElement)) {
finalElementsToAdd.add(boundTextElement);
}
}
for (const element of finalElementsToAdd) {
mutateElement(element, elementsMap, {
frameId: frame.id,
});
// we don't always need to update the element if it's already in the frame,
// but we still need to accumulate in finalElementsToAdd so we potentially
// reorder them if added together
if (element.frameId !== frame.id) {
mutateElement(element, elementsMap, {
frameId: frame.id,
});
}
}
return allElements;
// (re)order elements to be just below the frame,
// or just above the highest child if that is higher
// (latter case is denormalized order until we migrate)
// ---------------------------------------------------------------------------
if (
!finalElementsToAdd.size ||
// if all elements to add already belong to the frame, then we don't want to
// reorder (case: we're dragging element children within the frame)
commonFrameId === frame.id
) {
return allElements;
}
const otherElements = Array.from(allElements.values()).filter(
(element) => !finalElementsToAdd.has(element),
);
const insertionIndex = getFrameChildrenInsertionIndex(
otherElements,
frame.id,
);
if (insertionIndex === null) {
return allElements;
}
const reorderedElements = [
...otherElements.slice(0, insertionIndex),
...finalElementsToAdd,
...otherElements.slice(insertionIndex),
];
syncMovedIndices(reorderedElements, arrayToMap([...finalElementsToAdd]));
return (
Array.isArray(allElements)
? reorderedElements
: new Map(reorderedElements.map((element) => [element.id, element]))
) as T;
};
export const removeElementsFromFrame = (
elementsToRemove: ReadonlySetLike<NonDeletedExcalidrawElement>,
elementsToRemove: ReadonlySetLike<ExcalidrawElement>,
elementsMap: ElementsMap,
) => {
const _elementsToRemove = new Map<
@@ -621,13 +685,11 @@ export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
allElements: readonly T[],
nextElementsInFrame: ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
app: AppClassProperties,
): T[] => {
return addElementsToFrame(
removeAllElementsFromFrame(allElements, frame),
nextElementsInFrame,
frame,
app.state,
).slice();
};
@@ -918,8 +980,8 @@ export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
return element.name === null ? getDefaultFrameName(element) : element.name;
};
export const getElementsOverlappingFrame = (
elements: readonly ExcalidrawElement[],
export const getElementsOverlappingFrame = <T extends ExcalidrawElement>(
elements: readonly T[],
frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => {
+66 -81
View File
@@ -15,15 +15,16 @@ import type {
GroupId,
ExcalidrawElement,
NonDeleted,
NonDeletedExcalidrawElement,
ElementsMapOrArray,
ElementsMap,
NonDeletedExcalidrawElement,
NonDeletedElementsMapOrArray,
} from "./types";
export const selectGroup = (
groupId: GroupId,
appState: InteractiveCanvasAppState,
elements: readonly NonDeleted<ExcalidrawElement>[],
elements: readonly NonDeletedExcalidrawElement[],
): Pick<
InteractiveCanvasAppState,
"selectedGroupIds" | "selectedElementIds" | "editingGroupId"
@@ -68,14 +69,14 @@ export const selectGroupsForSelectedElements = (function () {
"selectedGroupIds" | "editingGroupId" | "selectedElementIds"
>;
let lastSelectedElements: readonly NonDeleted<ExcalidrawElement>[] | null =
let lastSelectedElements: readonly NonDeletedExcalidrawElement[] | null =
null;
let lastElements: readonly NonDeleted<ExcalidrawElement>[] | null = null;
let lastElements: readonly NonDeletedExcalidrawElement[] | null = null;
let lastReturnValue: SelectGroupsReturnType | null = null;
const _selectGroups = (
selectedElements: readonly NonDeleted<ExcalidrawElement>[],
elements: readonly NonDeleted<ExcalidrawElement>[],
selectedElements: readonly NonDeletedExcalidrawElement[],
elements: readonly NonDeletedExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds" | "editingGroupId">,
prevAppState: InteractiveCanvasAppState,
): SelectGroupsReturnType => {
@@ -212,7 +213,10 @@ export const selectGroupsForSelectedElements = (function () {
* selection border around it.
*/
export const isSelectedViaGroup = (
appState: InteractiveCanvasAppState,
appState: Pick<
InteractiveCanvasAppState,
"editingGroupId" | "selectedGroupIds"
>,
element: ExcalidrawElement,
) => getSelectedGroupForElement(appState, element) != null;
@@ -228,7 +232,7 @@ export const getSelectedGroupForElement = (
.find((groupId) => appState.selectedGroupIds[groupId]);
export const getSelectedGroupIds = (
appState: InteractiveCanvasAppState,
appState: Pick<InteractiveCanvasAppState, "selectedGroupIds">,
): GroupId[] =>
Object.entries(appState.selectedGroupIds)
.filter(([groupId, isSelected]) => isSelected)
@@ -237,7 +241,7 @@ export const getSelectedGroupIds = (
// given a list of elements, return the the actual group ids that should be selected
// or used to update the elements
export const selectGroupsFromGivenElements = (
elements: readonly NonDeleted<ExcalidrawElement>[],
elements: readonly NonDeletedExcalidrawElement[],
appState: InteractiveCanvasAppState,
) => {
let nextAppState: InteractiveCanvasAppState = {
@@ -282,14 +286,18 @@ export const editGroupForSelectedElement = (
export const isElementInGroup = (element: ExcalidrawElement, groupId: string) =>
element.groupIds.includes(groupId);
export const getElementsInGroup = (
elements: ElementsMapOrArray,
export const getElementsInGroup = <
P extends NonDeletedExcalidrawElement | ExcalidrawElement,
>(
elements: P extends NonDeletedExcalidrawElement
? NonDeletedElementsMapOrArray
: ElementsMapOrArray,
groupId: string,
) => {
const elementsInGroup: ExcalidrawElement[] = [];
): P[] => {
const elementsInGroup: P[] = [];
for (const element of elements.values()) {
if (isElementInGroup(element, groupId)) {
elementsInGroup.push(element);
elementsInGroup.push(element as P);
}
}
return elementsInGroup;
@@ -321,15 +329,14 @@ export const removeFromSelectedGroups = (
selectedGroupIds: { [groupId: string]: boolean },
) => groupIds.filter((groupId) => !selectedGroupIds[groupId]);
export const getMaximumGroups = (
elements: ExcalidrawElement[],
export const getMaximumGroups = <
T extends NonDeletedExcalidrawElement | ExcalidrawElement,
>(
elements: T[],
elementsMap: ElementsMap,
): ExcalidrawElement[][] => {
const groups: Map<String, ExcalidrawElement[]> = new Map<
String,
ExcalidrawElement[]
>();
elements.forEach((element: ExcalidrawElement) => {
): T[][] => {
const groups: Map<String, T[]> = new Map<String, T[]>();
elements.forEach((element: T) => {
const groupId =
element.groupIds.length === 0
? element.id
@@ -340,7 +347,7 @@ export const getMaximumGroups = (
// Include bound text if present when grouping
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
currentGroupMembers.push(boundTextElement);
currentGroupMembers.push(boundTextElement as T);
}
groups.set(groupId, [...currentGroupMembers, element]);
});
@@ -383,7 +390,7 @@ export const elementsAreInSameGroup = (
return maxGroup === elements.length;
};
export const isInGroup = (element: NonDeletedExcalidrawElement) => {
export const isInGroup = (element: ExcalidrawElement) => {
return element.groupIds.length > 0;
};
@@ -408,74 +415,52 @@ export const getNewGroupIdsForDuplication = (
// given a list of selected elements, return the element grouped by their immediate group selected state
// in the case if only one group is selected and all elements selected are within the group, it will respect group hierarchy in accordance to their nested grouping order
export const getSelectedElementsByGroup = (
selectedElements: ExcalidrawElement[],
selectedElements: NonDeletedExcalidrawElement[],
elementsMap: ElementsMap,
appState: Readonly<AppState>,
): ExcalidrawElement[][] => {
appState: Readonly<Pick<AppState, "selectedGroupIds" | "editingGroupId">>,
): NonDeletedExcalidrawElement[][] => {
const buckets: Map<string, NonDeletedExcalidrawElement[]> = new Map();
const selectedGroupIds = getSelectedGroupIds(appState);
const unboundElements = selectedElements.filter(
(element) => !isBoundToContainer(element),
);
const groups: Map<string, ExcalidrawElement[]> = new Map();
const elements: Map<string, ExcalidrawElement[]> = new Map();
const isSingleSelectedGroupCase =
selectedGroupIds.length === 1 &&
selectedElements.every((element) => isSelectedViaGroup(appState, element));
// helper function to add an element to the elements map
const addToElementsMap = (element: ExcalidrawElement) => {
// elements
const currentElementMembers = elements.get(element.id) || [];
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
currentElementMembers.push(boundTextElement);
selectedElements.forEach((element) => {
// skip dependent boundTextElements, they are appended after their container
if (isBoundToContainer(element)) {
return;
}
elements.set(element.id, [...currentElementMembers, element]);
};
// helper function to add an element to the groups map
const addToGroupsMap = (element: ExcalidrawElement, groupId: string) => {
// groups
const currentGroupMembers = groups.get(groupId) || [];
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
currentGroupMembers.push(boundTextElement);
}
groups.set(groupId, [...currentGroupMembers, element]);
};
// helper function to handle the case where a single group is selected
// and all elements selected are within the group, it will respect group hierarchy in accordance to
// their nested grouping order
const handleSingleSelectedGroupCase = (
element: ExcalidrawElement,
selectedGroupId: GroupId,
) => {
const indexOfSelectedGroupId = element.groupIds.indexOf(selectedGroupId, 0);
const nestedGroupCount = element.groupIds.slice(
0,
indexOfSelectedGroupId,
).length;
return nestedGroupCount > 0
? addToGroupsMap(element, element.groupIds[indexOfSelectedGroupId - 1])
: addToElementsMap(element);
};
const isAllInSameGroup = selectedElements.every((element) =>
isSelectedViaGroup(appState, element),
);
unboundElements.forEach((element) => {
let bucketKey: string;
const selectedGroupId = getSelectedGroupIdForElement(
element,
appState.selectedGroupIds,
);
if (!selectedGroupId) {
addToElementsMap(element);
} else if (selectedGroupIds.length === 1 && isAllInSameGroup) {
handleSingleSelectedGroupCase(element, selectedGroupId);
bucketKey = `${element.id}_element`;
} else {
addToGroupsMap(element, selectedGroupId);
// if only one group is selected, grouping is based on inner hierarchy
const keyIndex = isSingleSelectedGroupCase
? element.groupIds.indexOf(selectedGroupId) - 1
: element.groupIds.indexOf(selectedGroupId);
// edge case: single selected group where element is non member of inner group
bucketKey =
keyIndex < 0
? `${element.id}_element`
: `${element.groupIds[keyIndex]}_group`;
}
const currentBucketMembers = buckets.get(bucketKey) ?? [];
const boundTextElement = getBoundTextElement(element, elementsMap);
// preserve boundtext after container ordering
buckets.set(bucketKey, [
...currentBucketMembers,
element,
...(boundTextElement ? [boundTextElement] : []),
]);
});
return Array.from(groups.values()).concat(Array.from(elements.values()));
return [...buckets.values()];
};
+16 -10
View File
@@ -40,23 +40,27 @@ export const hashString = (s: string): number => {
return hash >>> 0; // Ensure unsigned 32-bit integer
};
export const getVisibleElements = (elements: readonly ExcalidrawElement[]) =>
elements.filter(
(el) => !el.isDeleted && !isInvisiblySmallElement(el),
) as readonly NonDeletedExcalidrawElement[];
export const getNonDeletedElements = <T extends ExcalidrawElement>(
elements: readonly T[],
) =>
elements.filter((element) => !element.isDeleted) as readonly NonDeleted<T>[];
export const isNonDeletedElement = <T extends ExcalidrawElement>(
element: T,
): element is NonDeleted<T> => !element.isDeleted;
export const getVisibleElements = (
elements: readonly ExcalidrawElement[],
): readonly NonDeletedExcalidrawElement[] =>
elements.filter(
(el): el is NonDeletedExcalidrawElement =>
isNonDeletedElement(el) && !isInvisiblySmallElement(el),
);
export const getNonDeletedElements = <T extends ExcalidrawElement>(
elements: readonly T[],
): readonly NonDeleted<T>[] => elements.filter(isNonDeletedElement);
export * from "./align";
export * from "./arrowEndpointText";
export * from "./binding";
export * from "./bounds";
export * from "./bucketFill";
export * from "./collision";
export * from "./comparisons";
export * from "./containerCache";
@@ -90,6 +94,7 @@ export * from "./showSelectedShapeActions";
export * from "./sizeHelpers";
export * from "./sortElements";
export * from "./store";
export * from "./stickyNote";
export * from "./textElement";
export * from "./textMeasurements";
export * from "./textWrapping";
@@ -100,3 +105,4 @@ export * from "./utils";
export * from "./zindex";
export * from "./arrows/helpers";
export * from "./arrowheads";
export * from "./convertToShape";
+440 -135
View File
@@ -8,7 +8,14 @@ import {
pointDistance,
vectorFromPoint,
curveLength,
curveLengthAtParameter,
curvePointAtLength,
lineSegmentClosestParameter,
lineSegmentPointAt,
curveClosestParameter,
clamp,
bezierEquation,
isCurve,
} from "@excalidraw/math";
import { getCurvePathOps } from "@excalidraw/utils/shape";
@@ -20,7 +27,6 @@ import {
getGridPoint,
invariant,
isShallowEqual,
getFeatureFlag,
} from "@excalidraw/common";
import {
@@ -30,8 +36,11 @@ import {
moveArrowAboveBindable,
projectFixedPointOntoDiagonal,
type Store,
getLinearElementPathSegments,
} from "@excalidraw/element";
import type { LinearPathSegment } from "@excalidraw/element";
import type { Radians } from "@excalidraw/math";
import type {
@@ -48,7 +57,6 @@ import {
calculateFixedPointForNonElbowArrowBinding,
getBindingStrategyForDraggingBindingElementEndpoints,
isBindingEnabled,
snapToMid,
updateBoundPoint,
} from "./binding";
import {
@@ -122,6 +130,15 @@ type PointMoveOtherUpdates = {
suggestedBinding?: AppState["suggestedBinding"] | null;
};
const BoundTextPositionCache = new WeakMap<
ExcalidrawTextElementWithContainer,
{
containerVersion: ExcalidrawElement["version"];
textVersion: ExcalidrawElement["version"];
position: { x: number; y: number };
}
>();
export class LinearElementEditor {
public readonly elementId: ExcalidrawElement["id"] & {
_brand: "excalidrawLinearElementId";
@@ -141,6 +158,7 @@ export class LinearElementEditor {
};
arrowStartIsInside: boolean;
altFocusPoint: Readonly<GlobalPoint> | null;
arrowOtherEndpointInitialBinding: FixedPointBinding | null;
}>;
/** whether you're dragging a point */
@@ -193,6 +211,7 @@ export class LinearElementEditor {
added: false,
},
arrowStartIsInside: false,
arrowOtherEndpointInitialBinding: null,
altFocusPoint: null,
};
this.hoverPointIndex = -1;
@@ -217,7 +236,7 @@ export class LinearElementEditor {
static getElement<T extends ExcalidrawLinearElement>(
id: InstanceType<typeof LinearElementEditor>["elementId"],
elementsMap: ElementsMap,
): T | null {
): NonDeleted<T> | null {
const element = elementsMap.get(id);
if (element) {
return element as NonDeleted<T>;
@@ -344,7 +363,7 @@ export class LinearElementEditor {
// Apply the point movement if needed
let suggestedBinding: AppState["suggestedBinding"] = null;
const { positions, updates } = pointDraggingUpdates(
const { positions, updates, hit } = pointDraggingUpdates(
[idx],
deltaX,
deltaY,
@@ -359,6 +378,7 @@ export class LinearElementEditor {
linearElementEditor,
);
const angleLocked = shouldRotateWithDiscreteAngle(event);
LinearElementEditor.movePoints(
element,
app.scene,
@@ -370,7 +390,10 @@ export class LinearElementEditor {
},
{
isBindingEnabled: app.state.isBindingEnabled,
isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled,
isMidpointSnappingEnabled:
app.state.isMidpointSnappingEnabled &&
!angleLocked &&
!app.state.gridModeEnabled,
},
);
// Set the suggested binding from the updates if available
@@ -382,17 +405,20 @@ export class LinearElementEditor {
// Move the arrow over the bindable object in terms of z-index
if (isBindingElement(element)) {
moveArrowAboveBindable(
LinearElementEditor.getPointGlobalCoordinates(
if (hit) {
moveArrowAboveBindable(
LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[element.points.length - 1],
elementsMap,
),
element,
element.points[element.points.length - 1],
elements,
elementsMap,
),
element,
elements,
elementsMap,
app.scene,
);
app.scene,
hit,
);
}
}
// PERF: Avoid state updates if not absolutely necessary
@@ -427,7 +453,9 @@ export class LinearElementEditor {
"start",
elementsMap,
app.state.zoom,
app.state.isMidpointSnappingEnabled,
app.state.isMidpointSnappingEnabled &&
!angleLocked &&
!app.state.gridModeEnabled,
)
: linearElementEditor.initialState.altFocusPoint,
},
@@ -486,7 +514,7 @@ export class LinearElementEditor {
selectedPointsIndices,
)}) points(0..${
element.points.length - 1
}) lastClickedPoint(${lastClickedPoint})`,
}) lastClickedPoint(${lastClickedPoint}) isElbowArrow: ${elbowed}`,
);
// Fall back to the actual last point as a last resort.
@@ -539,7 +567,7 @@ export class LinearElementEditor {
// Apply the point movement if needed
let suggestedBinding: AppState["suggestedBinding"] = null;
const { positions, updates } = pointDraggingUpdates(
const { positions, updates, hit } = pointDraggingUpdates(
selectedPointsIndices,
deltaX,
deltaY,
@@ -554,6 +582,8 @@ export class LinearElementEditor {
linearElementEditor,
);
const angleLocked =
shouldRotateWithDiscreteAngle(event) && singlePointDragged;
LinearElementEditor.movePoints(
element,
app.scene,
@@ -565,7 +595,10 @@ export class LinearElementEditor {
},
{
isBindingEnabled: app.state.isBindingEnabled,
isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled,
isMidpointSnappingEnabled:
app.state.isMidpointSnappingEnabled &&
!angleLocked &&
!app.state.gridModeEnabled,
},
);
@@ -578,19 +611,22 @@ export class LinearElementEditor {
// Move the arrow over the bindable object in terms of z-index
if (isBindingElement(element) && startIsSelected !== endIsSelected) {
moveArrowAboveBindable(
LinearElementEditor.getPointGlobalCoordinates(
if (hit) {
moveArrowAboveBindable(
LinearElementEditor.getPointGlobalCoordinates(
element,
startIsSelected
? element.points[0]
: element.points[element.points.length - 1],
elementsMap,
),
element,
startIsSelected
? element.points[0]
: element.points[element.points.length - 1],
elements,
elementsMap,
),
element,
elements,
elementsMap,
app.scene,
);
app.scene,
hit,
);
}
}
// Attached text might need to update if arrow dimensions change
@@ -661,7 +697,9 @@ export class LinearElementEditor {
"start",
elementsMap,
app.state.zoom,
app.state.isMidpointSnappingEnabled,
app.state.isMidpointSnappingEnabled &&
!angleLocked &&
!app.state.gridModeEnabled,
)
: linearElementEditor.initialState.altFocusPoint,
},
@@ -764,12 +802,13 @@ export class LinearElementEditor {
...editingLinearElement.initialState,
origin: null,
arrowStartIsInside: false,
arrowOtherEndpointInitialBinding: null,
},
};
}
static getEditorMidPoints = (
element: NonDeleted<ExcalidrawLinearElement>,
element: ExcalidrawLinearElement,
elementsMap: ElementsMap,
appState: InteractiveCanvasAppState,
): (GlobalPoint | null)[] => {
@@ -893,7 +932,7 @@ export class LinearElementEditor {
};
static isSegmentTooShort<P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>,
element: ExcalidrawLinearElement,
startPoint: P,
endPoint: P,
index: number,
@@ -934,7 +973,7 @@ export class LinearElementEditor {
}
static getSegmentMidPoint(
element: NonDeleted<ExcalidrawLinearElement>,
element: ExcalidrawLinearElement,
index: number,
elementsMap: ElementsMap,
): GlobalPoint {
@@ -1015,6 +1054,8 @@ export class LinearElementEditor {
): {
didAddPoint: boolean;
hitElement: NonDeleted<ExcalidrawElement> | null;
/** the pointer went down on the arrow's label, grabbing it for a drag */
hitBoundText: boolean;
linearElementEditor: LinearElementEditor | null;
} {
const appState = app.state;
@@ -1023,6 +1064,7 @@ export class LinearElementEditor {
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
didAddPoint: false,
hitElement: null,
hitBoundText: false,
linearElementEditor: null,
};
@@ -1081,10 +1123,9 @@ export class LinearElementEditor {
index: segmentMidpointIndex,
added: false,
},
arrowStartIsInside:
!!app.state.newElement &&
(app.state.bindMode === "inside" || app.state.bindMode === "skip"),
arrowStartIsInside: false,
altFocusPoint: null,
arrowOtherEndpointInitialBinding: element.startBinding,
},
selectedPointsIndices: [element.points.length - 1],
lastUncommittedPoint: null,
@@ -1095,16 +1136,51 @@ export class LinearElementEditor {
return ret;
}
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
const pointIndexUnderCursor = LinearElementEditor.getPointIndexUnderCursor(
element,
elementsMap,
appState.zoom,
scenePointer.x,
scenePointer.y,
);
// if we clicked on a point, set the element as hitElement otherwise
// it would get deselected if the point is outside the hitbox area
if (clickedPointIndex >= 0 || segmentMidpoint) {
const clickedPointIsHandle = LinearElementEditor.isPointHandle(
element,
pointIndexUnderCursor,
);
const boundTextElement = getBoundTextElement(element, elementsMap);
let boundTextGrabOffset: { x: number; y: number } | null = null;
if (
!clickedPointIsHandle &&
// the segment midpoint knob keeps precedence over the label sitting
// on top of it, so a labeled arrow can still be bent at its middle
!segmentMidpoint &&
boundTextElement &&
isArrowElement(element) &&
app.arrowText.isBoundTextGrabbable(
element,
scenePointer.x,
scenePointer.y,
)
) {
const { x: textX, y: textY } =
LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
elementsMap,
);
boundTextGrabOffset = {
x: scenePointer.x - (textX + boundTextElement.width / 2),
y: scenePointer.y - (textY + boundTextElement.height / 2),
};
}
// when the label grab wins (possible over a non-interactive elbow route
// point), the click must not double as a point click
const clickedPointIndex = boundTextGrabOffset ? -1 : pointIndexUnderCursor;
ret.hitBoundText = !!boundTextGrabOffset;
if (clickedPointIndex >= 0 || segmentMidpoint || boundTextGrabOffset) {
ret.hitElement = element;
}
@@ -1143,10 +1219,11 @@ export class LinearElementEditor {
index: segmentMidpointIndex,
added: false,
},
arrowStartIsInside:
!!app.state.newElement &&
(app.state.bindMode === "inside" || app.state.bindMode === "skip"),
arrowStartIsInside: false,
altFocusPoint: null,
arrowOtherEndpointInitialBinding: nextSelectedPointsIndices?.includes(0)
? element.endBinding
: element.startBinding,
},
selectedPointsIndices: nextSelectedPointsIndices,
pointerOffset: targetPoint
@@ -1154,7 +1231,7 @@ export class LinearElementEditor {
x: scenePointer.x - targetPoint[0],
y: scenePointer.y - targetPoint[1],
}
: { x: 0, y: 0 },
: boundTextGrabOffset ?? { x: 0, y: 0 },
};
return ret;
@@ -1254,7 +1331,7 @@ export class LinearElementEditor {
/** scene coords */
static getPointGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>,
element: ExcalidrawLinearElement,
p: LocalPoint,
elementsMap: ElementsMap,
): GlobalPoint {
@@ -1272,7 +1349,7 @@ export class LinearElementEditor {
/** scene coords */
static getPointsGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>,
element: ExcalidrawLinearElement,
elementsMap: ElementsMap,
): GlobalPoint[] {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
@@ -1289,7 +1366,7 @@ export class LinearElementEditor {
}
static getPointAtIndexGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>,
element: ExcalidrawLinearElement,
indexMaybeFromEnd: number, // -1 for last element
elementsMap: ElementsMap,
): GlobalPoint {
@@ -1312,7 +1389,7 @@ export class LinearElementEditor {
}
static pointFromAbsoluteCoords(
element: NonDeleted<ExcalidrawLinearElement>,
element: ExcalidrawLinearElement,
absoluteCoords: GlobalPoint,
elementsMap: ElementsMap,
): LocalPoint {
@@ -1335,8 +1412,21 @@ export class LinearElementEditor {
return pointFrom(x - element.x, y - element.y);
}
/**
* Whether the point at `index` is an interactive handle. Elbow arrows only
* expose their endpoints; their intermediate route points are not draggable.
*/
static isPointHandle(element: ExcalidrawLinearElement, index: number) {
return (
index >= 0 &&
(!isElbowArrow(element) ||
index === 0 ||
index === element.points.length - 1)
);
}
static getPointIndexUnderCursor(
element: NonDeleted<ExcalidrawLinearElement>,
element: ExcalidrawLinearElement,
elementsMap: ElementsMap,
zoom: AppState["zoom"],
x: number,
@@ -1364,7 +1454,7 @@ export class LinearElementEditor {
}
static createPointAt(
element: NonDeleted<ExcalidrawLinearElement>,
element: ExcalidrawLinearElement,
elementsMap: ElementsMap,
scenePointerX: number,
scenePointerY: number,
@@ -1798,7 +1888,7 @@ export class LinearElementEditor {
}
private static _getShiftLockedDelta(
element: NonDeleted<ExcalidrawLinearElement>,
element: ExcalidrawLinearElement,
elementsMap: ElementsMap,
referencePoint: LocalPoint,
scenePointer: GlobalPoint,
@@ -1839,41 +1929,200 @@ export class LinearElementEditor {
);
}
/**
* The point a bound text label is centered on — the middle of the linear
* element, whether that's an actual point or the midpoint of the middle
* segment.
*/
static getBoundTextElementCenter = (
element: ExcalidrawLinearElement,
elementsMap: ElementsMap,
): GlobalPoint => {
if (element.points.length % 2 === 1) {
const index = Math.floor(element.points.length / 2);
return LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[index],
elementsMap,
);
}
const index = element.points.length / 2 - 1;
return LinearElementEditor.getSegmentMidPoint(
element,
index + 1,
elementsMap,
);
};
static handleBoundTextDragging(
linearElementEditor: LinearElementEditor,
scene: Scene,
pointerX: number,
pointerY: number,
): LinearElementEditor | null {
const elementsMap = scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(
linearElementEditor.elementId,
elementsMap,
);
const boundTextElement = getBoundTextElement(element, elementsMap);
if (!element || !isArrowElement(element) || !boundTextElement) {
return null;
}
const pointerGlobalPoint = pointFrom<GlobalPoint>(
pointerX - linearElementEditor.pointerOffset.x,
pointerY - linearElementEditor.pointerOffset.y,
);
const { segments, lengths, prefixSums, totalLength } =
getLinearElementPathMetrics(element, elementsMap);
if (segments.length === 0 || totalLength === 0) {
return null;
}
let bestDistance = Infinity;
let bestSegmentIndex = 0;
let bestParameter = 0;
for (let i = 0; i < segments.length; i++) {
const { t, distance } = pathSegmentClosestParameter(
segments[i],
pointerGlobalPoint,
);
if (distance < bestDistance) {
bestDistance = distance;
bestSegmentIndex = i;
bestParameter = t;
}
}
const lengthWithinSegment = pathSegmentLengthAtParameter(
segments[bestSegmentIndex],
bestParameter,
lengths[bestSegmentIndex],
);
const labelPosition = clamp(
(prefixSums[bestSegmentIndex] + lengthWithinSegment) / totalLength,
0,
1,
);
const pathPoint = LinearElementEditor.getPointAtPathParameter(
element,
labelPosition,
elementsMap,
);
if (!pathPoint) {
return null;
}
scene.mutateElement(boundTextElement, {
labelPosition,
x: pathPoint[0] - boundTextElement.width / 2,
y: pathPoint[1] - boundTextElement.height / 2,
});
return {
...linearElementEditor,
isDragging: true,
};
}
static getPointAtPathParameter(
container: ExcalidrawLinearElement,
pathParameter: number,
elementsMap: ElementsMap,
): GlobalPoint | null {
if (!Number.isFinite(pathParameter)) {
return null;
}
const { segments, lengths, prefixSums, totalLength } =
getLinearElementPathMetrics(container, elementsMap);
if (segments.length === 0) {
return null;
}
const targetLength = clamp(pathParameter, 0, 1) * totalLength;
// the first segment whose end lies at or beyond the target; the last one
// catches a target that floating point pushed past the total length
const found = segments.findIndex(
(_, i) => targetLength <= prefixSums[i + 1],
);
const i = found === -1 ? segments.length - 1 : found;
const lengthFraction =
lengths[i] === 0
? 0
: clamp((targetLength - prefixSums[i]) / lengths[i], 0, 1);
return pathSegmentPointAtLength(segments[i], lengthFraction, lengths[i]);
}
static getBoundTextElementPosition = (
element: ExcalidrawLinearElement,
boundTextElement: ExcalidrawTextElementWithContainer,
elementsMap: ElementsMap,
): { x: number; y: number } => {
const points = LinearElementEditor.getPointsGlobalCoordinates(
// derived on every hit test, bounds query and render of the label, so
// memoize on the pair of versions the result depends on
const cached = BoundTextPositionCache.get(boundTextElement);
if (
cached &&
cached.containerVersion === element.version &&
cached.textVersion === boundTextElement.version
) {
return cached.position;
}
const position = LinearElementEditor.computeBoundTextElementPosition(
element,
boundTextElement,
elementsMap,
);
if (points.length < 2) {
mutateElement(boundTextElement, elementsMap, { isDeleted: true });
BoundTextPositionCache.set(boundTextElement, {
containerVersion: element.version,
textVersion: boundTextElement.version,
position,
});
return position;
};
private static computeBoundTextElementPosition = (
element: ExcalidrawLinearElement,
boundTextElement: ExcalidrawTextElementWithContainer,
elementsMap: ElementsMap,
): { x: number; y: number } => {
if (element.points.length < 2) {
return { x: boundTextElement.x, y: boundTextElement.y };
}
let x = 0;
let y = 0;
if (element.points.length % 2 === 1) {
const index = Math.floor(element.points.length / 2);
const midPoint = LinearElementEditor.getPointGlobalCoordinates(
if (isArrowElement(element) && boundTextElement.labelPosition != null) {
const pathPoint = LinearElementEditor.getPointAtPathParameter(
element,
element.points[index],
elementsMap,
);
x = midPoint[0] - boundTextElement.width / 2;
y = midPoint[1] - boundTextElement.height / 2;
} else {
const index = element.points.length / 2 - 1;
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element,
index + 1,
boundTextElement.labelPosition,
elementsMap,
);
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
y = midSegmentMidpoint[1] - boundTextElement.height / 2;
if (pathPoint) {
return {
x: pathPoint[0] - boundTextElement.width / 2,
y: pathPoint[1] - boundTextElement.height / 2,
};
}
}
return { x, y };
const center = LinearElementEditor.getBoundTextElementCenter(
element,
elementsMap,
);
return {
x: center[0] - boundTextElement.width / 2,
y: center[1] - boundTextElement.height / 2,
};
};
static getMinMaxXYWithBoundText = (
@@ -2136,16 +2385,17 @@ const pointDraggingUpdates = (
): {
positions: PointsPositionUpdates;
updates?: PointMoveOtherUpdates;
hit?: NonDeleted<ExcalidrawBindableElement> | null;
} => {
const naiveDraggingPoints = new Map(
selectedPointsIndices.map((pointIndex) => {
// NOTE: Avoid stale point index issue potentially caused by elbow
// arrows unpredictably changing the number of points during dragging
const point = element.points[pointIndex] ?? element.points.at(-1);
return [
pointIndex,
{
point: pointFrom<LocalPoint>(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
),
point: pointFrom<LocalPoint>(point[0] + deltaX, point[1] + deltaY),
isDragging: true,
},
];
@@ -2176,6 +2426,7 @@ const pointDraggingUpdates = (
newArrow: !!app.state.newElement,
angleLocked,
altKey,
gridSize: app.getEffectiveGridSize(),
},
);
@@ -2193,18 +2444,21 @@ const pointDraggingUpdates = (
? {
element: suggestedBindingElement,
midPoint: app.state.isMidpointSnappingEnabled
? snapToMid(
suggestedBindingElement,
elementsMap,
? getSnapOutlineMidPoint(
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
suggestedBindingElement,
elementsMap,
app.state.zoom,
element,
)
: undefined,
}
: null,
},
hit: suggestedBindingElement,
};
}
@@ -2290,11 +2544,7 @@ const pointDraggingUpdates = (
),
};
if (
startIsDragged &&
(updates.startBinding.mode === "orbit" ||
!getFeatureFlag("COMPLEX_BINDINGS"))
) {
if (startIsDragged) {
updates.suggestedBinding = start.element
? {
element: start.element,
@@ -2306,6 +2556,7 @@ const pointDraggingUpdates = (
start.element,
elementsMap,
app.state.zoom,
element,
),
}
: null;
@@ -2329,11 +2580,7 @@ const pointDraggingUpdates = (
),
};
if (
endIsDragged &&
(updates.endBinding.mode === "orbit" ||
!getFeatureFlag("COMPLEX_BINDINGS"))
) {
if (endIsDragged) {
updates.suggestedBinding = end.element
? {
element: end.element,
@@ -2345,6 +2592,7 @@ const pointDraggingUpdates = (
end.element,
elementsMap,
app.state.zoom,
element,
),
}
: null;
@@ -2387,19 +2635,6 @@ const pointDraggingUpdates = (
: updates.endBinding,
};
// 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
@@ -2409,13 +2644,7 @@ const pointDraggingUpdates = (
)! as ExcalidrawBindableElement)
: null;
const endLocalPoint = startIsDraggingOverEndElement
? nextArrow.points[nextArrow.points.length - 1]
: endIsDraggingOverStartElement &&
app.state.bindMode !== "inside" &&
getFeatureFlag("COMPLEX_BINDINGS")
? nextArrow.points[0]
: endBindable
const endLocalPoint = endBindable
? updateBoundPoint(
nextArrow,
"endBinding",
@@ -2439,32 +2668,17 @@ const pointDraggingUpdates = (
)! as ExcalidrawBindableElement)
: null;
const startLocalPoint =
endIsDraggingOverStartElement && getFeatureFlag("COMPLEX_BINDINGS")
? nextArrow.points[0]
: startIsDraggingOverEndElement &&
app.state.bindMode !== "inside" &&
getFeatureFlag("COMPLEX_BINDINGS")
? endLocalPoint
: startBindable
? updateBoundPoint(
nextArrow,
"startBinding",
nextArrow.startBinding,
startBindable,
elementsMap,
startIsDragged,
) || nextArrow.points[0]
: nextArrow.points[0];
const startLocalPoint = startBindable
? updateBoundPoint(
nextArrow,
"startBinding",
nextArrow.startBinding,
startBindable,
elementsMap,
startIsDragged,
) || nextArrow.points[0]
: nextArrow.points[0];
const endChanged =
!startIsDraggingOverEndElement &&
!(
endIsDraggingOverStartElement &&
app.state.bindMode !== "inside" &&
getFeatureFlag("COMPLEX_BINDINGS")
) &&
!!endBindable;
const startChanged =
pointDistance(startLocalPoint, nextArrow.points[0]) !== 0;
@@ -2472,7 +2686,7 @@ const pointDraggingUpdates = (
if (startBindable && startChanged) {
indicesSet.add(0);
}
if (endBindable && endChanged) {
if (endBindable) {
indicesSet.add(element.points.length - 1);
}
const indices = Array.from(indicesSet);
@@ -2497,6 +2711,7 @@ const pointDraggingUpdates = (
];
}),
),
hit: startIsDragged ? start.element : end.element,
};
};
@@ -2505,3 +2720,93 @@ const determineCustomLinearAngle = (
draggedPoint: LocalPoint,
) =>
Math.atan2(draggedPoint[1] - pivotPoint[1], draggedPoint[0] - pivotPoint[0]);
type LinearElementPathMetrics = {
segments: LinearPathSegment[];
lengths: number[];
prefixSums: number[];
totalLength: number;
};
const LinearElementPathMetricsCache = new WeakMap<
ExcalidrawElement,
{ version: ExcalidrawElement["version"]; metrics: LinearElementPathMetrics }
>();
function getLinearElementPathMetrics(
element: ExcalidrawLinearElement,
elementsMap: ElementsMap,
): LinearElementPathMetrics {
const cached = LinearElementPathMetricsCache.get(element);
if (cached) {
if (cached.version === element.version) {
return cached.metrics;
}
LinearElementPathMetricsCache.delete(element);
}
const segments = getLinearElementPathSegments(element, elementsMap);
const lengths = segments.map(pathSegmentLength);
const prefixSums = new Array<number>(lengths.length + 1);
prefixSums[0] = 0;
for (let i = 0; i < lengths.length; i++) {
prefixSums[i + 1] = prefixSums[i] + lengths[i];
}
const metrics: LinearElementPathMetrics = {
segments,
lengths,
prefixSums,
totalLength: prefixSums[lengths.length],
};
LinearElementPathMetricsCache.set(element, {
version: element.version,
metrics,
});
return metrics;
}
const pathSegmentLength = (segment: LinearPathSegment): number =>
isCurve(segment)
? curveLength(segment)
: pointDistance(segment[0], segment[1]);
/**
* The segment's parameter `t` (`0..1`) at the point closest to `point`, and
* how far that point is from `point`.
*/
const pathSegmentClosestParameter = (
segment: LinearPathSegment,
point: GlobalPoint,
): { t: number; distance: number } => {
if (isCurve(segment)) {
const t = curveClosestParameter(segment, point);
return { t, distance: pointDistance(point, bezierEquation(segment, t)) };
}
const t = lineSegmentClosestParameter(point, segment);
return { t, distance: pointDistance(point, lineSegmentPointAt(segment, t)) };
};
/** arc length from the segment's start to its parameter `t` */
const pathSegmentLengthAtParameter = (
segment: LinearPathSegment,
t: number,
segmentLength: number,
): number =>
isCurve(segment) ? curveLengthAtParameter(segment, t) : t * segmentLength;
/** point at `fraction` (`0..1`) of the segment's arc length */
const pathSegmentPointAtLength = (
segment: LinearPathSegment,
fraction: number,
segmentLength: number,
): GlobalPoint =>
isCurve(segment)
? curvePointAtLength(segment, fraction, segmentLength)
: lineSegmentPointAt(segment, fraction);
+5 -2
View File
@@ -21,9 +21,12 @@ import type {
NonDeletedSceneElementsMap,
} from "./types";
// `created` is lifetime metadata: it is set once when an element instance is
// constructed (or duplicated) and never updated, so it is not part of an
// element update. An explicit repair must construct a new element object.
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>,
"id" | "updated"
"id" | "updated" | "created"
>;
/**
@@ -61,7 +64,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
angle: 0 as Radians,
...updateElbowArrowPoints(
{
...element,
...(element as ExcalidrawElbowArrowElement),
x: updates.x || element.x,
y: updates.y || element.y,
},
+159 -13
View File
@@ -3,7 +3,10 @@ import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
DEFAULT_STICKY_NOTE_SIZE,
STICKY_NOTE_MIN_SIZE,
DEFAULT_VERTICAL_ALIGN,
DEFAULT_STROKE_STREAMLINE,
VERTICAL_ALIGN,
randomInteger,
randomId,
@@ -21,6 +24,10 @@ import {
getResizedElementAbsoluteCoords,
} from "./bounds";
import { newElementWith } from "./mutateElement";
import {
normalizeStickyNoteBackgroundColor,
normalizeStickyNoteStrokeColor,
} from "./stickyNote";
import { getBoundTextMaxWidth } from "./textElement";
import { normalizeText, measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping";
@@ -48,6 +55,7 @@ import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawLineElement,
ExcalidrawStickyNoteElement,
} from "./types";
export type ElementConstructorOpts = MarkOptional<
@@ -73,6 +81,7 @@ export type ElementConstructorOpts = MarkOptional<
| "locked"
| "opacity"
| "customData"
| "created"
>;
const _newElementBase = <T extends ExcalidrawElement>(
@@ -122,8 +131,13 @@ const _newElementBase = <T extends ExcalidrawElement>(
});
}
const timestamp = getUpdatedTimestamp();
// assign type to guard against excess properties
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
const element: Merge<
ExcalidrawGenericElement,
{ type: T["type"]; isDeleted: false }
> = {
id: rest.id || randomId(),
type,
x,
@@ -147,7 +161,9 @@ const _newElementBase = <T extends ExcalidrawElement>(
versionNonce: rest.versionNonce ?? 0,
isDeleted: false as false,
boundElements,
updated: getUpdatedTimestamp(),
updated: timestamp,
// Preserve explicit null when reconstructing a legacy element with its id.
created: rest.created === undefined ? timestamp : rest.created,
link,
locked,
customData: rest.customData,
@@ -162,6 +178,70 @@ export const newElement = (
): NonDeleted<ExcalidrawGenericElement> =>
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
/**
* Style invariants of a sticky note: never-transparent colors, solid fill.
* Applied by the constructor and by every normalization pass. Returns the
* same object when nothing needs fixing.
*/
export const normalizeStickyNoteStyle = <T extends ExcalidrawStickyNoteElement>(
element: T,
): T => {
return newElementWith(element as ExcalidrawStickyNoteElement, {
backgroundColor: normalizeStickyNoteBackgroundColor(
element.backgroundColor,
),
strokeColor: normalizeStickyNoteStrokeColor(element.strokeColor),
fillStyle: "solid",
}) as T;
};
/**
* Geometry invariants of a *finalized* sticky note: minimum size and
* `baseHeight ≤ height`. Deliberately not part of the constructor — a
* pointer-down draft starts at 0×0 like every other tool and is previewed at
* its true dragged size; pointer-up, restore, the skeleton path and the
* action post-passes enforce this.
*/
export const normalizeStickyNoteGeometry = <
T extends ExcalidrawStickyNoteElement,
>(
element: T,
): T => {
const width = Math.max(element.width, STICKY_NOTE_MIN_SIZE);
const baseHeight = Math.max(
element.baseHeight || element.height || DEFAULT_STICKY_NOTE_SIZE,
STICKY_NOTE_MIN_SIZE,
);
return newElementWith(element as ExcalidrawStickyNoteElement, {
width,
height: Math.max(element.height, baseHeight),
baseHeight,
}) as T;
};
/** all sticky note invariants (style + finalized geometry) */
export const normalizeStickyNote = <T extends ExcalidrawStickyNoteElement>(
element: T,
): T => {
return normalizeStickyNoteGeometry(normalizeStickyNoteStyle(element));
};
export const newStickyNoteElement = (
opts: {
type: "stickynote";
baseHeight?: number;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawStickyNoteElement> => {
const base = _newElementBase<ExcalidrawStickyNoteElement>("stickynote", opts);
// no size inflation here (see `normalizeStickyNoteGeometry`)
return normalizeStickyNoteStyle({
...base,
baseHeight: opts.baseHeight ?? base.height,
});
};
export const newEmbeddableElement = (
opts: {
type: "embeddable";
@@ -214,6 +294,25 @@ export const newMagicFrameElement = (
return frameElement;
};
/**
* The point of the text box its alignment pins, as ratios of width/height.
*
* This is the point that stays put as the text grows — see the sides passed to
* `adjustXYWithRotation` in `getAdjustedDimensions`.
*/
export const getTextAnchorRatios = (opts: {
textAlign: ExcalidrawTextElement["textAlign"];
verticalAlign: ExcalidrawTextElement["verticalAlign"];
}) => ({
x: opts.textAlign === "center" ? 0.5 : opts.textAlign === "right" ? 1 : 0,
y:
opts.verticalAlign === VERTICAL_ALIGN.MIDDLE
? 0.5
: opts.verticalAlign === VERTICAL_ALIGN.BOTTOM
? 1
: 0,
});
/** computes element x/y offset based on textAlign/verticalAlign */
const getTextElementPositionOffsets = (
opts: {
@@ -225,14 +324,11 @@ const getTextElementPositionOffsets = (
height: number;
},
) => {
const ratios = getTextAnchorRatios(opts);
return {
x:
opts.textAlign === "center"
? metrics.width / 2
: opts.textAlign === "right"
? metrics.width
: 0,
y: opts.verticalAlign === "middle" ? metrics.height / 2 : 0,
x: metrics.width * ratios.x,
y: metrics.height * ratios.y,
};
};
@@ -247,6 +343,8 @@ export const newTextElement = (
containerId?: ExcalidrawTextContainer["id"] | null;
lineHeight?: ExcalidrawTextElement["lineHeight"];
autoResize?: ExcalidrawTextElement["autoResize"];
labelPosition?: ExcalidrawTextElement["labelPosition"];
baseFontSize?: ExcalidrawTextElement["baseFontSize"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => {
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
@@ -265,10 +363,11 @@ export const newTextElement = (
metrics,
);
const textElementProps: ExcalidrawTextElement = {
const textElementProps: NonDeleted<ExcalidrawTextElement> = {
..._newElementBase<ExcalidrawTextElement>("text", opts),
text,
fontSize,
baseFontSize: opts.baseFontSize ?? null,
fontFamily,
textAlign,
verticalAlign,
@@ -280,9 +379,10 @@ export const newTextElement = (
originalText: opts.originalText ?? text,
autoResize: opts.autoResize ?? true,
lineHeight,
labelPosition: opts.labelPosition ?? null,
};
const textElement: ExcalidrawTextElement = newElementWith(
const textElement: NonDeleted<ExcalidrawTextElement> = newElementWith(
textElementProps,
{},
);
@@ -294,6 +394,8 @@ const getAdjustedDimensions = (
element: ExcalidrawTextElement,
elementsMap: ElementsMap,
nextText: string,
/** the width the text is to take regardless of its content */
nextFixedWidth?: number,
): {
x: number;
y: number;
@@ -307,7 +409,9 @@ const getAdjustedDimensions = (
);
// wrapped text
if (!element.autoResize) {
if (nextFixedWidth !== undefined) {
nextWidth = nextFixedWidth;
} else if (!element.autoResize) {
nextWidth = element.width;
}
@@ -346,9 +450,18 @@ const getAdjustedDimensions = (
const deltaX2 = (x2 - nextX2) / 2;
const deltaY2 = (y2 - nextY2) / 2;
// grow away from the edge(s) the alignment anchors the text to, so that
// the anchor stays put as the text is edited. `verticalAlign` has no
// visual effect on unbound text, but standalone text bound to an arrow
// endpoint uses it to pin the side the arrow attaches to.
[x, y] = adjustXYWithRotation(
{
s: true,
n:
verticalAlign === VERTICAL_ALIGN.MIDDLE ||
verticalAlign === VERTICAL_ALIGN.BOTTOM,
s:
verticalAlign === VERTICAL_ALIGN.MIDDLE ||
verticalAlign === VERTICAL_ALIGN.TOP,
e: textAlign === "center" || textAlign === "left",
w: textAlign === "center" || textAlign === "right",
},
@@ -422,10 +535,38 @@ export const refreshTextDimensions = (
container: ExcalidrawTextContainer | null,
elementsMap: ElementsMap,
text = textElement.text,
/**
* For a free text that grows with its content (`autoResize`): the widest
* it may grow to. Crossing it, the text stops growing and wraps at this
* width from then on (`autoResize: false`), anchored as its growth was. A
* text that is already wider keeps growing.
*/
maxWidth?: number,
) => {
if (textElement.isDeleted) {
return;
}
if (
maxWidth !== undefined &&
!container &&
textElement.autoResize &&
textElement.width <= maxWidth
) {
const font = getFontString(textElement);
if (measureText(text, font, textElement.lineHeight).width > maxWidth) {
const wrappedText = wrapText(text, font, maxWidth);
return {
text: wrappedText,
autoResize: false,
...getAdjustedDimensions(
textElement,
elementsMap,
wrappedText,
maxWidth,
),
};
}
}
if (container || !textElement.autoResize) {
text = wrapText(
text,
@@ -444,6 +585,7 @@ export const newFreeDrawElement = (
type: "freedraw";
points?: ExcalidrawFreeDrawElement["points"];
simulatePressure: boolean;
strokeOptions?: ExcalidrawFreeDrawElement["strokeOptions"];
pressures?: ExcalidrawFreeDrawElement["pressures"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => {
@@ -452,6 +594,10 @@ export const newFreeDrawElement = (
points: opts.points || [],
pressures: opts.pressures || [],
simulatePressure: opts.simulatePressure,
strokeOptions: opts.strokeOptions ?? {
variability: "variable",
streamline: DEFAULT_STROKE_STREAMLINE,
},
};
};
+459 -258
View File
@@ -1,6 +1,7 @@
import rough from "roughjs/bin/rough";
import {
clamp,
type GlobalPoint,
isRightAngleRads,
lineSegment,
@@ -24,6 +25,11 @@ import {
invariant,
applyDarkModeFilter,
isSafari,
STICKY_NOTE_EDGE_SHADOW_OPACITY,
STICKY_NOTE_EDGE_SHADOW_WIDTH,
STICKY_NOTE_FOOTER,
STICKY_NOTE_SHADOW_OPACITY,
DEFAULT_ZOOM,
} from "@excalidraw/common";
import type {
@@ -31,9 +37,7 @@ import type {
StaticCanvasAppState,
Zoom,
InteractiveCanvasAppState,
ElementsPendingErasure,
PendingExcalidrawElements,
NormalizedZoomValue,
ElementRenderOverrides,
} from "@excalidraw/excalidraw/types";
import type {
@@ -61,21 +65,27 @@ import {
isArrowElement,
hasBoundTextElement,
isMagicFrameElement,
isFrameLikeElement,
isImageElement,
} from "./typeChecks";
import { getContainingFrame } from "./frame";
import { getCornerRadius } from "./utils";
import { ShapeCache } from "./shape";
import {
getStickyNoteFooter,
getStickyNotePathCommands,
type StickyNotePathCommand,
} from "./stickyNote";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
NonDeleted,
NonDeletedExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
ExcalidrawTextElementWithContainer,
ExcalidrawFrameLikeElement,
NonDeletedSceneElementsMap,
ElementsMap,
} from "./types";
@@ -105,30 +115,90 @@ const getCanvasPadding = (element: ExcalidrawElement) => {
}
};
export const getRenderOpacity = (
element: ExcalidrawElement,
containingFrame: ExcalidrawFrameLikeElement | null,
elementsPendingErasure: ElementsPendingErasure,
pendingNodes: Readonly<PendingExcalidrawElements> | null,
globalAlpha: number = 1,
) => {
// multiplying frame opacity with element opacity to combine them
// (e.g. frame 50% and element 50% opacity should result in 25% opacity)
let opacity =
(((containingFrame?.opacity ?? 100) * element.opacity) / 10000) *
globalAlpha;
export type RenderPositionOffset = Readonly<{ x: number; y: number }>;
// if pending erasure, multiply again to combine further
// (so that erasing always results in lower opacity than original)
const ZERO_RENDER_OFFSET: RenderPositionOffset = { x: 0, y: 0 };
/** Bound labels follow their container; a label's own offset is ignored. */
export const getElementRenderOffset = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
overrides: ElementRenderOverrides | undefined,
): RenderPositionOffset | undefined => {
const container = isTextElement(element)
? getContainerElement(element, elementsMap)
: null;
return overrides?.get((container ?? element).id)?.offset;
};
export const getRenderElementWithPositionOverride = <
TElement extends ExcalidrawElement,
>(
element: TElement,
positionOffset: RenderPositionOffset,
): TElement => {
if (positionOffset.x === 0 && positionOffset.y === 0) {
return element;
}
return {
...element,
x: element.x + positionOffset.x,
y: element.y + positionOffset.y,
} as TElement;
};
export type ElementRenderState = Readonly<{
/** Alpha including frame opacity and pending erasure; selection dimming is separate. */
opacity: number;
offset: RenderPositionOffset;
}>;
/** Resolve visual state at the drawing boundary, preserving document cache keys. */
export const resolveElementRenderState = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
renderConfig: Pick<
StaticCanvasRenderConfig,
| "elementRenderOverrides"
| "elementsPendingErasure"
| "pendingFlowchartNodes"
>,
allElementsMap: ElementsMap = elementsMap,
): ElementRenderState => {
const {
elementRenderOverrides,
elementsPendingErasure,
pendingFlowchartNodes,
} = renderConfig;
const override = elementRenderOverrides?.get(element.id);
const containingFrame = getContainingFrame(element, elementsMap);
const frameOpacity = containingFrame
? clamp(
elementRenderOverrides?.get(containingFrame.id)?.opacity ??
containingFrame.opacity,
0,
100,
)
: 100;
// Frame and element alpha multiply (50% each produces 25%).
let opacity =
(frameOpacity * clamp(override?.opacity ?? element.opacity, 0, 100)) /
10000;
if (
elementsPendingErasure.has(element.id) ||
(pendingNodes && pendingNodes.some((node) => node.id === element.id)) ||
pendingFlowchartNodes?.some((node) => node.id === element.id) ||
(containingFrame && elementsPendingErasure.has(containingFrame.id))
) {
opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100;
}
const offset = getElementRenderOffset(
element,
allElementsMap,
elementRenderOverrides,
);
return opacity;
return { opacity, offset: offset ?? ZERO_RENDER_OFFSET };
};
export interface ExcalidrawElementWithCanvas {
@@ -136,14 +206,11 @@ export interface ExcalidrawElementWithCanvas {
canvas: HTMLCanvasElement;
theme: AppState["theme"];
scale: number;
angle: number;
zoomValue: AppState["zoom"]["value"];
canvasOffsetX: number;
canvasOffsetY: number;
boundTextElementVersion: number | null;
imageCrop: ExcalidrawImageElement["crop"] | null;
containingFrameOpacity: number;
boundTextCanvas: HTMLCanvasElement;
}
const cappedElementCanvasSize = (
@@ -257,69 +324,6 @@ const generateElementCanvas = (
context.restore();
const boundTextElement = getBoundTextElement(element, elementsMap);
const boundTextCanvas = document.createElement("canvas");
const boundTextCanvasContext = boundTextCanvas.getContext("2d")!;
if (isArrowElement(element) && boundTextElement) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
// Take max dimensions of arrow canvas so that when canvas is rotated
// the arrow doesn't get clipped
const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
boundTextCanvas.width =
maxDim * window.devicePixelRatio * scale + padding * scale * 10;
boundTextCanvas.height =
maxDim * window.devicePixelRatio * scale + padding * scale * 10;
boundTextCanvasContext.translate(
boundTextCanvas.width / 2,
boundTextCanvas.height / 2,
);
boundTextCanvasContext.rotate(element.angle);
boundTextCanvasContext.drawImage(
canvas!,
-canvas.width / 2,
-canvas.height / 2,
canvas.width,
canvas.height,
);
const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords(
boundTextElement,
elementsMap,
);
boundTextCanvasContext.rotate(-element.angle);
const offsetX = (boundTextCanvas.width - canvas!.width) / 2;
const offsetY = (boundTextCanvas.height - canvas!.height) / 2;
const shiftX =
boundTextCanvas.width / 2 -
(boundTextCx - x1) * window.devicePixelRatio * scale -
offsetX -
padding * scale;
const shiftY =
boundTextCanvas.height / 2 -
(boundTextCy - y1) * window.devicePixelRatio * scale -
offsetY -
padding * scale;
boundTextCanvasContext.translate(-shiftX, -shiftY);
// Clear the bound text area
boundTextCanvasContext.clearRect(
-(boundTextElement.width / 2 + BOUND_TEXT_PADDING) *
window.devicePixelRatio *
scale,
-(boundTextElement.height / 2 + BOUND_TEXT_PADDING) *
window.devicePixelRatio *
scale,
(boundTextElement.width + BOUND_TEXT_PADDING * 2) *
window.devicePixelRatio *
scale,
(boundTextElement.height + BOUND_TEXT_PADDING * 2) *
window.devicePixelRatio *
scale,
);
}
return {
element,
canvas,
@@ -328,12 +332,8 @@ const generateElementCanvas = (
zoomValue: zoom.value,
canvasOffsetX,
canvasOffsetY,
boundTextElementVersion:
getBoundTextElement(element, elementsMap)?.version || null,
containingFrameOpacity:
getContainingFrame(element, elementsMap)?.opacity || 100,
boundTextCanvas,
angle: element.angle,
imageCrop: isImageElement(element) ? element.crop : null,
};
};
@@ -384,6 +384,50 @@ const drawImagePlaceholder = (
);
};
const drawStickyNotePath = (
context: CanvasRenderingContext2D,
commands: StickyNotePathCommand[],
) => {
context.beginPath();
for (const command of commands) {
if (command.type === "move") {
context.moveTo(command.point.x, command.point.y);
} else if (command.type === "line") {
context.lineTo(command.point.x, command.point.y);
} else {
context.quadraticCurveTo(
command.control.x,
command.control.y,
command.point.x,
command.point.y,
);
}
}
context.closePath();
};
const fillStickyNoteShape = (
context: CanvasRenderingContext2D,
commands: StickyNotePathCommand[],
) => {
drawStickyNotePath(context, commands);
context.fill();
};
const strokeStickyNoteEdge = (
context: CanvasRenderingContext2D,
commands: StickyNotePathCommand[],
) => {
context.save();
drawStickyNotePath(context, commands);
context.clip();
context.lineWidth = STICKY_NOTE_EDGE_SHADOW_WIDTH * 2;
context.strokeStyle = `rgba(0, 0, 0, ${STICKY_NOTE_EDGE_SHADOW_OPACITY})`;
drawStickyNotePath(context, commands);
context.stroke();
context.restore();
};
const drawElementOnCanvas = (
element: NonDeletedExcalidrawElement,
rc: RoughCanvas,
@@ -391,6 +435,41 @@ const drawElementOnCanvas = (
renderConfig: StaticCanvasRenderConfig,
) => {
switch (element.type) {
case "stickynote": {
context.save();
context.fillStyle = `rgba(0, 0, 0, ${STICKY_NOTE_SHADOW_OPACITY})`;
fillStickyNoteShape(
context,
getStickyNotePathCommands(element, { shadow: true }),
);
const commands = getStickyNotePathCommands(element);
context.fillStyle = applyDarkModeFilter(
element.backgroundColor,
renderConfig.theme === THEME.DARK,
);
fillStickyNoteShape(context, commands);
strokeStickyNoteEdge(context, commands);
// the label is absolute, so this cached canvas only goes stale at a
// year boundary — and is regenerated on the next zoom, theme or
// element change anyway
const footer = getStickyNoteFooter(element);
if (footer) {
context.font = `${STICKY_NOTE_FOOTER.fontSize}px ${STICKY_NOTE_FOOTER.fontFamily}`;
context.textAlign = "right";
context.textBaseline = "alphabetic";
context.fillStyle = applyDarkModeFilter(
element.strokeColor,
renderConfig.theme === THEME.DARK,
);
context.globalAlpha *= STICKY_NOTE_FOOTER.opacity;
context.fillText(footer.text, footer.x, footer.y);
}
context.restore();
break;
}
case "rectangle":
case "iframe":
case "embeddable":
@@ -422,10 +501,10 @@ const drawElementOnCanvas = (
for (const shape of shapes) {
if (typeof shape === "string") {
context.fillStyle =
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
context.fillStyle = applyDarkModeFilter(
element.strokeColor,
renderConfig.theme === THEME.DARK,
);
context.fill(new Path2D(shape));
} else {
rc.draw(shape);
@@ -555,10 +634,10 @@ const drawElementOnCanvas = (
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
context.save();
context.font = getFontString(element);
context.fillStyle =
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
context.fillStyle = applyDarkModeFilter(
element.strokeColor,
renderConfig.theme === THEME.DARK,
);
context.textAlign = element.textAlign as CanvasTextAlign;
// Canvas does not support multiline text by default
@@ -611,18 +690,12 @@ const generateElementWithCanvas = (
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
) => {
const zoom: Zoom = renderConfig
? appState.zoom
: {
value: 1 as NormalizedZoomValue,
};
const zoom: Zoom = renderConfig ? appState.zoom : DEFAULT_ZOOM;
const prevElementWithCanvas = elementWithCanvasCache.get(element);
const shouldRegenerateBecauseZoom =
prevElementWithCanvas &&
prevElementWithCanvas.zoomValue !== zoom.value &&
!appState?.shouldCacheIgnoreZoom;
const boundTextElement = getBoundTextElement(element, elementsMap);
const boundTextElementVersion = boundTextElement?.version || null;
const imageCrop = isImageElement(element) ? element.crop : null;
const containingFrameOpacity =
@@ -632,16 +705,8 @@ const generateElementWithCanvas = (
!prevElementWithCanvas ||
shouldRegenerateBecauseZoom ||
prevElementWithCanvas.theme !== appState.theme ||
prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion ||
prevElementWithCanvas.imageCrop !== imageCrop ||
prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity ||
// since we rotate the canvas when copying from cached canvas, we don't
// regenerate the cached canvas. But we need to in case of labels which are
// cached alongside the arrow, and we want the labels to remain unrotated
// with respect to the arrow.
(isArrowElement(element) &&
boundTextElement &&
element.angle !== prevElementWithCanvas.angle)
prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity
) {
const elementWithCanvas = generateElementCanvas(
element,
@@ -662,91 +727,206 @@ const generateElementWithCanvas = (
return prevElementWithCanvas;
};
/**
* Whether an element's cached bitmap may be laid on the device-pixel grid:
* unrotated or at a right angle, and not during a zoom gesture. The cached
* path disables smoothing for exactly these — a nearest-neighbor 1:1 blit is
* a pixel-exact copy, and rounding its origin keeps it off the half-pixel
* boundary where it tears — and every eligible blit is snapped. It is
* eligibility, not a guarantee of a 1:1 copy: freedraw is blitted with
* smoothing on, and a size-capped cache is rescaled; snapping is harmless
* for both.
*
* Not during a zoom gesture: the bitmaps stay at the old scale and are
* resampled, and blurry shapes look better on low resolution (while still
* zooming in) than sharp ones; snapping would only make elements twitch.
* Right angles qualify because smoothing can be off there without aliasing
* (for other angles it is terrible on Chromium); the right-angle test
* tolerates float arithmetic.
*/
const canSnapElement = (
element: ExcalidrawElement,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
) =>
!appState?.shouldCacheIgnoreZoom &&
(!element.angle || isRightAngleRads(element.angle));
/**
* Breaks a `Math.round` tie at exactly half a device pixel the same way
* every frame. A label's offset from its container lands on one routinely
* (an odd scene offset at 150%), and the float noise of a fractional drag
* would otherwise flip it between the two neighbors.
*/
const SNAP_TIE_BIAS = 1e-6;
const drawElementFromCanvas = (
elementWithCanvas: ExcalidrawElementWithCanvas,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
allElementsMap: NonDeletedSceneElementsMap,
positionOffset: RenderPositionOffset,
) => {
const element = elementWithCanvas.element;
// the ratio the cached bitmap was generated with (`generateElementCanvas`);
// the blit's size math needs the same one, so it is not the owner window's
const devicePixelRatio = window.devicePixelRatio;
const padding = getCanvasPadding(element);
const zoom = elementWithCanvas.scale;
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap);
const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio;
const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio;
const cx =
((x1 + x2) / 2 + positionOffset.x + appState.scrollX) * devicePixelRatio;
const cy =
((y1 + y2) / 2 + positionOffset.y + appState.scrollY) * devicePixelRatio;
context.save();
context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
context.scale(1 / devicePixelRatio, 1 / devicePixelRatio);
const boundTextElement = getBoundTextElement(element, allElementsMap);
if (isArrowElement(element) && boundTextElement) {
const offsetX =
(elementWithCanvas.boundTextCanvas.width -
elementWithCanvas.canvas!.width) /
2;
const offsetY =
(elementWithCanvas.boundTextCanvas.height -
elementWithCanvas.canvas!.height) /
2;
context.translate(cx, cy);
context.drawImage(
elementWithCanvas.boundTextCanvas,
(-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding,
(-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding,
elementWithCanvas.boundTextCanvas.width / zoom,
elementWithCanvas.boundTextCanvas.height / zoom,
// punch the label "hole" by clipping the arrow's blit out of the label
// rect (even-odd) instead of baking a rotated arrow copy with a cleared
// hole into a separate (`maxDim`-squared!) canvas. The hole stays
// axis-aligned in scene space while the blit below rotates.
const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords(
boundTextElement,
allElementsMap,
);
} else {
// we translate context to element center so that rotation and scale
// originates from the element center
context.translate(cx, cy);
context.rotate(element.angle);
if (
"scale" in elementWithCanvas.element &&
!isPendingImageElement(element, renderConfig)
) {
context.scale(
elementWithCanvas.element.scale[0],
elementWithCanvas.element.scale[1],
);
}
// revert afterwards we don't have account for it during drawing
context.translate(-cx, -cy);
context.drawImage(
elementWithCanvas.canvas!,
(x1 + appState.scrollX) * window.devicePixelRatio -
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
(y1 + appState.scrollY) * window.devicePixelRatio -
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
elementWithCanvas.canvas!.width / elementWithCanvas.scale,
elementWithCanvas.canvas!.height / elementWithCanvas.scale,
// generously covers the arrow's blit at any rotation
const outerHalf =
Math.max(distance(x1, x2), distance(y1, y2)) * devicePixelRatio +
padding * 10;
context.beginPath();
context.rect(cx - outerHalf, cy - outerHalf, outerHalf * 2, outerHalf * 2);
context.rect(
(boundTextCx -
boundTextElement.width / 2 -
BOUND_TEXT_PADDING +
positionOffset.x +
appState.scrollX) *
devicePixelRatio,
(boundTextCy -
boundTextElement.height / 2 -
BOUND_TEXT_PADDING +
positionOffset.y +
appState.scrollY) *
devicePixelRatio,
(boundTextElement.width + BOUND_TEXT_PADDING * 2) * devicePixelRatio,
(boundTextElement.height + BOUND_TEXT_PADDING * 2) * devicePixelRatio,
);
context.clip("evenodd");
}
if (
import.meta.env.VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX ===
"true" &&
hasBoundTextElement(element)
) {
const textElement = getBoundTextElement(
element,
allElementsMap,
) as ExcalidrawTextElementWithContainer;
const coords = getContainerCoords(element);
context.strokeStyle = "#c92a2a";
context.lineWidth = 3;
context.strokeRect(
(coords.x + appState.scrollX) * window.devicePixelRatio,
(coords.y + appState.scrollY) * window.devicePixelRatio,
getBoundTextMaxWidth(element, textElement) * window.devicePixelRatio,
getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio,
);
}
// we translate context to element center so that rotation and scale
// originates from the element center
context.translate(cx, cy);
context.rotate(element.angle);
if (
"scale" in elementWithCanvas.element &&
!isPendingImageElement(element, renderConfig)
) {
context.scale(
elementWithCanvas.element.scale[0],
elementWithCanvas.element.scale[1],
);
}
// revert afterwards we don't have account for it during drawing
context.translate(-cx, -cy);
// the blit origin, in the space the context is in here (scaled by
// canvas scale × zoom ÷ devicePixelRatio)
let drawX =
(x1 + positionOffset.x + appState.scrollX) * devicePixelRatio - padding;
let drawY =
(y1 + positionOffset.y + appState.scrollY) * devicePixelRatio - padding;
const transform = context.getTransform();
if (canSnapElement(element, appState)) {
// blit the cached bitmap on whole device pixels. Nearest-neighbor at a
// fractional offset is a pixel-exact copy shifted to the nearest pixel
// — except at an exact half pixel, where a GPU-accelerated canvas
// decides the rounding per scanline by float precision and a few rows
// sample the neighboring row: doubled or broken strokes, varying with
// scroll and position. A centered label lands on a half pixel
// routinely.
//
// Done by moving the transform's origin onto the rounded device
// position of the blit and drawing at (0, 0): the rotation, mirroring
// and scale stay in `a`–`d` (a right angle keeps its scale in `b`/`c`),
// and the origin is an exact integer even in the canvas's float32
// matrix. Rounding `drawX` itself only lands on device pixels at 100%
// zoom.
const { a, b, c, d, e, f } = transform;
const container = isTextElement(element)
? getContainerElement(element, allElementsMap)
: null;
// A bound label shares its unrotated container's offset and anchor.
// Other bitmaps anchor to themselves.
const anchor = container && !container.angle ? container : element;
const anchorCoords =
anchor === element
? null
: getElementAbsoluteCoords(anchor, allElementsMap);
const anchorSceneX = anchorCoords?.[0] ?? x1;
const anchorSceneY = anchorCoords?.[1] ?? y1;
const anchorPadding =
anchor === element ? padding : getCanvasPadding(anchor);
const anchorX =
(anchorSceneX + positionOffset.x + appState.scrollX) * devicePixelRatio -
anchorPadding;
const anchorY =
(anchorSceneY + positionOffset.y + appState.scrollY) * devicePixelRatio -
anchorPadding;
// Form the relative vector before introducing the scroll translation.
const dx = (x1 - anchorSceneX) * devicePixelRatio + anchorPadding - padding;
const dy = (y1 - anchorSceneY) * devicePixelRatio + anchorPadding - padding;
context.setTransform(
a,
b,
c,
d,
Math.round(a * anchorX + c * anchorY + e) +
Math.round(a * dx + c * dy + SNAP_TIE_BIAS),
Math.round(b * anchorX + d * anchorY + f) +
Math.round(b * dx + d * dy + SNAP_TIE_BIAS),
);
drawX = 0;
drawY = 0;
}
context.drawImage(
elementWithCanvas.canvas!,
drawX,
drawY,
elementWithCanvas.canvas!.width / elementWithCanvas.scale,
elementWithCanvas.canvas!.height / elementWithCanvas.scale,
);
context.setTransform(transform);
if (
import.meta.env.VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX ===
"true" &&
hasBoundTextElement(element)
) {
const textElement = getBoundTextElement(
element,
allElementsMap,
) as ExcalidrawTextElementWithContainer;
const coords = getContainerCoords(element);
context.strokeStyle = "#c92a2a";
context.lineWidth = 3;
context.strokeRect(
(coords.x + positionOffset.x + appState.scrollX) * devicePixelRatio,
(coords.y + positionOffset.y + appState.scrollY) * devicePixelRatio,
getBoundTextMaxWidth(element, textElement) * devicePixelRatio,
getBoundTextMaxHeight(element, textElement) * devicePixelRatio,
);
}
context.restore();
@@ -761,7 +941,10 @@ export const renderSelectionElement = (
) => {
context.save();
context.translate(element.x + appState.scrollX, element.y + appState.scrollY);
context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.fillStyle = applyDarkModeFilter(
"rgba(0, 0, 200, 0.04)",
appState.theme === THEME.DARK,
);
// render from 0.5px offset to get 1px wide line
// https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
@@ -785,20 +968,56 @@ export const renderElement = (
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
renderState = resolveElementRenderState(
element,
elementsMap,
renderConfig,
allElementsMap,
),
) => {
const reduceAlphaForSelection =
appState.openDialog?.name === "elementLinkSelector" &&
!appState.selectedElementIds[element.id] &&
!appState.hoveredElementIds[element.id];
context.globalAlpha = getRenderOpacity(
element,
getContainingFrame(element, elementsMap),
renderConfig.elementsPendingErasure,
renderConfig.pendingFlowchartNodes,
reduceAlphaForSelection ? DEFAULT_REDUCED_GLOBAL_ALPHA : 1,
);
context.save();
context.globalAlpha =
renderState.opacity *
(reduceAlphaForSelection ? DEFAULT_REDUCED_GLOBAL_ALPHA : 1);
// Cached bitmaps apply the offset before pixel snapping. Moving it into
// the canvas transform first loses precision at half-device-pixel ties.
if (
(renderConfig.isExporting || isFrameLikeElement(element)) &&
(renderState.offset.x || renderState.offset.y)
) {
context.translate(renderState.offset.x, renderState.offset.y);
}
try {
drawElement(
element,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
renderState,
);
} finally {
context.restore();
}
};
const drawElement = (
element: NonDeletedExcalidrawElement,
elementsMap: RenderableElementsMap,
allElementsMap: NonDeletedSceneElementsMap,
rc: RoughCanvas,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
renderState: ElementRenderState,
) => {
switch (element.type) {
case "magicframe":
case "frame": {
@@ -811,10 +1030,10 @@ export const renderElement = (
context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle =
appState.theme === THEME.DARK
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
: FRAME_STYLE.strokeColor;
context.strokeStyle = applyDarkModeFilter(
FRAME_STYLE.strokeColor,
appState.theme === THEME.DARK,
);
// TODO change later to only affect AI frames
if (isMagicFrameElement(element)) {
@@ -873,12 +1092,14 @@ export const renderElement = (
renderConfig,
appState,
allElementsMap,
renderState.offset,
);
}
break;
}
case "rectangle":
case "stickynote":
case "diamond":
case "ellipse":
case "line":
@@ -889,8 +1110,10 @@ export const renderElement = (
case "embeddable": {
if (renderConfig.isExporting) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2 + appState.scrollX;
const cy = (y1 + y2) / 2 + appState.scrollY;
const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2;
const cx = centerX + appState.scrollX;
const cy = centerY + appState.scrollY;
let shiftX = (x2 - x1) / 2 - (element.x - x1);
let shiftY = (y2 - y1) / 2 - (element.y - y1);
if (isTextElement(element)) {
@@ -912,64 +1135,46 @@ export const renderElement = (
const boundTextElement = getBoundTextElement(element, elementsMap);
if (isArrowElement(element) && boundTextElement) {
const tempCanvas = document.createElement("canvas");
const tempCanvasContext = tempCanvas.getContext("2d")!;
// Take max dimensions of arrow canvas so that when canvas is rotated
// the arrow doesn't get clipped
const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
const padding = getCanvasPadding(element);
tempCanvas.width =
maxDim * appState.exportScale + padding * 10 * appState.exportScale;
tempCanvas.height =
maxDim * appState.exportScale + padding * 10 * appState.exportScale;
tempCanvasContext.translate(
tempCanvas.width / 2,
tempCanvas.height / 2,
);
tempCanvasContext.scale(appState.exportScale, appState.exportScale);
// Shift the canvas to left most point of the arrow
// Draw arrow directly as vector (no temp-canvas bitmap blit which
// introduces resampling blur). The label "hole" is cut by clipping
// the arrow's own strokes out of the label rect (even-odd clip)
// so that elements rendered beneath the arrow keep showing through
// the gap.
shiftX = element.width / 2 - (element.x - x1);
shiftY = element.height / 2 - (element.y - y1);
tempCanvasContext.rotate(element.angle);
const tempRc = rough.canvas(tempCanvas);
tempCanvasContext.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
tempCanvasContext.translate(shiftX, shiftY);
tempCanvasContext.rotate(-element.angle);
// Shift the canvas to center of bound text
const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords(
boundTextElement,
elementsMap,
);
const boundTextShiftX = (x1 + x2) / 2 - boundTextCx;
const boundTextShiftY = (y1 + y2) / 2 - boundTextCy;
tempCanvasContext.translate(-boundTextShiftX, -boundTextShiftY);
const holeX =
boundTextCx -
centerX -
boundTextElement.width / 2 -
BOUND_TEXT_PADDING;
const holeY =
boundTextCy -
centerY -
boundTextElement.height / 2 -
BOUND_TEXT_PADDING;
const holeWidth = boundTextElement.width + BOUND_TEXT_PADDING * 2;
const holeHeight = boundTextElement.height + BOUND_TEXT_PADDING * 2;
// Clear the bound text area
tempCanvasContext.clearRect(
-boundTextElement.width / 2,
-boundTextElement.height / 2,
boundTextElement.width,
boundTextElement.height,
);
context.scale(1 / appState.exportScale, 1 / appState.exportScale);
context.drawImage(
tempCanvas,
-tempCanvas.width / 2,
-tempCanvas.height / 2,
tempCanvas.width,
tempCanvas.height,
);
// generously covers the arrow's painted extent at any rotation
// (the hole rect stays axis-aligned in scene space)
const outerHalf =
Math.max(distance(x1, x2), distance(y1, y2)) +
getCanvasPadding(element) * 10;
context.save();
context.beginPath();
context.rect(-outerHalf, -outerHalf, outerHalf * 2, outerHalf * 2);
context.rect(holeX, holeY, holeWidth, holeHeight);
context.clip("evenodd");
context.rotate(element.angle);
context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context, renderConfig);
context.restore();
} else {
context.rotate(element.angle);
@@ -999,17 +1204,9 @@ export const renderElement = (
const currentImageSmoothingStatus = context.imageSmoothingEnabled;
if (
// do not disable smoothing during zoom as blurry shapes look better
// on low resolution (while still zooming in) than sharp ones
!appState?.shouldCacheIgnoreZoom &&
// angle is 0 -> always disable smoothing
(!element.angle ||
// or check if angle is a right angle in which case we can still
// disable smoothing without adversely affecting the result
// We need less-than comparison because of FP artihmetic
isRightAngleRads(element.angle))
) {
// see `canSnapElement` for why not during zoom gestures or at
// other angles
if (canSnapElement(element, appState)) {
// Disabling smoothing makes output much sharper, especially for
// text. Unless for non-right angles, where the aliasing is really
// terrible on Chromium.
@@ -1029,7 +1226,10 @@ export const renderElement = (
context.globalAlpha = 0.1;
const uncroppedElementCanvas = generateElementCanvas(
getUncroppedImageElement(elementWithCanvas.element, elementsMap),
getUncroppedImageElement(
elementWithCanvas.element,
elementsMap,
) as NonDeleted<ExcalidrawImageElement>,
allElementsMap,
appState.zoom,
renderConfig,
@@ -1043,6 +1243,8 @@ export const renderElement = (
renderConfig,
appState,
allElementsMap,
// The crop editor's uncropped preview stays at document coordinates.
ZERO_RENDER_OFFSET,
);
}
@@ -1055,6 +1257,7 @@ export const renderElement = (
renderConfig,
appState,
allElementsMap,
renderState.offset,
);
// reset
@@ -1067,8 +1270,6 @@ export const renderElement = (
throw new Error(`Unimplemented type ${element.type}`);
}
}
context.globalAlpha = 1;
};
export function getFreedrawOutlineAsSegments(
+131 -37
View File
@@ -10,6 +10,7 @@ import {
import {
MIN_FONT_SIZE,
SHIFT_LOCKING_ANGLE,
STICKY_NOTE_MIN_SIZE,
rescalePoints,
getFontString,
} from "@excalidraw/common";
@@ -57,10 +58,16 @@ import {
isFreeDrawElement,
isImageElement,
isLinearElement,
isStickyNoteElement,
isTextElement,
} from "./typeChecks";
import { isInGroup } from "./groups";
import {
getStickyNoteMinSize,
getStickyNoteResizeIntent,
updateStickyNoteLayout,
} from "./stickyNote";
import type { Scene } from "./Scene";
@@ -234,7 +241,7 @@ const rotateSingleElement = (
if (isBindingElement(element)) {
update = {
...update,
} as ElementUpdate<ExcalidrawArrowElement>;
} as ElementUpdate<NonDeletedExcalidrawElement>;
if (element.startBinding) {
unbindBindingElement(element, "start", scene);
@@ -388,7 +395,7 @@ export const resizeSingleTextElement = (
shouldResizeFromCenter,
);
const resizedElement: Partial<ExcalidrawTextElement> = {
const resizedElement: Partial<NonDeleted<ExcalidrawTextElement>> = {
width: Math.abs(newWidth),
height: Math.abs(metrics.height),
x: newOrigin.x,
@@ -722,8 +729,8 @@ const getResizedOrigin = (
export const resizeSingleElement = (
nextWidth: number,
nextHeight: number,
latestElement: ExcalidrawElement,
origElement: ExcalidrawElement,
latestElement: NonDeletedExcalidrawElement,
origElement: NonDeletedExcalidrawElement,
originalElementsMap: ElementsMap,
scene: Scene,
handleDirection: TransformHandleDirection,
@@ -749,11 +756,54 @@ export const resizeSingleElement = (
);
}
let boundTextFont: { fontSize?: number } = {};
// Constraints and font scaling use magnitudes. Keep the signs for the
// geometry below, where crossing the opposite edge flips the element.
const flipFactorX = nextWidth < 0 ? -1 : 1;
const flipFactorY = nextHeight < 0 ? -1 : 1;
nextWidth = Math.abs(nextWidth);
nextHeight = Math.abs(nextHeight);
const elementsMap = scene.getNonDeletedElementsMap();
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
const isResizingStickyNote = isStickyNoteElement(latestElement);
let minSize: { width: number; height: number } | undefined;
if (isResizingStickyNote) {
// A note must fit one line at its label's font ceiling.
minSize = boundTextElement
? getStickyNoteMinSize({
fontSize: boundTextElement.baseFontSize ?? boundTextElement.fontSize,
fontFamily: boundTextElement.fontFamily,
})
: { width: STICKY_NOTE_MIN_SIZE, height: STICKY_NOTE_MIN_SIZE };
} else if (boundTextElement && !shouldMaintainAspectRatio) {
minSize = {
width: getApproxMinLineWidth(
getFontString(boundTextElement),
boundTextElement.lineHeight,
),
height: getApproxMinLineHeight(
boundTextElement.fontSize,
boundTextElement.lineHeight,
),
};
}
if (boundTextElement) {
if (minSize) {
nextWidth = Math.max(nextWidth, minSize.width);
nextHeight = Math.max(nextHeight, minSize.height);
if (shouldMaintainAspectRatio) {
// Both dimensions must use the same scale even at the minimum size.
const scale = Math.max(
nextWidth / origElement.width,
nextHeight / origElement.height,
);
nextWidth = origElement.width * scale;
nextHeight = origElement.height * scale;
}
}
let boundTextFont: { fontSize?: number } = {};
if (boundTextElement && !isResizingStickyNote) {
const stateOfBoundTextElementAtResize = originalElementsMap.get(
boundTextElement.id,
) as typeof boundTextElement | undefined;
@@ -780,20 +830,12 @@ export const resizeSingleElement = (
boundTextFont = {
fontSize: nextFont.size,
};
} else {
const minWidth = getApproxMinLineWidth(
getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const minHeight = getApproxMinLineHeight(
boundTextElement.fontSize,
boundTextElement.lineHeight,
);
nextWidth = Math.max(nextWidth, minWidth);
nextHeight = Math.max(nextHeight, minHeight);
}
}
nextWidth *= flipFactorX;
nextHeight *= flipFactorY;
const rescaledPoints = rescalePointsInElement(
origElement,
nextWidth,
@@ -865,7 +907,7 @@ export const resizeSingleElement = (
shouldMaintainAspectRatio
) {
const fontSize =
(nextWidth / latestElement.width) * boundTextElement.fontSize;
(Math.abs(nextWidth) / latestElement.width) * boundTextElement.fontSize;
if (fontSize < MIN_FONT_SIZE) {
return;
}
@@ -909,17 +951,35 @@ export const resizeSingleElement = (
isDragging: false,
});
if (boundTextElement && boundTextFont != null) {
scene.mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
if (isStickyNoteElement(latestElement)) {
updateStickyNoteLayout(latestElement, scene, {
...getStickyNoteResizeIntent(
latestElement,
originalElementsMap,
handleDirection,
{
proportional: shouldMaintainAspectRatio,
fromCenter: shouldResizeFromCenter,
},
),
// the arrow pass below is this function's — keep it single
bindings: false,
});
} else {
if (boundTextElement && boundTextFont != null) {
scene.mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
});
}
handleBindTextResize(
latestElement,
scene,
handleDirection,
shouldMaintainAspectRatio,
shouldResizeFromCenter,
flipFactorY < 0,
);
}
handleBindTextResize(
latestElement,
scene,
handleDirection,
shouldMaintainAspectRatio,
);
updateBoundElements(latestElement, scene);
}
@@ -1200,7 +1260,10 @@ export const resizeMultipleElements = (
}[],
element,
) => {
const origElement = originalElementsMap!.get(element.id);
// originalElementsMap holds snapshots of the (non-deleted) selection
const origElement = originalElementsMap!.get(element.id) as
| NonDeletedExcalidrawElement
| undefined;
if (origElement) {
acc.push({ orig: origElement, latest: element });
}
@@ -1239,9 +1302,10 @@ export const resizeMultipleElements = (
];
}, [] as ExcalidrawTextElementWithContainer[]);
boundingBox = getCommonBoundingBox(
targetElements.map(({ orig }) => orig).concat(boundTextElements),
);
boundingBox = getCommonBoundingBox([
...targetElements.map(({ orig }) => orig),
...boundTextElements,
]);
}
const { minX, minY, maxX, maxY, midX, midY } = boundingBox;
const width = maxX - minX;
@@ -1436,7 +1500,8 @@ export const resizeMultipleElements = (
getBoundTextElementId(orig) ?? "",
) as ExcalidrawTextElementWithContainer | undefined;
if (boundTextElement) {
// sticky notes derive their label's size from the layout below
if (boundTextElement && !isStickyNoteElement(orig)) {
if (keepAspectRatio) {
const newFontSize = boundTextElement.fontSize * scale;
if (newFontSize < MIN_FONT_SIZE) {
@@ -1468,9 +1533,27 @@ export const resizeMultipleElements = (
scene.mutateElement(element, update);
updateBoundElements(element, scene, {
simultaneouslyUpdated: elementsToUpdate,
});
if (isStickyNoteElement(element)) {
// the content correction runs before the (single) arrow pass, which
// must still skip arrows resized in the same gesture
updateStickyNoteLayout(element, scene, {
...getStickyNoteResizeIntent(
element,
originalElementsMap,
handleDirection,
{
proportional: keepAspectRatio,
fromCenter: shouldResizeFromCenter,
flip: flipByX || flipByY,
},
),
bindings: { simultaneouslyUpdated: elementsToUpdate },
});
} else {
updateBoundElements(element, scene, {
simultaneouslyUpdated: elementsToUpdate,
});
}
if (isBindingElement(element)) {
if (element.startBinding) {
@@ -1486,12 +1569,23 @@ export const resizeMultipleElements = (
}
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && boundTextFontSize) {
if (
boundTextElement &&
boundTextFontSize &&
!isStickyNoteElement(element)
) {
scene.mutateElement(boundTextElement, {
fontSize: boundTextFontSize,
angle: isLinearElement(element) ? undefined : angle,
});
handleBindTextResize(element, scene, handleDirection, true);
handleBindTextResize(
element,
scene,
handleDirection,
true,
shouldResizeFromCenter,
flipByY,
);
}
}
+45 -269
View File
@@ -1,10 +1,4 @@
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common";
import {
lineSegment,
pointFrom,
pointRotateRads,
type GlobalPoint,
} from "@excalidraw/math";
import type {
AppState,
@@ -12,34 +6,20 @@ import type {
InteractiveCanvasAppState,
} from "@excalidraw/excalidraw/types";
import {
boundsContainBounds,
doBoundsIntersect,
elementCenterPoint,
getElementAbsoluteCoords,
getElementBounds,
pointInsideBounds,
} from "./bounds";
import { intersectElementWithLineSegment } from "./collision";
import { elementsOverlappingBBox, getElementAbsoluteCoords } from "./bounds";
import { isElementInViewport } from "./sizeHelpers";
import {
isArrowElement,
isBoundToContainer,
isFrameLikeElement,
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import {
elementOverlapsWithFrame,
getContainingFrame,
getFrameChildren,
isElementIntersectingFrame,
} from "./frame";
import { getFrameChildren } from "./frame";
import { LinearElementEditor } from "./linearElementEditor";
import { selectGroupsForSelectedElements } from "./groups";
import { getBoundTextElement } from "./textElement";
import { isNonDeletedElement } from ".";
import type {
ElementsMap,
@@ -50,9 +30,8 @@ import type {
NonDeletedExcalidrawElement,
} from "./types";
const shouldIgnoreElementFromSelection = (
element: NonDeletedExcalidrawElement,
) => element.locked || isBoundToContainer(element);
const shouldIgnoreElementFromSelection = (element: ExcalidrawElement) =>
element.locked || isBoundToContainer(element);
const excludeElementsFromFrames = <T extends ExcalidrawElement>(
selectedElements: readonly T[],
@@ -88,14 +67,14 @@ export const excludeElementsInFramesFromSelection = <
return excludeElementsFromFrames(selectedElements, framesInSelection);
};
export const getElementsWithinSelection = (
elements: readonly NonDeletedExcalidrawElement[],
selection: NonDeletedExcalidrawElement,
export const getElementsWithinSelection = <T extends ExcalidrawElement>(
elements: readonly T[],
selection: ExcalidrawElement,
elementsMap: ElementsMap,
// TODO remove (this flag is effectively unused AFAIK)
excludeElementsInFrames: boolean = true,
boxSelectionMode: BoxSelectionMode = "contain",
): NonDeletedExcalidrawElement[] => {
): T[] => {
const [selectionStartX, selectionStartY, selectionEndX, selectionEndY] =
getElementAbsoluteCoords(selection, elementsMap);
const selectionX1 = Math.min(selectionStartX, selectionEndX);
@@ -108,238 +87,22 @@ export const getElementsWithinSelection = (
selectionX2,
selectionY2,
] as Bounds;
const selectionEdges = [
lineSegment<GlobalPoint>(
pointFrom(selectionX1, selectionY1),
pointFrom(selectionX2, selectionY1),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX2, selectionY1),
pointFrom(selectionX2, selectionY2),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX2, selectionY2),
pointFrom(selectionX1, selectionY2),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX1, selectionY2),
pointFrom(selectionX1, selectionY1),
),
];
const framesInSelection = excludeElementsInFrames
? new Set<NonDeletedExcalidrawElement["id"]>()
: null;
let elementsInSelection: NonDeletedExcalidrawElement[] = [];
for (const element of elements) {
if (shouldIgnoreElementFromSelection(element)) {
continue;
}
const strokeWidth = element.strokeWidth;
let labelAABB: Bounds | null = null;
let elementAABB = getElementBounds(element, elementsMap);
elementAABB = [
elementAABB[0] - strokeWidth / 2,
elementAABB[1] - strokeWidth / 2,
elementAABB[2] + strokeWidth / 2,
elementAABB[3] + strokeWidth / 2,
] as Bounds;
// Whether the element bounds should include the bound text element bounds
const boundTextElement =
isArrowElement(element) && getBoundTextElement(element, elementsMap);
if (boundTextElement) {
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
elementsMap,
);
labelAABB = [
x,
y,
x + boundTextElement.width,
y + boundTextElement.height,
] as Bounds;
}
// Clip element bounds by its containing frame (if any), since only the
// visible (frame-clipped) portion of the element is relevant for selection.
const associatedFrame = getContainingFrame(element, elementsMap);
if (
associatedFrame &&
isElementIntersectingFrame(element, associatedFrame, elementsMap)
) {
const frameAABB = getElementBounds(associatedFrame, elementsMap);
elementAABB = [
Math.max(elementAABB[0], frameAABB[0]),
Math.max(elementAABB[1], frameAABB[1]),
Math.min(elementAABB[2], frameAABB[2]),
Math.min(elementAABB[3], frameAABB[3]),
] as Bounds;
labelAABB = labelAABB
? ([
Math.max(labelAABB[0], frameAABB[0]),
Math.max(labelAABB[1], frameAABB[1]),
Math.min(labelAABB[2], frameAABB[2]),
Math.min(labelAABB[3], frameAABB[3]),
] as Bounds)
: null;
}
const commonAABB = labelAABB
? ([
Math.min(labelAABB[0], elementAABB[0]),
Math.min(labelAABB[1], elementAABB[1]),
Math.max(labelAABB[2], elementAABB[2]),
Math.max(labelAABB[3], elementAABB[3]),
] as Bounds)
: elementAABB;
// ============== Evaluation ==============
// 1. If the selection box WRAPs the element's AABB, then add it to the
// selection and move on, regardless of the selection mode.
//
// PERF: This trick only works with axis-aligned box selection and the
// current convex element shapes!
if (boundsContainBounds(selectionBounds, commonAABB)) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
} else {
elementsInSelection.push(element);
continue;
}
}
// 2. Handle the case where the label is overlapped by the selection box
if (
boxSelectionMode === "overlap" &&
labelAABB &&
doBoundsIntersect(selectionBounds, labelAABB)
) {
elementsInSelection.push(element);
continue;
}
// 3. Handle the case where the selection is not wrapping the element, but
// it does intersect the element's outline (non-AABB).
if (
boxSelectionMode === "overlap" &&
doBoundsIntersect(selectionBounds, elementAABB)
) {
let hasIntersection = false;
// Preliminary check potential intersection imprecision
if (isLinearElement(element) || isFreeDrawElement(element)) {
const center = elementCenterPoint(element, elementsMap);
hasIntersection = element.points.some((point) => {
const rotatedPoint = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return pointInsideBounds(rotatedPoint, selectionBounds);
});
} else {
const nonRotatedElementBounds = getElementBounds(
element,
elementsMap,
true,
);
const center = elementCenterPoint(element, elementsMap);
hasIntersection = [
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[2],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[0],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
].some((point) => {
return pointInsideBounds(
pointRotateRads(point, center, element.angle),
selectionBounds,
);
});
}
if (!hasIntersection) {
hasIntersection = selectionEdges.some(
(selectionEdge) =>
intersectElementWithLineSegment(
element,
elementsMap,
selectionEdge,
strokeWidth / 2,
true, // Stop at first hit for better performance
).length > 0,
);
}
if (hasIntersection) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
elementsInSelection.push(element);
continue;
}
}
// 4. We don't need to handle when the selection is inside the element
// as it is separately handled in App.
}
elementsInSelection = framesInSelection
? excludeElementsFromFrames(elementsInSelection, framesInSelection)
: elementsInSelection;
elementsInSelection = elementsInSelection.filter((element) => {
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
return elementOverlapsWithFrame(element, containingFrame, elementsMap);
}
return true;
return elementsOverlappingBBox({
elements,
bounds: selectionBounds,
elementsMap,
type: boxSelectionMode,
shouldIgnoreElementFromSelection,
excludeElementsInFrames,
});
return elementsInSelection;
};
export const getVisibleAndNonSelectedElements = (
elements: readonly NonDeletedExcalidrawElement[],
selectedElements: readonly NonDeletedExcalidrawElement[],
export const getVisibleAndNonSelectedElements = <
T extends NonDeletedExcalidrawElement,
>(
elements: readonly T[],
selectedElements: readonly ExcalidrawElement[],
appState: AppState,
elementsMap: ElementsMap,
) => {
@@ -404,30 +167,40 @@ export const getSelectedElements = (
},
) => {
const addedElements = new Set<ExcalidrawElement["id"]>();
const selectedElements: ExcalidrawElement[] = [];
// selection can only contain non-deleted elements
const selectedElements: NonDeletedExcalidrawElement[] = [];
for (const element of elements.values()) {
if (appState.selectedElementIds[element.id]) {
selectedElements.push(element);
addedElements.add(element.id);
if (isNonDeletedElement(element)) {
selectedElements.push(element as NonDeletedExcalidrawElement);
addedElements.add(element.id);
} else {
console.error(
"[NONDELETED][INVARIANT] getSelectedElements skipping deleted selected element which should not be in the selection",
);
}
continue;
}
if (
opts?.includeBoundTextElement &&
isBoundToContainer(element) &&
isNonDeletedElement(element) &&
appState.selectedElementIds[element?.containerId]
) {
selectedElements.push(element);
selectedElements.push(element as NonDeletedExcalidrawElement);
addedElements.add(element.id);
continue;
}
}
if (opts?.includeElementsInFrames) {
const elementsToInclude: ExcalidrawElement[] = [];
const elementsToInclude: NonDeletedExcalidrawElement[] = [];
selectedElements.forEach((element) => {
if (isFrameLikeElement(element)) {
getFrameChildren(elements, element.id).forEach(
(e) => !addedElements.has(e.id) && elementsToInclude.push(e),
(e) =>
!addedElements.has(e.id) &&
elementsToInclude.push(e as NonDeletedExcalidrawElement),
);
}
elementsToInclude.push(element);
@@ -443,12 +216,15 @@ export const getTargetElements = (
elements: ElementsMapOrArray,
appState: Pick<
AppState,
"selectedElementIds" | "editingTextElement" | "newElement"
"selectedElementIds" | "editingTextElement" | "newElement" | "activeTool"
>,
) =>
appState.editingTextElement
? [appState.editingTextElement]
: appState.newElement
: // the drawShape recognition preview in `newElement` is not a real
// in-progress element — targeting it would make the styles panel track
// whatever shape is currently recognized instead of the tool defaults
appState.newElement && appState.activeTool.type !== "autoshape"
? [appState.newElement]
: getSelectedElements(elements, appState, {
includeBoundTextElement: true,
@@ -470,7 +246,7 @@ export const makeNextSelectedElementIds = (
};
const _getLinearElementEditor = (
targetElements: readonly ExcalidrawElement[],
targetElements: readonly NonDeletedExcalidrawElement[],
allElements: readonly NonDeletedExcalidrawElement[],
) => {
const linears = targetElements.filter(isLinearElement);
@@ -490,7 +266,7 @@ const _getLinearElementEditor = (
};
export const getSelectionStateForElements = (
targetElements: readonly ExcalidrawElement[],
targetElements: readonly NonDeletedExcalidrawElement[],
allElements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
) => {
+202 -90
View File
@@ -1,5 +1,6 @@
import { simplify } from "points-on-curve";
import { getStroke } from "perfect-freehand";
import { pointsOnBezierCurves, simplify } from "points-on-curve";
import { getStroke, getStrokePoints } from "perfect-freehand";
import { LaserPointer } from "@excalidraw/laser-pointer";
import {
type GeometricShape,
@@ -15,6 +16,7 @@ import {
pointDistance,
type LocalPoint,
pointRotateRads,
polygonFromPoints,
} from "@excalidraw/math";
import {
ROUGHNESS,
@@ -24,11 +26,12 @@ import {
COLOR_PALETTE,
LINE_POLYGON_POINT_MERGE_DISTANCE,
applyDarkModeFilter,
DEFAULT_STROKE_STREAMLINE,
} from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator";
import type { GlobalPoint } from "@excalidraw/math";
import type { GlobalPoint, Polygon } from "@excalidraw/math";
import type { Mutable } from "@excalidraw/common/utility-types";
@@ -66,7 +69,6 @@ import { shouldTestInside } from "./collision";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
ExcalidrawSelectionElement,
ExcalidrawLinearElement,
ExcalidrawFreeDrawElement,
@@ -218,9 +220,7 @@ export const generateRoughOptions = (
fillWeight: element.strokeWidth / 2,
hachureGap: element.strokeWidth * 4,
roughness: adjustRoughness(element),
stroke: isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
stroke: applyDarkModeFilter(element.strokeColor, isDarkMode),
preserveVertices:
continuousPath || element.roughness < ROUGHNESS.cartoonist,
};
@@ -234,9 +234,7 @@ export const generateRoughOptions = (
options.fillStyle = element.fillStyle;
options.fill = isTransparent(element.backgroundColor)
? undefined
: isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor;
: applyDarkModeFilter(element.backgroundColor, isDarkMode);
if (element.type === "ellipse") {
options.curveFitting = 1;
}
@@ -249,9 +247,7 @@ export const generateRoughOptions = (
options.fill =
element.backgroundColor === "transparent"
? undefined
: isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor;
: applyDarkModeFilter(element.backgroundColor, isDarkMode);
}
return options;
}
@@ -264,7 +260,7 @@ export const generateRoughOptions = (
};
const modifyIframeLikeForRoughOptions = (
element: NonDeletedExcalidrawElement,
element: ExcalidrawElement,
isExporting: boolean,
embedsValidationStatus: EmbedsValidationStatus | null,
) => {
@@ -386,12 +382,11 @@ const getArrowheadShapes = (
return [];
}
const strokeColor = isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
const backgroundFillColor = isDarkMode
? applyDarkModeFilter(canvasBackgroundColor)
: canvasBackgroundColor;
const strokeColor = applyDarkModeFilter(element.strokeColor, isDarkMode);
const backgroundFillColor = applyDarkModeFilter(
canvasBackgroundColor,
isDarkMode,
);
const cardinalityOneOrManyOffset = -0.25;
const cardinalityZeroCircleScale = 0.8;
@@ -581,6 +576,48 @@ const getArrowheadShapes = (
}
};
/** The simplified centerline the freedraw fill is drawn along. */
const getFreedrawFillCurvePoints = (element: ExcalidrawFreeDrawElement) =>
simplify(element.points as Mutable<LocalPoint[]>, 0.75) as [number, number][];
const freedrawFillPolygonCache = new WeakMap<
ExcalidrawFreeDrawElement,
{ version: number; polygon: Polygon<LocalPoint> }
>();
/**
* Returns the flattened fill contour of a freedraw loop in local, unrotated
* coordinates, following the rendered fill rather than the stroke outline.
*/
export const getFreedrawFillPolygon = (element: ExcalidrawFreeDrawElement) => {
const cached = freedrawFillPolygonCache.get(element);
if (cached?.version === element.version) {
return cached.polygon;
}
// Same curve as the rendered fill, without roughness jitter. RoughJS also
// handles inputs simplified to two points.
const ops = new RoughGenerator().curve(getFreedrawFillCurvePoints(element), {
roughness: 0,
disableMultiStroke: true,
}).sets[0].ops;
const bezierPoints: LocalPoint[] = [];
// A single curve pass consists of a move followed by cubic control points.
for (const { data } of ops) {
for (let i = 0; i < data.length; i += 2) {
bezierPoints.push(pointFrom<LocalPoint>(data[i], data[i + 1]));
}
}
// Omit the optional distance argument to avoid simplifying the boundary again.
const polygon = polygonFromPoints(
pointsOnBezierCurves(bezierPoints, 0.5) as LocalPoint[],
);
freedrawFillPolygonCache.set(element, { version: element.version, polygon });
return polygon;
};
export const generateLinearCollisionShape = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
@@ -681,69 +718,44 @@ export const generateLinearCollisionShape = (
});
}
case "freedraw": {
if (element.points.length < 2) {
const outlinePoints = getFreedrawOutlinePoints(element);
if (outlinePoints.length < 2) {
return [];
}
const simplifiedPoints = simplify(
element.points as Mutable<LocalPoint[]>,
0.75,
);
const collisionOutline =
element.strokeOptions?.variability === "constant"
? (simplify(
outlinePoints as Mutable<LocalPoint[]>,
CONSTANT_WIDTH_FREEDRAW.COLLISION_SIMPLIFY_TOLERANCE,
) as [number, number][])
: outlinePoints;
return generator
.curve(simplifiedPoints as [number, number][], options)
.sets[0].ops.slice(0, element.points.length)
.map((op, i) => {
if (i === 0) {
const p = pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
);
if (collisionOutline.length < 2) {
return [];
}
return {
op: "move",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
}
// Close the outline polygon so its boundary never has a gap at the seam.
const [firstX, firstY] = collisionOutline[0];
const [lastX, lastY] = collisionOutline[collisionOutline.length - 1];
const closedOutline =
firstX === lastX && firstY === lastY
? collisionOutline
: [...collisionOutline, collisionOutline[0]];
return {
op: "bcurveTo",
data: [
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
center,
element.angle,
),
]
.map((p) =>
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
)
.flat(),
};
});
return closedOutline.map((point, idx) => {
const p = pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return {
op: idx === 0 ? "move" : "lineTo",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
});
}
}
};
@@ -756,7 +768,7 @@ export const generateLinearCollisionShape = (
* @private
*/
const _generateElementShape = (
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
generator: RoughGenerator,
{
isExporting,
@@ -968,12 +980,8 @@ const _generateElementShape = (
// (1) background fill (rc shape), optional
if (isPathALoop(element.points)) {
// generate rough polygon to fill freedraw shape
const simplifiedPoints = simplify(
element.points as Mutable<LocalPoint[]>,
0.75,
);
shapes.push(
generator.curve(simplifiedPoints as [number, number][], {
generator.curve(getFreedrawFillCurvePoints(element), {
...generateRoughOptions(element, false, isDarkMode),
stroke: "none",
}),
@@ -985,6 +993,9 @@ const _generateElementShape = (
return shapes;
}
// sticky notes are painted directly (canvas + SVG) from
// `getStickyNoteRenderPoints`, never through roughjs
case "stickynote":
case "frame":
case "magicframe":
case "text":
@@ -1079,6 +1090,7 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
): GeometricShape<Point> => {
switch (element.type) {
case "rectangle":
case "stickynote":
case "diamond":
case "frame":
case "magicframe":
@@ -1178,27 +1190,127 @@ const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => {
) as SVGPathString;
};
export const getFreedrawOutlinePoints = (
/**
* Freedraw stroke geometry tuning constants.
*
* These factors are not derived analytically — they were tuned empirically by
* visually comparing rendered strokes until they matched the desired feel.
* Treat them as magic numbers backed by visual verification.
*/
const VARIABLE_WIDTH_FREEDRAW = {
/** Stroke size relative to `strokeWidth` for pressure-sensitive strokes. */
SIZE_FACTOR: 4.25,
THINNING: 0.6,
SMOOTHING: 0.5,
} as const;
const CONSTANT_WIDTH_FREEDRAW = {
/** Stroke size relative to `strokeWidth` for uniform (laser) strokes. */
SIZE_FACTOR: 1.4,
/**
* Max deviation (px) when dropping vertices of the dense laser outline for
* collision. Perfect-freehand outlines are already thinned by `smoothing`.
*/
COLLISION_SIMPLIFY_TOLERANCE: 0.2,
} as const;
const getFreedrawStreamline = (element: ExcalidrawFreeDrawElement) =>
element.strokeOptions?.streamline ?? DEFAULT_STROKE_STREAMLINE;
/**
* Pressure-sensitive (variable width) freedraw outline, rendered with
* perfect-freehand. This is the original Excalidraw freedraw look.
*/
const getVariableWidthFreedrawOutline = (
element: ExcalidrawFreeDrawElement,
) => {
): [number, number][] => {
// If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
? element.points.map(
([x, y], i) => [x, y, element.pressures[i]] as [number, number, number],
)
: [[0, 0, 0.5]];
return getStroke(inputPoints as number[][], {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * 4.25,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
size: element.strokeWidth * VARIABLE_WIDTH_FREEDRAW.SIZE_FACTOR,
thinning: VARIABLE_WIDTH_FREEDRAW.THINNING,
smoothing: VARIABLE_WIDTH_FREEDRAW.SMOOTHING,
streamline: getFreedrawStreamline(element),
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: true,
}) as [number, number][];
};
const createLaserPointer = (element: ExcalidrawFreeDrawElement) =>
new LaserPointer({
size: element.strokeWidth * CONSTANT_WIDTH_FREEDRAW.SIZE_FACTOR,
streamline: getFreedrawStreamline(element),
simplify: 0,
sizeMapping: (details) => Math.max(0.1, details.pressure),
});
/**
* Uniform (constant width) freedraw outline, rendered with the laser-pointer
* geometry. Pressure is pinned to 1 so the stroke keeps a constant width.
*/
const getConstantWidthFreedrawOutline = (
element: ExcalidrawFreeDrawElement,
): [number, number][] => {
const laserPointer = createLaserPointer(element);
element.points.map(([x, y]) => laserPointer.addPoint([x, y, 1]));
return laserPointer
.getStrokeOutline()
.map(([x, y]) => [x, y] as [number, number]);
};
export const getFreedrawOutlinePoints = (
element: ExcalidrawFreeDrawElement,
): [number, number][] => {
// Unknown/absent variability falls back to the original variable rendering.
return element.strokeOptions?.variability === "constant"
? getConstantWidthFreedrawOutline(element)
: getVariableWidthFreedrawOutline(element);
};
/**
* Upper bound on how far a freedraw stroke's ink reaches past its centerline
* points. Both stroke generators scale the stroke size by at most 1 (easing
* or pressure), so the size itself bounds the radius, except for
* perfect-freehand's short strokes: under 3px long, the start cap is drawn
* around the first point from an outline point of the last one (reaching
* sqrt(size² + 3²)), and a single point gets a synthetic neighbor 1px away.
*/
export const getFreedrawMaxStrokeRadius = (
element: ExcalidrawFreeDrawElement,
) =>
element.strokeOptions?.variability === "constant"
? element.strokeWidth * CONSTANT_WIDTH_FREEDRAW.SIZE_FACTOR
: element.strokeWidth * VARIABLE_WIDTH_FREEDRAW.SIZE_FACTOR + 3;
/**
* The streamline-smoothed centerline the freedraw stroke is rendered
* around, in element-local coordinates. Boundary-sensitive consumers (e.g.
* bucket fill) should use this instead of `element.points`: raw input
* points can sit 20px+ apart and the rendered stroke is smoothed between
* them, so raw chords visibly deviate from what's on screen.
*
* Constant-width ("laser geometry") strokes technically smooth via
* `LaserPointer` instead; the perfect-freehand centerline with the same
* `streamline` is a close approximation the stroke width hides.
*/
export const getFreedrawStrokeCenterPoints = (
element: ExcalidrawFreeDrawElement,
): [number, number][] =>
getStrokePoints(element.points as unknown as number[][], {
size: element.strokeWidth * VARIABLE_WIDTH_FREEDRAW.SIZE_FACTOR,
streamline: getFreedrawStreamline(element),
last: true,
}).map((strokePoint) => strokePoint.point as [number, number]);
const med = (A: number[], B: number[]) => {
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
};
+31
View File
@@ -22,6 +22,37 @@ import {
import type { ElementsMap, ExcalidrawElement } from "./types";
export type VerticalResizeAnchor = "top" | "bottom" | "center";
/** Keeps the chosen edge or center fixed when an element's height changes. */
export const getPositionAfterHeightChange = (
element: Pick<ExcalidrawElement, "x" | "y" | "height" | "angle">,
nextHeight: number,
anchor: VerticalResizeAnchor = "top",
) => {
const delta = (element.height - nextHeight) / 2;
if (anchor === "center") {
// Rotation is about the center, so holding it is angle-independent.
return { x: element.x, y: element.y + delta };
}
const sin = Math.sin(element.angle);
const cos = Math.cos(element.angle);
if (anchor === "bottom") {
return {
x: element.x - delta * sin,
y: element.y + delta * (1 + cos),
};
}
return {
x: element.x + delta * sin,
y: element.y + delta * (1 - cos),
};
};
export const INVISIBLY_SMALL_ELEMENT_SIZE = 0.1;
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
+66 -68
View File
@@ -1,59 +1,56 @@
import { arrayToMapWithIndex } from "@excalidraw/common";
import { arrayToMap } from "@excalidraw/common";
import type { ExcalidrawElement } from "./types";
const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
const origElements: ExcalidrawElement[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>();
const orderInnerGroups = (
elements: readonly ExcalidrawElement[],
): ExcalidrawElement[] => {
const firstGroupSig = elements[0]?.groupIds?.join("");
const aGroup: ExcalidrawElement[] = [elements[0]];
const bGroup: ExcalidrawElement[] = [];
for (const element of elements.slice(1)) {
if (element.groupIds?.join("") === firstGroupSig) {
aGroup.push(element);
} else {
bGroup.push(element);
}
}
return bGroup.length ? [...aGroup, ...orderInnerGroups(bGroup)] : aGroup;
const defragmentGroups = (elements: readonly ExcalidrawElement[]) => {
const groupIdAtLevel = (element: ExcalidrawElement, level: number) => {
return element.groupIds[element.groupIds.length - level - 1];
};
const groupHandledElements = new Map<string, true>();
const orderLevel = (
levelElements: readonly ExcalidrawElement[],
level: number,
): ExcalidrawElement[] => {
const buckets = new Map<string, ExcalidrawElement[]>();
// Slots preserve first-occurrence order: a groupId reserves its slot
// the first time one of its members is seen; loose elements occupy
// their own slot. Groups are then expanded (and recursed into) in place.
const slots: (ExcalidrawElement | string)[] = [];
origElements.forEach((element, idx) => {
if (groupHandledElements.has(element.id)) {
return;
}
if (element.groupIds?.length) {
const topGroup = element.groupIds[element.groupIds.length - 1];
const groupElements = origElements.slice(idx).filter((element) => {
const ret = element?.groupIds?.some((id) => id === topGroup);
if (ret) {
groupHandledElements.set(element!.id, true);
}
return ret;
});
for (const elem of orderInnerGroups(groupElements)) {
sortedElements.add(elem);
for (const element of levelElements) {
const groupId = groupIdAtLevel(element, level);
if (groupId === undefined) {
slots.push(element);
continue;
}
} else {
sortedElements.add(element);
let bucket = buckets.get(groupId);
if (!bucket) {
bucket = [];
buckets.set(groupId, bucket);
slots.push(groupId);
}
bucket.push(element);
}
});
return slots.flatMap((slot) =>
typeof slot === "string"
? orderLevel(buckets.get(slot)!, level + 1)
: [slot],
);
};
// `groupIds` is stored innermost-first, so the outermost group is the
// last entry. We recurse from level 0 (outermost) inward.
const sortedElements = orderLevel(elements, 0);
// if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data
if (sortedElements.size !== elements.length) {
console.error("normalizeGroupElementOrder: lost some elements... bailing!");
if (sortedElements.length !== elements.length) {
console.error("defragmentGroups: lost some elements... bailing!");
return elements;
}
return [...sortedElements];
return sortedElements;
};
/**
@@ -65,42 +62,43 @@ const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
* original z-index of container (i.e. it moves bound text elements after
* containers).
*/
const normalizeBoundElementsOrder = (
elements: readonly ExcalidrawElement[],
export const normalizeBoundElementsOrder = <T extends ExcalidrawElement>(
elements: readonly T[],
) => {
const elementsMap = arrayToMapWithIndex(elements);
const elementsMap = arrayToMap(elements);
const origElements: (ExcalidrawElement | null)[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>();
const sortedElements = new Set<T>();
origElements.forEach((element, idx) => {
if (!element) {
return;
for (const element of elements) {
if (sortedElements.has(element)) {
continue;
}
if (element.boundElements?.length) {
sortedElements.add(element);
origElements[idx] = null;
element.boundElements.forEach((boundElement) => {
for (const boundElement of element.boundElements) {
const child = elementsMap.get(boundElement.id);
if (child && boundElement.type === "text") {
sortedElements.add(child[0]);
origElements[child[1]] = null;
sortedElements.add(child);
}
});
} else if (element.type === "text" && element.containerId) {
const parent = elementsMap.get(element.containerId);
if (!parent?.[0].boundElements?.find((x) => x.id === element.id)) {
sortedElements.add(element);
origElements[idx] = null;
// if element has a container and container lists it, skip this element
// as it'll be taken care of by the container
}
} else {
sortedElements.add(element);
origElements[idx] = null;
continue;
}
});
// if element has a container and container lists it, skip this element
// as it'll be taken care of by the container
if (
element.type === "text" &&
element.containerId &&
elementsMap
.get(element.containerId)
?.boundElements?.some((el) => el.id === element.id)
) {
continue;
}
sortedElements.add(element);
}
// if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data
@@ -117,5 +115,5 @@ const normalizeBoundElementsOrder = (
export const normalizeElementOrder = (
elements: readonly ExcalidrawElement[],
) => {
return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
return normalizeBoundElementsOrder(defragmentGroups(elements));
};
+971
View File
@@ -0,0 +1,971 @@
import {
DEFAULT_ELEMENT_PROPS,
DEFAULT_STICKY_NOTE_BG,
MIN_FONT_SIZE,
arrayToMap,
STICKY_NOTE_BODY_INSET_Y,
STICKY_NOTE_FALLBACK_FONT_SIZE,
STICKY_NOTE_FOOTER,
STICKY_NOTE_FONT_STEP,
STICKY_NOTE_MAX_FONT_SIZE,
STICKY_NOTE_MIN_SIZE,
STICKY_NOTE_MIN_FONT_SIZE,
STICKY_NOTE_PADDING,
STICKY_NOTE_SHADOW_OFFSET,
getFontString,
getLineHeight,
isTransparent,
seededRandom,
} from "@excalidraw/common";
import { clamp } from "@excalidraw/math";
import { updateBoundElements } from "./binding";
import { newElementWith } from "./mutateElement";
import { getPositionAfterHeightChange } from "./sizeHelpers";
import { computeBoundTextPosition, getBoundTextElement } from "./textElement";
import { measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping";
import { isStickyNoteElement, isTextElement } from "./typeChecks";
import type { Scene } from "./Scene";
import type { VerticalResizeAnchor } from "./sizeHelpers";
import type { TransformHandleDirection } from "./transformHandles";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawStickyNoteElement,
ExcalidrawTextElement,
ExcalidrawTextElementWithContainer,
NonDeletedExcalidrawElement,
} from "./types";
export type StickyNoteRenderPoint = {
x: number;
y: number;
};
export type StickyNotePathCommand =
| {
type: "move";
point: StickyNoteRenderPoint;
}
| {
type: "line";
point: StickyNoteRenderPoint;
}
| {
type: "quadratic";
control: StickyNoteRenderPoint;
point: StickyNoteRenderPoint;
};
const STICKY_NOTE_RENDER_ROUGHNESS = [0, 1.5, 8] as const;
const STICKY_NOTE_CORNER_RADIUS_RATIO = 0.04;
const STICKY_NOTE_MAX_CORNER_RADIUS = 16;
export const normalizeStickyNoteStrokeColor = (
strokeColor: string | null | undefined,
) => {
return !strokeColor || isTransparent(strokeColor)
? DEFAULT_ELEMENT_PROPS.strokeColor
: strokeColor;
};
export const normalizeStickyNoteBackgroundColor = (
backgroundColor: string | null | undefined,
) => {
return !backgroundColor || isTransparent(backgroundColor)
? DEFAULT_STICKY_NOTE_BG
: backgroundColor;
};
/**
* The update that applies a picked color to an element under the sticky
* note policy: a note is always filled and its label — the visible text,
* which the note's own `strokeColor` seeds — never goes transparent. Every
* color surface (actions, both eyedroppers, paste styles, bind) routes
* through this instead of re-deriving the rule.
*/
/**
* The element a color pick on `element` lands on. A note's label has no fill
* of its own, so a background pick on the label — the styles panel while
* editing it — goes to the note; everything else colors itself.
*/
export const getColorTargetElement = (
element: ExcalidrawElement,
property: "strokeColor" | "backgroundColor",
elementsMap: ElementsMap,
): ExcalidrawElement => {
if (
property === "backgroundColor" &&
isTextElement(element) &&
isStickyNoteBoundText(element, elementsMap)
) {
return elementsMap.get(element.containerId!) ?? element;
}
return element;
};
export const getColorUpdate = (
element: ExcalidrawElement,
property: "strokeColor" | "backgroundColor",
color: string,
elementsMap: ElementsMap,
): { strokeColor: string } | { backgroundColor: string } => {
if (isStickyNoteElement(element)) {
return property === "backgroundColor"
? { backgroundColor: normalizeStickyNoteBackgroundColor(color) }
: { strokeColor: normalizeStickyNoteStrokeColor(color) };
}
if (
property === "strokeColor" &&
isTextElement(element) &&
isStickyNoteBoundText(element, elementsMap)
) {
const container = elementsMap.get(element.containerId!);
return {
strokeColor: isTransparent(color)
? normalizeStickyNoteStrokeColor(container?.strokeColor)
: color,
};
}
return property === "backgroundColor"
? { backgroundColor: color }
: { strokeColor: color };
};
/**
* A note's ink is one color: the container's `strokeColor` — the seed for a
* new label and what the creation-date footer paints with — and its label's.
* Run after a property write to copy the side that changed onto the other;
* when both changed, or neither (data that drifted), the label wins: it is
* the text the user styled. A transparent label always takes the note's
* color. Returns the same array when nothing needs to change.
*/
export const syncStickyNoteInk = <T extends ExcalidrawElement>(
elements: readonly T[],
prevElementsMap: ElementsMap,
): readonly T[] => {
const elementsMap = arrayToMap(elements);
const inkById = new Map<string, string>();
for (const container of elements) {
if (!isStickyNoteElement(container) || container.isDeleted) {
continue;
}
const label = getBoundTextElement(container, elementsMap);
if (!label || label.strokeColor === container.strokeColor) {
continue;
}
const containerChanged =
prevElementsMap.get(container.id)?.strokeColor !== container.strokeColor;
const labelChanged =
prevElementsMap.get(label.id)?.strokeColor !== label.strokeColor;
if (
isTransparent(label.strokeColor) ||
(containerChanged && !labelChanged)
) {
inkById.set(
label.id,
normalizeStickyNoteStrokeColor(container.strokeColor),
);
} else {
inkById.set(
container.id,
normalizeStickyNoteStrokeColor(label.strokeColor),
);
}
}
if (!inkById.size) {
return elements;
}
return elements.map((element) => {
const strokeColor = inkById.get(element.id);
return strokeColor === undefined
? element
: (newElementWith(element as ExcalidrawElement, { strokeColor }) as T);
});
};
const jitter = (random: () => number, amount: number) =>
(random() * 2 - 1) * amount;
const pointAtDistance = (
from: StickyNoteRenderPoint,
to: StickyNoteRenderPoint,
distance: number,
) => {
const dx = to.x - from.x;
const dy = to.y - from.y;
const length = Math.hypot(dx, dy);
if (!length) {
return from;
}
const ratio = Math.min(distance / length, 1);
return {
x: from.x + dx * ratio,
y: from.y + dy * ratio,
};
};
export const getStickyNoteCornerRadius = (
element: ExcalidrawStickyNoteElement,
) => {
if (!element.roundness) {
return 0;
}
return Math.min(
Math.min(element.width, element.height) * STICKY_NOTE_CORNER_RADIUS_RATIO,
STICKY_NOTE_MAX_CORNER_RADIUS,
);
};
export const getStickyNoteRenderPoints = (
element: ExcalidrawStickyNoteElement,
{
offsetX = 0,
offsetY = 0,
seedOffset = 0,
}: {
offsetX?: number;
offsetY?: number;
seedOffset?: number;
} = {},
): StickyNoteRenderPoint[] => {
const roughness = Math.max(0, Math.min(2, Math.round(element.roughness)));
const amount = Math.min(
STICKY_NOTE_RENDER_ROUGHNESS[roughness],
Math.min(element.width, element.height) * 0.012,
);
if (!amount) {
return [
{ x: offsetX, y: offsetY },
{ x: offsetX + element.width, y: offsetY },
{ x: offsetX + element.width, y: offsetY + element.height },
{ x: offsetX, y: offsetY + element.height },
];
}
const random = seededRandom(element.seed + seedOffset);
return [
{
x: offsetX + jitter(random, amount),
y: offsetY + jitter(random, amount),
},
{
x: offsetX + element.width + jitter(random, amount),
y: offsetY + jitter(random, amount),
},
{
x: offsetX + element.width + jitter(random, amount),
y: offsetY + element.height + jitter(random, amount),
},
{
x: offsetX + jitter(random, amount),
y: offsetY + element.height + jitter(random, amount),
},
];
};
export const getStickyNotePathCommands = (
element: ExcalidrawStickyNoteElement,
{ shadow = false }: { shadow?: boolean } = {},
): StickyNotePathCommand[] => {
const points = getStickyNoteRenderPoints(
element,
shadow
? {
offsetX: STICKY_NOTE_SHADOW_OFFSET,
offsetY: STICKY_NOTE_SHADOW_OFFSET,
seedOffset: 1,
}
: undefined,
);
const radius = getStickyNoteCornerRadius(element);
// The note's seed keeps the same corner for the paper and shadow through
// redraws and resizes.
const liftedCorner =
element.roughness === 2
? Math.floor(seededRandom(element.seed)() * points.length)
: -1;
if (!radius && liftedCorner === -1) {
return [
{ type: "move", point: points[0] },
...points.slice(1).map(
(point): StickyNotePathCommand => ({
type: "line",
point,
}),
),
];
}
const corners = points.map((point, index): StickyNotePathCommand[] => {
const prev = points[(index + points.length - 1) % points.length];
const next = points[(index + 1) % points.length];
const cornerRadius = Math.min(
radius,
Math.hypot(point.x - prev.x, point.y - prev.y) / 2,
Math.hypot(point.x - next.x, point.y - next.y) / 2,
);
if (index === liftedCorner) {
const size = Math.min(element.width, element.height);
const reach = Math.min(size * 0.18, 40);
// The shadow follows the paper inward at half the bend, keeping its
// usual down-right offset from the light at the top left.
const lift = Math.min(size * 0.02, 5) * (shadow ? 0.5 : 1);
// The bend joins the straight edges before the corner, keeping it local.
const tip = {
x: point.x + (index === 1 || index === 2 ? -lift : lift),
y: point.y + (index >= 2 ? -lift : lift),
};
const start = pointAtDistance(point, prev, reach);
const end = pointAtDistance(point, next, reach);
return [
{ type: "line", point: start },
{
type: "quadratic",
control: pointAtDistance(point, prev, reach / 2),
point: pointAtDistance(tip, start, cornerRadius),
},
{
type: "quadratic",
control: tip,
point: pointAtDistance(tip, end, cornerRadius),
},
{
type: "quadratic",
control: pointAtDistance(point, next, reach / 2),
point: end,
},
];
}
return [
{ type: "line", point: pointAtDistance(point, prev, cornerRadius) },
{
type: "quadratic",
control: point,
point: pointAtDistance(point, next, cornerRadius),
},
];
});
const commands: StickyNotePathCommand[] = [
{ type: "move", point: corners[0][corners[0].length - 1].point },
];
for (let index = 1; index <= corners.length; index++) {
commands.push(...corners[index % corners.length]);
}
return commands;
};
// -----------------------------------------------------------------------------
// font size — `baseFontSize` is the size the user picked, `fontSize` the fitted one
// -----------------------------------------------------------------------------
// clamping the ceiling: the fit steps down from it in `STICKY_NOTE_FONT_STEP`
// increments, and for values >= ~2^56 (1e20 - 2 === 1e20) that would never
// progress and hang the editor
export const normalizeStickyNoteFontSize = (fontSize: number) => {
if (!Number.isFinite(fontSize)) {
return STICKY_NOTE_FALLBACK_FONT_SIZE;
}
return Math.min(STICKY_NOTE_MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, fontSize));
};
/** whether the text element is the label of a sticky note */
export const isStickyNoteBoundText = (
textElement: ExcalidrawTextElement,
elementsMap: ElementsMap,
) => {
return (
!!textElement.containerId &&
isStickyNoteElement(elementsMap.get(textElement.containerId))
);
};
/**
* The font size the user picked: the ceiling the auto-fit shrinks from for a
* sticky note label, plain `fontSize` for any other text.
*
* Container-aware on purpose — generic binding repair (duplication, history)
* rewrites `containerId` without touching the ceiling, so a numeric
* `baseFontSize` alone does not prove the text is still sticky-bound.
*/
export const getBaseFontSize = (
textElement: ExcalidrawTextElement,
elementsMap: ElementsMap,
) => {
return isStickyNoteBoundText(textElement, elementsMap)
? textElement.baseFontSize ?? textElement.fontSize
: textElement.fontSize;
};
/** the update that applies a user-picked font size (see `getBaseFontSize`) */
export const getBaseFontSizeUpdate = (
textElement: ExcalidrawTextElement,
fontSize: number,
elementsMap: ElementsMap,
): { fontSize: number } | { baseFontSize: number } => {
return isStickyNoteBoundText(textElement, elementsMap)
? { baseFontSize: normalizeStickyNoteFontSize(fontSize) }
: { fontSize };
};
const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
/**
* The creation-date label: absolute, so painting needs no clock and exports
* don't go stale, and short ("7 Sep") while the year is the current one.
* `null` when the note has no usable timestamp (files restored without one).
* Fixed English in the viewer's local time zone — the element package has no
* locale, and the footer is chosen by width bucket rather than measured.
*/
export const getStickyNoteDateLabel = (
created: ExcalidrawStickyNoteElement["created"],
{ short = false, now = Date.now() }: { short?: boolean; now?: number } = {},
): string | null => {
if (created === null || !Number.isFinite(created)) {
return null;
}
const date = new Date(created);
if (Number.isNaN(date.getTime())) {
return null;
}
const label = `${date.getDate()} ${MONTHS[date.getMonth()]}`;
const year = date.getFullYear();
return short || year === new Date(now).getFullYear()
? label
: `${label} ${year}`;
};
/**
* What the footer paints and where, in note-local coordinates — shared by the
* canvas and SVG renderers, which take the font and opacity from
* `STICKY_NOTE_FOOTER`. `null` for the 0×0 creation draft and anything under
* the data floor, where the band would overlap the top padding.
*/
export const getStickyNoteFooter = (
element: Pick<ExcalidrawStickyNoteElement, "created" | "width" | "height">,
now = Date.now(),
) => {
if (
element.width < STICKY_NOTE_MIN_SIZE ||
element.height < STICKY_NOTE_MIN_SIZE
) {
return null;
}
const text = getStickyNoteDateLabel(element.created, {
short:
element.width - STICKY_NOTE_PADDING * 2 <
STICKY_NOTE_FOOTER.minBodyWidthForYear,
now,
});
if (!text) {
return null;
}
return {
text,
x: element.width - STICKY_NOTE_PADDING,
y: element.height - STICKY_NOTE_FOOTER.baselineFromBottom,
};
};
/**
* The smallest note the UI lets a user create or resize to: one line at the
* label's font ceiling plus padding (and, vertically, the footer), never below
* `STICKY_NOTE_MIN_SIZE`. Without the font term a fresh note would grow on
* the very first keystroke. Data-level passes (restore, action post-passes)
* only enforce the constant floor — the layout grows a note as needed.
*/
export const getStickyNoteMinSize = ({
fontSize,
fontFamily,
}: Pick<ExcalidrawTextElement, "fontSize" | "fontFamily">) => {
const lineHeightPx = Math.ceil(
normalizeStickyNoteFontSize(fontSize) * getLineHeight(fontFamily),
);
return {
width: Math.max(
STICKY_NOTE_MIN_SIZE,
lineHeightPx + STICKY_NOTE_PADDING * 2,
),
height: Math.max(
STICKY_NOTE_MIN_SIZE,
lineHeightPx + STICKY_NOTE_BODY_INSET_Y,
),
};
};
// -----------------------------------------------------------------------------
// layout — one pure calculation, two ways of applying it
// -----------------------------------------------------------------------------
export type StickyNoteLayoutAnchor = VerticalResizeAnchor;
export type StickyNoteLayoutOpts = {
/** unwrapped text to lay out; defaults to the label's `originalText` */
originalText?: string;
/**
* absolute base-height intent — the gesture-start value to preserve, or the
* height a resize asked for. Omitted = keep the note's live `baseHeight`
* (typing, style changes, restore).
*/
baseHeight?: number;
/**
* absolute font-ceiling intent (a proportional resize passes
* `gesture-start ceiling × scale`). Omitted = keep the label's live ceiling.
* Never a multiplier on the live value — that compounds across pointer-moves.
*/
baseFontSize?: number;
/** the edge that stays put when the content correction changes the height */
anchor?: StickyNoteLayoutAnchor;
};
export type StickyNoteLayout = {
container: Pick<
ExcalidrawStickyNoteElement,
"x" | "y" | "width" | "height" | "baseHeight"
>;
text: Pick<
ExcalidrawTextElement,
| "text"
| "fontSize"
| "baseFontSize"
| "width"
| "height"
| "x"
| "y"
| "angle"
> | null;
};
type FontFit = {
text: string;
fontSize: number;
width: number;
height: number;
};
// `computeBoundTextPosition` only consults the map for arrow containers
const NO_ELEMENTS: ElementsMap = new Map();
const getStickyNoteBaseWidth = (container: ExcalidrawStickyNoteElement) => {
return Math.max(container.width, STICKY_NOTE_MIN_SIZE);
};
/**
* Largest font size on the grid `{ceiling − k·STEP} ∪ {min}` whose wrapped
* text fits the note; `min` when nothing fits (the note then grows). The grid
* is anchored at the ceiling so the result never depends on earlier edits.
* Warm-started from the previously fitted size: the common keystroke costs one
* or two wrap+measure passes, a cold search at most log2(steps) + 2.
*/
const fitStickyNoteFont = (
fit: (fontSize: number) => FontFit,
{
baseFontSize,
fontSizeMin,
maxWidth,
maxHeight,
warmStart,
}: {
baseFontSize: number;
fontSizeMin: number;
maxWidth: number;
maxHeight: number;
warmStart: number;
},
): FontFit => {
const steps = Math.max(
0,
Math.ceil((baseFontSize - fontSizeMin) / STICKY_NOTE_FONT_STEP),
);
const sizeAt = (index: number) =>
index >= steps ? fontSizeMin : baseFontSize - index * STICKY_NOTE_FONT_STEP;
const fits = new Map<number, FontFit>();
const at = (index: number) => {
let result = fits.get(index);
if (!result) {
result = fit(sizeAt(index));
fits.set(index, result);
}
return result;
};
const doesFit = (index: number) => {
const result = at(index);
return result.width <= maxWidth && result.height <= maxHeight;
};
if (steps === 0) {
return at(0);
}
// the previous fitted size, snapped onto the grid and clamped into the
// current interval (a lowered ceiling must not keep the old larger size)
const warm = clamp(
Math.round((baseFontSize - warmStart) / STICKY_NOTE_FONT_STEP),
0,
steps,
);
// binary search for the smallest index (largest font) that fits
let lo: number;
let hi: number;
if (doesFit(warm)) {
if (warm === 0 || !doesFit(warm - 1)) {
return at(warm);
}
lo = 0;
hi = warm - 1;
} else {
if (warm === steps) {
return at(steps);
}
lo = warm + 1;
hi = steps;
}
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (doesFit(mid)) {
hi = mid;
} else {
lo = mid + 1;
}
}
return at(lo);
};
/**
* The single source of truth for a sticky note's geometry: wraps the label at
* the note's width, fits the font under the ceiling, grows the note past
* `baseHeight` only when the text still overflows at the minimum size, and
* positions the label inside. Pure — returns the updates for both elements.
*/
export const getStickyNoteLayout = (
container: ExcalidrawStickyNoteElement,
textElement: ExcalidrawTextElement | null,
opts: StickyNoteLayoutOpts = {},
): StickyNoteLayout => {
const baseWidth = getStickyNoteBaseWidth(container);
const baseHeight = Math.max(
opts.baseHeight ?? (container.baseHeight || container.height),
STICKY_NOTE_MIN_SIZE,
);
const anchor = opts.anchor ?? "top";
if (!textElement) {
// an empty note sits at its base height
return {
container: {
...getPositionAfterHeightChange(container, baseHeight, anchor),
width: baseWidth,
height: baseHeight,
baseHeight,
},
text: null,
};
}
const originalText = opts.originalText ?? textElement.originalText;
const baseFontSize = normalizeStickyNoteFontSize(
opts.baseFontSize ?? textElement.baseFontSize ?? textElement.fontSize,
);
const fontSizeMin = Math.min(STICKY_NOTE_MIN_FONT_SIZE, baseFontSize);
const maxWidth = Math.max(baseWidth - STICKY_NOTE_PADDING * 2, 1);
const maxHeight = Math.max(baseHeight - STICKY_NOTE_BODY_INSET_Y, 0);
const { fontFamily, lineHeight } = textElement;
const fit = (fontSize: number): FontFit => {
const font = getFontString({ fontFamily, fontSize });
const text = wrapText(originalText, font, maxWidth);
return { text, fontSize, ...measureText(text, font, lineHeight) };
};
const isBlank = !originalText.trim();
const fitted: FontFit = isBlank
? {
text: "",
fontSize: baseFontSize,
...measureText(
"",
getFontString({ fontFamily, fontSize: baseFontSize }),
lineHeight,
),
}
: fitStickyNoteFont(fit, {
baseFontSize,
fontSizeMin,
maxWidth,
maxHeight,
warmStart: textElement.fontSize,
});
const height = isBlank
? baseHeight
: Math.max(baseHeight, fitted.height + STICKY_NOTE_BODY_INSET_Y);
const nextContainer = {
...getPositionAfterHeightChange(container, height, anchor),
width: baseWidth,
height,
baseHeight,
};
const { x, y } = computeBoundTextPosition(
{ ...container, ...nextContainer },
{
...textElement,
text: fitted.text,
fontSize: fitted.fontSize,
width: fitted.width,
height: fitted.height,
} as ExcalidrawTextElementWithContainer,
NO_ELEMENTS,
);
return {
container: nextContainer,
text: {
text: fitted.text,
fontSize: fitted.fontSize,
baseFontSize,
width: fitted.width,
height: fitted.height,
x,
y,
angle: container.angle,
},
};
};
/**
* Resize intents for a sticky note — the §3.3 matrix of the plan. Every value
* is absolute and derived from the gesture-start snapshot on each pointer-move,
* so releasing Shift (or Alt) mid-gesture restores the original base height
* and ceiling instead of keeping values a previous move scaled.
*
* - width-only gestures (free E/W, Stats W) preserve the base height
* - height-changing gestures use the requested height as the new base
* - proportional gestures (Shift on any handle, aspect-locked multi-select,
* Stats group) additionally scale the font ceiling with the note
* - flips preserve everything
* - the content correction anchors at the edge the gesture holds still
*/
export const getStickyNoteResizeIntent = (
/** the note with the requested geometry already applied */
container: ExcalidrawStickyNoteElement,
originalElementsMap: ElementsMap,
handleDirection: TransformHandleDirection,
{
proportional,
fromCenter,
flip = false,
}: { proportional: boolean; fromCenter: boolean; flip?: boolean },
): Pick<StickyNoteLayoutOpts, "baseHeight" | "baseFontSize" | "anchor"> => {
const origContainer = originalElementsMap.get(container.id);
const origSticky = isStickyNoteElement(origContainer)
? origContainer
: container;
const origText = getBoundTextElement(origSticky, originalElementsMap);
const ceiling = origText
? origText.baseFontSize ?? origText.fontSize
: undefined;
if (flip) {
return { baseHeight: origSticky.baseHeight, baseFontSize: ceiling };
}
const changesHeight =
proportional ||
handleDirection.includes("n") ||
handleDirection.includes("s");
const scale =
proportional && origSticky.width ? container.width / origSticky.width : 1;
const isSideHandle = handleDirection.length === 1;
return {
baseHeight: changesHeight ? container.height : origSticky.baseHeight,
baseFontSize: ceiling === undefined ? undefined : ceiling * scale,
anchor: fromCenter
? "center"
: handleDirection.includes("n")
? "bottom"
: proportional &&
isSideHandle &&
(handleDirection === "e" || handleDirection === "w")
? // Shift+E/W holds the opposite side's midpoint, i.e. the vertical center
"center"
: "top",
};
};
/**
* Applies `getStickyNoteLayout` to a live scene, then runs the bound-arrow
* pass unless the caller already owns one (`bindings: false`) or needs to
* forward its context (`bindings: { simultaneouslyUpdated }` — arrows resized
* in the same gesture must not be moved by the pass, `updateBoundElements`
* skips them only when told).
*/
export const updateStickyNoteLayout = (
container: ExcalidrawStickyNoteElement,
scene: Scene,
{
text,
bindings,
...layoutOpts
}: StickyNoteLayoutOpts & {
/** the label to lay out when it is an uncommitted clone (font actions clone before install) */
text?: ExcalidrawTextElement | null;
bindings?:
| { simultaneouslyUpdated?: readonly NonDeletedExcalidrawElement[] }
| false;
} = {},
): StickyNoteLayout => {
const elementsMap = scene.getNonDeletedElementsMap();
const textElement =
text === undefined ? getBoundTextElement(container, elementsMap) : text;
const layout = getStickyNoteLayout(container, textElement, layoutOpts);
scene.mutateElement(container, layout.container);
if (textElement && layout.text) {
scene.mutateElement(textElement, layout.text);
}
if (bindings !== false && !container.isDeleted) {
updateBoundElements(
container as NonDeletedExcalidrawElement,
scene,
bindings || undefined,
);
}
return layout;
};
const STICKY_NOTE_LAYOUT_INPUTS = {
container: ["x", "y", "width", "baseHeight", "angle"],
text: [
"originalText",
"baseFontSize",
"fontFamily",
"lineHeight",
"textAlign",
"verticalAlign",
],
} as const;
const hasStickyNoteLayoutInputChanged = (
container: ExcalidrawStickyNoteElement,
textElement: ExcalidrawTextElement | null,
prevElementsMap: ElementsMap,
) => {
const prevContainer = prevElementsMap.get(container.id);
const prevText = textElement ? prevElementsMap.get(textElement.id) : null;
if (
!prevContainer ||
(textElement && !prevText) ||
(!textElement &&
prevContainer.boundElements?.some(({ type }) => type === "text"))
) {
return true;
}
return (
STICKY_NOTE_LAYOUT_INPUTS.container.some(
(key) => (prevContainer as any)[key] !== container[key],
) ||
(!!textElement &&
STICKY_NOTE_LAYOUT_INPUTS.text.some(
(key) => (prevText as any)[key] !== textElement[key],
))
);
};
/**
* Applies `getStickyNoteLayout` inside an immutable elements array (property
* actions, paste styles, restore): the notes among `affectedIds` — or the
* notes whose labels are — get both halves merged into their clones. Elements
* that need no change keep their identity and version; when `prevElementsMap`
* is given, notes whose layout inputs did not change skip the fit entirely.
* Bound arrows are the caller's job once the result is installed.
*/
export const relayoutStickyNotes = <T extends ExcalidrawElement>(
elements: readonly T[],
affectedIds: ReadonlySet<ExcalidrawElement["id"]>,
opts?: { prevElementsMap?: ElementsMap },
): readonly T[] => {
if (!affectedIds.size) {
return elements;
}
const elementsMap = arrayToMap(elements);
const containerIds = new Set<ExcalidrawElement["id"]>();
for (const id of affectedIds) {
const element = elementsMap.get(id);
if (!element || element.isDeleted) {
continue;
}
if (isStickyNoteElement(element)) {
containerIds.add(element.id);
} else if (isTextElement(element) && element.containerId) {
const container = elementsMap.get(element.containerId);
if (container && !container.isDeleted && isStickyNoteElement(container)) {
containerIds.add(container.id);
}
}
}
if (!containerIds.size) {
return elements;
}
const replacements = new Map<ExcalidrawElement["id"], T>();
for (const id of containerIds) {
const container = elementsMap.get(id) as ExcalidrawStickyNoteElement;
const textElement = getBoundTextElement(container, elementsMap);
if (
opts?.prevElementsMap &&
!hasStickyNoteLayoutInputChanged(
container,
textElement,
opts.prevElementsMap,
)
) {
continue;
}
const layout = getStickyNoteLayout(container, textElement);
const nextContainer = newElementWith(container, layout.container);
if (nextContainer !== container) {
replacements.set(id, nextContainer as unknown as T);
}
if (textElement && layout.text) {
const nextText = newElementWith(textElement, layout.text);
if (nextText !== textElement) {
replacements.set(textElement.id, nextText as unknown as T);
}
}
}
if (!replacements.size) {
return elements;
}
return elements.map((element) => replacements.get(element.id) ?? element);
};
+142 -57
View File
@@ -3,6 +3,8 @@ import {
ARROW_LABEL_WIDTH_FRACTION,
BOUND_TEXT_PADDING,
DEFAULT_FONT_SIZE,
STICKY_NOTE_BODY_INSET_Y,
STICKY_NOTE_PADDING,
TEXT_ALIGN,
VERTICAL_ALIGN,
getFontString,
@@ -12,8 +14,6 @@ import {
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { ExtractSetType } from "@excalidraw/common/utility-types";
import {
@@ -21,15 +21,20 @@ import {
updateOriginalContainerCache,
} from "./containerCache";
import { LinearElementEditor } from "./linearElementEditor";
import { getPositionAfterHeightChange } from "./sizeHelpers";
import { updateStickyNoteLayout } from "./stickyNote";
import { measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping";
import {
isBoundToContainer,
isArrowElement,
isStickyNoteElement,
isTextElement,
} from "./typeChecks";
import { isNonDeletedElement } from ".";
import type { Scene } from "./Scene";
import type { MaybeTransformHandleType } from "./transformHandles";
@@ -40,7 +45,7 @@ import type {
ExcalidrawTextContainer,
ExcalidrawTextElement,
ExcalidrawTextElementWithContainer,
NonDeletedExcalidrawElement,
NonDeleted,
} from "./types";
export const redrawTextBoundingBox = (
@@ -50,6 +55,14 @@ export const redrawTextBoundingBox = (
) => {
const elementsMap = scene.getNonDeletedElementsMap();
if (container && isStickyNoteElement(container)) {
// the sticky fit owns both halves (label + note geometry). `textElement`
// may be an uncommitted clone (font actions clone before install), so it
// is passed explicitly instead of looked up in the scene
updateStickyNoteLayout(container, scene, { text: textElement });
return;
}
let maxWidth = undefined;
if (!isProdEnv()) {
@@ -140,11 +153,19 @@ export const redrawTextBoundingBox = (
};
export const handleBindTextResize = (
container: NonDeletedExcalidrawElement,
container: ExcalidrawElement,
scene: Scene,
transformHandleType: MaybeTransformHandleType,
shouldMaintainAspectRatio = false,
shouldResizeFromCenter = false,
flipByY = false,
) => {
if (isStickyNoteElement(container)) {
// resize callers pass their intents to `updateStickyNoteLayout` directly
// and own the bound-arrow pass; this is the fallback for generic callers
updateStickyNoteLayout(container, scene, { bindings: false });
return;
}
const elementsMap = scene.getNonDeletedElementsMap();
const boundTextElementId = getBoundTextElementId(container);
if (!boundTextElementId) {
@@ -189,18 +210,24 @@ export const handleBindTextResize = (
container.type,
);
const diff = containerHeight - container.height;
// fix the y coord when resizing from ne/nw/n
const updatedY =
!isArrowElement(container) &&
(transformHandleType === "ne" ||
transformHandleType === "nw" ||
transformHandleType === "n")
? container.y - diff
: container.y;
// Crossing the opposite edge swaps the anchor for text-driven growth.
const shouldResizeFromTop =
(transformHandleType === "n" ||
transformHandleType === "ne" ||
transformHandleType === "nw") !== flipByY;
scene.mutateElement(container, {
height: containerHeight,
y: updatedY,
...(!isArrowElement(container) &&
getPositionAfterHeightChange(
container,
containerHeight,
shouldResizeFromCenter
? "center"
: shouldResizeFromTop
? "bottom"
: "top",
)),
});
}
@@ -241,6 +268,18 @@ export const computeBoundTextPosition = (
y = containerCoords.y;
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
y = containerCoords.y + (maxContainerHeight - boundTextElement.height);
} else if (isStickyNoteElement(container)) {
// a note's label body ends above the creation-date footer, but a label
// centered in that body sits visibly high — center it in the whole
// padded note while it stays clear of the footer, and only push it up
// against the body's bottom once it would overlap
const paddedHeight = container.height - STICKY_NOTE_PADDING * 2;
y =
containerCoords.y +
Math.min(
(paddedHeight - boundTextElement.height) / 2,
maxContainerHeight - boundTextElement.height,
);
} else {
y =
containerCoords.y +
@@ -257,10 +296,17 @@ export const computeBoundTextPosition = (
const angle = (container.angle ?? 0) as Radians;
if (angle !== 0) {
const contentCenter = pointFrom(
containerCoords.x + maxContainerWidth / 2,
containerCoords.y + maxContainerHeight / 2,
);
// A sticky's footer makes its body asymmetric. The body still rotates
// about the note's center, rather than about its own (higher) center.
const contentCenter = isStickyNoteElement(container)
? pointFrom(
container.x + container.width / 2,
container.y + container.height / 2,
)
: pointFrom(
containerCoords.x + maxContainerWidth / 2,
containerCoords.y + maxContainerHeight / 2,
);
const textCenter = pointFrom(
x + boundTextElement.width / 2,
y + boundTextElement.height / 2,
@@ -286,36 +332,50 @@ export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
export const getBoundTextElement = (
element: ExcalidrawElement | null,
elementsMap: ElementsMap,
) => {
): NonDeleted<ExcalidrawTextElementWithContainer> | null => {
if (!element) {
return null;
}
const boundTextElementId = getBoundTextElementId(element);
if (boundTextElementId) {
return (elementsMap.get(boundTextElementId) ||
null) as ExcalidrawTextElementWithContainer | null;
const boundTextElement = (elementsMap.get(boundTextElementId) ||
null) as NonDeleted<ExcalidrawTextElementWithContainer> | null;
// SAFETY: This should never happen, but log it just in case
if (boundTextElement && !isNonDeletedElement(boundTextElement)) {
console.error(
"[NONDELETED][INVARIANT] Bound text element `isDeleted: true` which is not expected.",
);
}
return boundTextElement;
}
return null;
};
export const getContainerElement = (
element: ExcalidrawTextElement | null,
export const getContainerElement = <
T extends ExcalidrawTextElement,
R extends ExcalidrawTextContainer,
>(
element: T | null,
elementsMap: ElementsMap,
): ExcalidrawTextContainer | null => {
): R | null => {
if (!element) {
return null;
}
if (element.containerId) {
return (elementsMap.get(element.containerId) ||
null) as ExcalidrawTextContainer | null;
return (elementsMap.get(element.containerId) || null) as R | null;
}
return null;
};
/**
* The point a text bound to this container centers on — and, for arrows, the
* point the text tool snaps a new label to.
*/
export const getContainerCenter = (
container: ExcalidrawElement,
appState: AppState,
elementsMap: ElementsMap,
) => {
if (!isArrowElement(container)) {
@@ -324,38 +384,21 @@ export const getContainerCenter = (
y: container.y + container.height / 2,
};
}
const points = LinearElementEditor.getPointsGlobalCoordinates(
const center = LinearElementEditor.getBoundTextElementCenter(
container,
elementsMap,
);
if (points.length % 2 === 1) {
const index = Math.floor(container.points.length / 2);
const midPoint = LinearElementEditor.getPointGlobalCoordinates(
container,
container.points[index],
elementsMap,
);
return { x: midPoint[0], y: midPoint[1] };
}
const index = container.points.length / 2 - 1;
let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints(
container,
elementsMap,
appState,
)[index];
if (!midSegmentMidpoint) {
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
container,
index + 1,
elementsMap,
);
}
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
return { x: center[0], y: center[1] };
};
export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
let offsetX = BOUND_TEXT_PADDING;
let offsetY = BOUND_TEXT_PADDING;
export const getContainerCoords = (container: ExcalidrawElement) => {
const padding = isStickyNoteElement(container)
? STICKY_NOTE_PADDING
: BOUND_TEXT_PADDING;
let offsetX = padding;
let offsetY = padding;
if (container.type === "ellipse") {
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
@@ -400,8 +443,37 @@ export const getBoundTextElementPosition = (
}
};
/**
* Returns the text element with its accurate coords. An arrow label's
* position is derived from the arrow at render time rather than kept in
* sync on the element itself (see getBoundTextElementPosition), so
* consumers reading a label's coords directly must go through this helper.
*/
export const getTextElementWithAccuratePosition = <
T extends ExcalidrawTextElement,
>(
textElement: T,
elementsMap: ElementsMap,
): T => {
if (!textElement.containerId) {
return textElement;
}
const container = elementsMap.get(textElement.containerId);
if (!container || !isArrowElement(container)) {
return textElement;
}
return {
...textElement,
...LinearElementEditor.getBoundTextElementPosition(
container,
textElement as ExcalidrawTextElementWithContainer,
elementsMap,
),
};
};
export const shouldAllowVerticalAlign = (
selectedElements: NonDeletedExcalidrawElement[],
selectedElements: readonly ExcalidrawElement[],
elementsMap: ElementsMap,
) => {
return selectedElements.some((element) => {
@@ -417,7 +489,7 @@ export const shouldAllowVerticalAlign = (
};
export const suppportsHorizontalAlign = (
selectedElements: NonDeletedExcalidrawElement[],
selectedElements: readonly ExcalidrawElement[],
elementsMap: ElementsMap,
) => {
return selectedElements.some((element) => {
@@ -435,6 +507,7 @@ export const suppportsHorizontalAlign = (
const VALID_CONTAINER_TYPES = new Set([
"rectangle",
"stickynote",
"ellipse",
"diamond",
"arrow",
@@ -486,7 +559,13 @@ export const getBoundTextMaxWidth = (
// Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
return Math.round(width / 2) - BOUND_TEXT_PADDING * 2;
}
return width - BOUND_TEXT_PADDING * 2;
return (
width -
(isStickyNoteElement(container)
? STICKY_NOTE_PADDING
: BOUND_TEXT_PADDING) *
2
);
};
export const getBoundTextMaxHeight = (
@@ -494,6 +573,10 @@ export const getBoundTextMaxHeight = (
boundTextElement: ExcalidrawTextElementWithContainer,
) => {
const { height } = container;
if (isStickyNoteElement(container)) {
// the label body ends above the creation-date footer
return Math.max(0, height - STICKY_NOTE_BODY_INSET_Y);
}
if (isArrowElement(container)) {
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
if (containerHeight <= 0) {
@@ -530,3 +613,5 @@ export const getTextFromElements = (
.join(separator);
return text;
};
export const DEFAULT_BOUND_TEXT_LABEL_POSITION = 0.5;
+113 -20
View File
@@ -11,30 +11,44 @@ import {
assertNever,
cloneJSON,
getFontString,
getUpdatedTimestamp,
isDevEnv,
toBrandedType,
getLineHeight,
DEFAULT_STICKY_NOTE_SIZE,
isTransparent,
DEFAULT_ZOOM,
} from "@excalidraw/common";
import type { MarkOptional } from "@excalidraw/common/utility-types";
import type { Zoom } from "@excalidraw/excalidraw/types";
import { bindBindingElement } from "./binding";
import {
newArrowElement,
newElement,
newEmbeddableElement,
newFrameElement,
newFreeDrawElement,
newIframeElement,
newImageElement,
newLinearElement,
newMagicFrameElement,
newTextElement,
type ElementConstructorOpts,
newStickyNoteElement,
normalizeStickyNoteGeometry,
} from "./newElement";
import { normalizeStickyNoteStrokeColor } from "./stickyNote";
import { measureText, normalizeText } from "./textMeasurements";
import { isArrowElement } from "./typeChecks";
import { isArrowElement, isStickyNoteElement } from "./typeChecks";
import { syncInvalidIndices } from "./fractionalIndex";
import { redrawTextBoundingBox } from "./textElement";
import {
DEFAULT_BOUND_TEXT_LABEL_POSITION,
redrawTextBoundingBox,
} from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
@@ -57,11 +71,25 @@ import type {
ExcalidrawTextElement,
FileId,
FontFamilyValues,
NonDeleted,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
Ordered,
TextAlign,
VerticalAlign,
ExcalidrawStickyNoteElement,
} from "./types";
/**
* Options for elements generated from a skeleton fragment (bound labels and
* binding endpoints). Such elements are always constructed fresh, hence they
* never carry a creation time of their own.
*/
type FragmentConstructorOpts = MarkOptional<
Omit<ElementConstructorOpts, "created">,
"x" | "y"
>;
export type ValidLinearElement = {
type: "arrow" | "line";
x: number;
@@ -72,7 +100,7 @@ export type ValidLinearElement = {
fontFamily?: FontFamilyValues;
textAlign?: TextAlign;
verticalAlign?: VerticalAlign;
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
} & FragmentConstructorOpts;
end?:
| (
| (
@@ -112,9 +140,9 @@ export type ValidLinearElement = {
text: string;
}
) &
Partial<ExcalidrawTextElement>)
Partial<Omit<ExcalidrawTextElement, "created">>)
) &
MarkOptional<ElementConstructorOpts, "x" | "y">;
FragmentConstructorOpts;
start?:
| (
| (
@@ -154,9 +182,9 @@ export type ValidLinearElement = {
text: string;
}
) &
Partial<ExcalidrawTextElement>)
Partial<Omit<ExcalidrawTextElement, "created">>)
) &
MarkOptional<ElementConstructorOpts, "x" | "y">;
FragmentConstructorOpts;
} & Partial<ExcalidrawLinearElement>;
export type ValidContainer =
@@ -169,9 +197,21 @@ export type ValidContainer =
fontFamily?: FontFamilyValues;
textAlign?: TextAlign;
verticalAlign?: VerticalAlign;
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
} & FragmentConstructorOpts;
} & ElementConstructorOpts;
/**
* A sticky note: an always-filled note whose label auto-fits. `label.fontSize`
* is the font ceiling the fit shrinks from; the note grows past its height
* (kept as `baseHeight`) only once the label hits the minimum font size.
*/
export type ValidStickyNote = {
type: "stickynote";
id?: ExcalidrawStickyNoteElement["id"];
label?: Extract<ValidContainer, { label?: unknown }>["label"];
} & ElementConstructorOpts &
Partial<Pick<ExcalidrawStickyNoteElement, "baseHeight">>;
export type ExcalidrawElementSkeleton =
| Extract<
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
@@ -183,6 +223,7 @@ export type ExcalidrawElementSkeleton =
y: number;
} & Partial<ExcalidrawLinearElement>)
| ValidContainer
| ValidStickyNote
| ValidLinearElement
| ({
type: "text";
@@ -217,9 +258,19 @@ const DEFAULT_DIMENSION = 100;
const bindTextToContainer = (
container: ExcalidrawElement,
textProps: { text: string } & MarkOptional<ElementConstructorOpts, "x" | "y">,
textProps: { text: string } & FragmentConstructorOpts,
scene: Scene,
) => {
// a note and its label share one ink: a label that sets its own color
// gives it to the note (the footer paints with it); transparent falls
// back to the note's
const stickyInk = isStickyNoteElement(container)
? normalizeStickyNoteStrokeColor(
textProps.strokeColor && !isTransparent(textProps.strokeColor)
? textProps.strokeColor
: container.strokeColor,
)
: null;
const textElement: ExcalidrawTextElement = newTextElement({
x: 0,
y: 0,
@@ -227,7 +278,10 @@ const bindTextToContainer = (
verticalAlign: VERTICAL_ALIGN.MIDDLE,
...textProps,
containerId: container.id,
strokeColor: textProps.strokeColor || container.strokeColor,
strokeColor: stickyInk ?? (textProps.strokeColor || container.strokeColor),
labelPosition: isArrowElement(container)
? DEFAULT_BOUND_TEXT_LABEL_POSITION
: null,
});
Object.assign(container, {
@@ -235,6 +289,9 @@ const bindTextToContainer = (
type: "text",
id: textElement.id,
}),
...(stickyInk && stickyInk !== container.strokeColor
? { strokeColor: stickyInk }
: null),
});
redrawTextBoundingBox(textElement, container, scene);
@@ -243,11 +300,12 @@ const bindTextToContainer = (
};
const bindLinearElementToElement = (
linearElement: ExcalidrawArrowElement,
linearElement: NonDeleted<ExcalidrawArrowElement>,
start: ValidLinearElement["start"],
end: ValidLinearElement["end"],
elementStore: ElementStore,
scene: Scene,
zoom: Zoom,
): {
linearElement: ExcalidrawLinearElement;
startBoundElement?: ExcalidrawElement;
@@ -331,10 +389,11 @@ const bindLinearElementToElement = (
bindBindingElement(
linearElement,
startBoundElement as ExcalidrawBindableElement,
startBoundElement as NonDeleted<ExcalidrawBindableElement>,
"orbit",
"start",
scene,
zoom,
);
}
}
@@ -407,10 +466,11 @@ const bindLinearElementToElement = (
bindBindingElement(
linearElement,
endBoundElement as ExcalidrawBindableElement,
endBoundElement as NonDeleted<ExcalidrawBindableElement>,
"orbit",
"end",
scene,
zoom,
);
}
}
@@ -492,7 +552,10 @@ class ElementStore {
};
getElements = () => {
return syncInvalidIndices(Array.from(this.excalidrawElements.values()));
// programmatically created elements are always non-deleted
return syncInvalidIndices(
Array.from(this.excalidrawElements.values()),
) as Ordered<NonDeletedExcalidrawElement>[];
};
getElementsMap = () => {
@@ -519,11 +582,14 @@ export const convertToExcalidrawElements = (
const oldToNewElementIdMap = new Map<string, string>();
// Create individual elements
// regenerated ids mean new instances, hence a fresh creation time as well
const created = getUpdatedTimestamp();
for (const element of elements) {
let excalidrawElement: ExcalidrawElement;
const originalId = element.id;
if (opts?.regenerateIds !== false) {
Object.assign(element, { id: randomId() });
Object.assign(element, { id: randomId(), created });
}
switch (element.type) {
@@ -622,10 +688,32 @@ export const convertToExcalidrawElements = (
});
break;
}
case "freedraw":
case "iframe":
case "freedraw": {
excalidrawElement = newFreeDrawElement({ ...element });
break;
}
case "iframe": {
excalidrawElement = newIframeElement({ ...element });
break;
}
case "stickynote": {
const width = element.width || DEFAULT_STICKY_NOTE_SIZE;
const height = element.height || DEFAULT_STICKY_NOTE_SIZE;
// finalized geometry (min size, baseHeight ≤ height) — only a
// pointer-down draft is exempt from it
excalidrawElement = normalizeStickyNoteGeometry(
newStickyNoteElement({
...element,
type: "stickynote",
width,
height,
baseHeight: element.baseHeight ?? height,
}),
);
break;
}
case "embeddable": {
excalidrawElement = element;
excalidrawElement = newEmbeddableElement({ ...element });
break;
}
@@ -662,8 +750,11 @@ export const convertToExcalidrawElements = (
case "rectangle":
case "ellipse":
case "diamond":
case "stickynote":
case "arrow": {
if (element.label?.text) {
// for a sticky note this runs the sticky fit (via
// `redrawTextBoundingBox`): the label's font size becomes its ceiling
let [container, text] = bindTextToContainer(
excalidrawElement,
element?.label,
@@ -691,11 +782,12 @@ export const convertToExcalidrawElements = (
}
const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement(
container,
container as NonDeleted<ExcalidrawArrowElement>,
originalStart,
originalEnd,
elementStore,
scene,
DEFAULT_ZOOM,
);
container = linearElement;
elementStore.add(linearElement);
@@ -716,11 +808,12 @@ export const convertToExcalidrawElements = (
}
const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement(
excalidrawElement as ExcalidrawArrowElement,
excalidrawElement as NonDeleted<ExcalidrawArrowElement>,
start,
end,
elementStore,
scene,
DEFAULT_ZOOM,
);
elementStore.add(linearElement);
+114 -76
View File
@@ -29,70 +29,77 @@ import type {
ExcalidrawLineElement,
ExcalidrawFlowchartNodeElement,
ExcalidrawLinearElementSubType,
ExcalidrawStickyNoteElement,
} from "./types";
export const isInitializedImageElement = (
element: ExcalidrawElement | null,
): element is InitializedExcalidrawImageElement => {
export const isInitializedImageElement = <T extends ExcalidrawElement>(
element: T | null,
): element is T & InitializedExcalidrawImageElement => {
return !!element && element.type === "image" && !!element.fileId;
};
export const isImageElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawImageElement => {
export const isImageElement = <T extends ExcalidrawElement>(
element: T | null,
): element is T & ExcalidrawImageElement => {
return !!element && element.type === "image";
};
export const isEmbeddableElement = (
element: ExcalidrawElement | null | undefined,
): element is ExcalidrawEmbeddableElement => {
export const isEmbeddableElement = <T extends ExcalidrawElement>(
element: T | null | undefined,
): element is T & ExcalidrawEmbeddableElement => {
return !!element && element.type === "embeddable";
};
export const isIframeElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawIframeElement => {
export const isIframeElement = <T extends ExcalidrawElement>(
element: T | null,
): element is T & ExcalidrawIframeElement => {
return !!element && element.type === "iframe";
};
export const isIframeLikeElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawIframeLikeElement => {
export const isIframeLikeElement = <T extends ExcalidrawElement>(
element: T | null,
): element is T & ExcalidrawIframeLikeElement => {
return (
!!element && (element.type === "iframe" || element.type === "embeddable")
);
};
export const isTextElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawTextElement => {
export const isTextElement = <T extends ExcalidrawElement>(
element: T | null,
): element is T & ExcalidrawTextElement => {
return element != null && element.type === "text";
};
export const isFrameElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawFrameElement => {
export const isStickyNoteElement = <T extends ExcalidrawElement>(
element: T | null | undefined,
): element is T & ExcalidrawStickyNoteElement => {
return element != null && element.type === "stickynote";
};
export const isFrameElement = <T extends ExcalidrawElement>(
element: T | null,
): element is T & ExcalidrawFrameElement => {
return element != null && element.type === "frame";
};
export const isMagicFrameElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawMagicFrameElement => {
export const isMagicFrameElement = <T extends ExcalidrawElement>(
element: T | null,
): element is T & ExcalidrawMagicFrameElement => {
return element != null && element.type === "magicframe";
};
export const isFrameLikeElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawFrameLikeElement => {
export const isFrameLikeElement = <T extends ExcalidrawElement>(
element: T | null,
): element is T & ExcalidrawFrameLikeElement => {
return (
element != null &&
(element.type === "frame" || element.type === "magicframe")
);
};
export const isFreeDrawElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawFreeDrawElement => {
export const isFreeDrawElement = <T extends ExcalidrawElement>(
element?: T | null,
): element is T & ExcalidrawFreeDrawElement => {
return element != null && isFreeDrawElementType(element.type);
};
@@ -102,48 +109,48 @@ export const isFreeDrawElementType = (
return elementType === "freedraw";
};
export const isLinearElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawLinearElement => {
export const isLinearElement = <T extends ExcalidrawElement>(
element?: T | null,
): element is T & ExcalidrawLinearElement => {
return element != null && isLinearElementType(element.type);
};
export const isLineElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawLineElement => {
export const isLineElement = <T extends ExcalidrawElement>(
element?: T | null,
): element is T & ExcalidrawLineElement => {
return element != null && element.type === "line";
};
export const isArrowElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawArrowElement => {
export const isArrowElement = <T extends ExcalidrawElement>(
element?: T | null,
): element is T & ExcalidrawArrowElement => {
return element != null && element.type === "arrow";
};
export const isElbowArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawElbowArrowElement => {
export const isElbowArrow = <T extends ExcalidrawElement>(
element?: T,
): element is T & ExcalidrawElbowArrowElement => {
return isArrowElement(element) && element.elbowed;
};
/**
* sharp or curved arrow, but not elbow
*/
export const isSimpleArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawArrowElement => {
export const isSimpleArrow = <T extends ExcalidrawElement>(
element?: T,
): element is T & ExcalidrawArrowElement => {
return isArrowElement(element) && !element.elbowed;
};
export const isSharpArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawArrowElement => {
export const isSharpArrow = <T extends ExcalidrawElement>(
element?: T,
): element is T & ExcalidrawArrowElement => {
return isArrowElement(element) && !element.elbowed && !element.roundness;
};
export const isCurvedArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawArrowElement => {
export const isCurvedArrow = <T extends ExcalidrawElement>(
element?: T,
): element is T & ExcalidrawArrowElement => {
return (
isArrowElement(element) && !element.elbowed && element.roundness !== null
);
@@ -157,10 +164,10 @@ export const isLinearElementType = (
);
};
export const isBindingElement = (
element?: ExcalidrawElement | null,
export const isBindingElement = <T extends ExcalidrawElement>(
element?: T | null,
includeLocked = true,
): element is ExcalidrawArrowElement => {
): element is T & ExcalidrawArrowElement => {
return (
element != null &&
(!element.locked || includeLocked === true) &&
@@ -174,14 +181,15 @@ export const isBindingElementType = (
return elementType === "arrow";
};
export const isBindableElement = (
element: ExcalidrawElement | null | undefined,
export const isBindableElement = <T extends ExcalidrawElement>(
element: T | null | undefined,
includeLocked = true,
): element is ExcalidrawBindableElement => {
): element is T & ExcalidrawBindableElement => {
return (
element != null &&
(!element.locked || includeLocked === true) &&
(element.type === "rectangle" ||
element.type === "stickynote" ||
element.type === "diamond" ||
element.type === "ellipse" ||
element.type === "image" ||
@@ -193,12 +201,13 @@ export const isBindableElement = (
);
};
export const isRectanguloidElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawBindableElement => {
export const isRectanguloidElement = <T extends ExcalidrawElement>(
element?: T | null,
): element is T & ExcalidrawBindableElement => {
return (
element != null &&
(element.type === "rectangle" ||
element.type === "stickynote" ||
element.type === "diamond" ||
element.type === "image" ||
element.type === "iframe" ||
@@ -211,12 +220,13 @@ export const isRectanguloidElement = (
// TODO: Remove this when proper distance calculation is introduced
// @see binding.ts:distanceToBindableElement()
export const isRectangularElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawBindableElement => {
export const isRectangularElement = <T extends ExcalidrawElement>(
element?: T | null,
): element is T & ExcalidrawBindableElement => {
return (
element != null &&
(element.type === "rectangle" ||
element.type === "stickynote" ||
element.type === "image" ||
element.type === "text" ||
element.type === "iframe" ||
@@ -227,14 +237,15 @@ export const isRectangularElement = (
);
};
export const isTextBindableContainer = (
element: ExcalidrawElement | null,
export const isTextBindableContainer = <T extends ExcalidrawElement>(
element: T | null,
includeLocked = true,
): element is ExcalidrawTextContainer => {
): element is T & ExcalidrawTextContainer => {
return (
element != null &&
(!element.locked || includeLocked === true) &&
(element.type === "rectangle" ||
element.type === "stickynote" ||
element.type === "diamond" ||
element.type === "ellipse" ||
isArrowElement(element))
@@ -252,6 +263,7 @@ export const isExcalidrawElement = (
case "text":
case "diamond":
case "rectangle":
case "stickynote":
case "iframe":
case "embeddable":
case "ellipse":
@@ -271,28 +283,30 @@ export const isExcalidrawElement = (
}
};
export const isFlowchartNodeElement = (
element: ExcalidrawElement,
): element is ExcalidrawFlowchartNodeElement => {
export const isFlowchartNodeElement = <T extends ExcalidrawElement>(
element: T,
): element is T & ExcalidrawFlowchartNodeElement => {
return (
element.type === "rectangle" ||
element.type === "stickynote" ||
element.type === "ellipse" ||
element.type === "diamond"
);
};
export const hasBoundTextElement = (
element: ExcalidrawElement | null,
): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => {
export const hasBoundTextElement = <T extends ExcalidrawElement>(
element: T | null,
): element is T &
MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => {
return (
isTextBindableContainer(element) &&
!!element.boundElements?.some(({ type }) => type === "text")
);
};
export const isBoundToContainer = (
element: ExcalidrawElement | null,
): element is ExcalidrawTextElementWithContainer => {
export const isBoundToContainer = <T extends ExcalidrawElement>(
element: T | null,
): element is T & ExcalidrawTextElementWithContainer => {
return (
element !== null &&
"containerId" in element &&
@@ -312,7 +326,10 @@ export const isUsingAdaptiveRadius = (type: string) =>
type === "image";
export const isUsingProportionalRadius = (type: string) =>
type === "line" || type === "arrow" || type === "diamond";
type === "line" ||
type === "arrow" ||
type === "diamond" ||
type === "stickynote";
export const canApplyRoundnessTypeToElement = (
roundnessType: RoundnessType,
@@ -392,3 +409,24 @@ export const canBecomePolygon = (
(points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
);
};
export const isEligibleFrameChildType = (type: ElementOrToolType) => {
switch (type) {
case "rectangle":
case "stickynote":
case "diamond":
case "ellipse":
case "arrow":
case "line":
case "freedraw":
case "text":
case "image":
case "frame":
case "embeddable": {
return true;
}
default: {
return false;
}
}
};
+51 -3
View File
@@ -76,6 +76,11 @@ type _ExcalidrawElementBase = Readonly<{
boundElements: readonly BoundElement[] | null;
/** epoch (ms) timestamp of last element update */
updated: number;
/** Client wall-clock creation time in epoch milliseconds; null if unknown.
Preserved for this element's lifetime, including edits and undo/redo,
and excluded from `ElementUpdate` (mutateElement / newElementWith).
Duplicating an element starts a new lifetime. Not an ordering clock. */
created: number | null;
link: string | null;
locked: boolean;
customData?: Record<string, any>;
@@ -89,6 +94,16 @@ export type ExcalidrawRectangleElement = _ExcalidrawElementBase & {
type: "rectangle";
};
export type ExcalidrawStickyNoteElement = _ExcalidrawElementBase &
Readonly<{
type: "stickynote";
/**
* The height the user set, from which the layout derives `height`: the
* note grows above it to fit its label and never shrinks below it
*/
baseHeight: number;
}>;
export type ExcalidrawDiamondElement = _ExcalidrawElementBase & {
type: "diamond";
};
@@ -185,11 +200,13 @@ export type ExcalidrawGenericElement =
export type ExcalidrawFlowchartNodeElement =
| ExcalidrawRectangleElement
| ExcalidrawStickyNoteElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement;
export type ExcalidrawRectanguloidElement =
| ExcalidrawRectangleElement
| ExcalidrawStickyNoteElement
| ExcalidrawImageElement
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement
@@ -205,6 +222,7 @@ export type ExcalidrawRectanguloidElement =
*/
export type ExcalidrawElement =
| ExcalidrawGenericElement
| ExcalidrawStickyNoteElement
| ExcalidrawTextElement
| ExcalidrawLinearElement
| ExcalidrawArrowElement
@@ -227,7 +245,7 @@ export type Ordered<TElement extends ExcalidrawElement> = TElement & {
export type OrderedExcalidrawElement = Ordered<ExcalidrawElement>;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
isDeleted: boolean;
isDeleted: false;
};
export type NonDeletedExcalidrawElement = NonDeleted<ExcalidrawElement>;
@@ -237,10 +255,19 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
type: "text";
fontSize: number;
fontFamily: FontFamilyValues;
/**
* The font size the user picked, from which the layout derives `fontSize`.
* Today only sticky note labels have one: the auto-fit shrinks below it
* and never above it (compare `baseHeight`, which the note grows above).
* `null` for every other text. Read it through `getBaseFontSize` —
* generic binding repair can detach a label without clearing this, so
* the container decides its meaning.
*/
baseFontSize: number | null;
text: string;
textAlign: TextAlign;
verticalAlign: VerticalAlign;
containerId: ExcalidrawGenericElement["id"] | null;
containerId: ExcalidrawTextContainer["id"] | null;
originalText: string;
/**
* If `true` the width will fit the text. If `false`, the text will
@@ -254,10 +281,18 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
* with font size (using `getLineHeightInPx` helper).
*/
lineHeight: number & { _brand: "unitlessLineHeight" };
/**
* Position of text bound to a linear element (such as an arrow),
* expressed as a normalized arc-length parameter (0–1) along the
* container's whole path. Independent of how the path is segmented,
* so it survives midpoint insertion and other geometry changes.
* */
labelPosition?: number | null;
}>;
export type ExcalidrawBindableElement =
| ExcalidrawRectangleElement
| ExcalidrawStickyNoteElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawTextElement
@@ -269,6 +304,7 @@ export type ExcalidrawBindableElement =
export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement
| ExcalidrawStickyNoteElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawArrowElement;
@@ -279,7 +315,7 @@ export type ExcalidrawTextElementWithContainer = {
export type FixedPoint = [number, number];
export type BindMode = "inside" | "orbit" | "skip";
export type BindMode = "inside" | "orbit";
export type FixedPointBinding = {
elementId: ExcalidrawBindableElement["id"];
@@ -384,12 +420,20 @@ export type ExcalidrawElbowArrowElement = Merge<
}
>;
export type StrokeVariability = "variable" | "constant";
export type StrokeOptions = Readonly<{
variability: StrokeVariability;
streamline: number;
}>;
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{
type: "freedraw";
points: readonly LocalPoint[];
pressures: readonly number[];
simulatePressure: boolean;
strokeOptions: StrokeOptions;
}>;
export type FileId = string & { _brand: "FileId" };
@@ -437,6 +481,10 @@ export type ElementsMapOrArray =
| readonly ExcalidrawElement[]
| Readonly<ElementsMap>;
export type NonDeletedElementsMapOrArray =
| readonly NonDeletedExcalidrawElement[]
| Readonly<NonDeletedElementsMap | NonDeletedSceneElementsMap>;
export type ExcalidrawLinearElementSubType =
| "line"
| "sharpArrow"
+228 -69
View File
@@ -8,6 +8,7 @@ import {
import {
bezierEquation,
clamp,
curve,
curveCatmullRomCubicApproxPoints,
curveOffsetPoints,
@@ -26,7 +27,7 @@ import {
type GlobalPoint,
} from "@excalidraw/math";
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
import type { Curve, LineSegment, LocalPoint, Radians } from "@excalidraw/math";
import type {
AppState,
@@ -40,13 +41,14 @@ import { generateLinearCollisionShape } from "./shape";
import { hitElementItself, isPointInElement } from "./collision";
import { LinearElementEditor } from "./linearElementEditor";
import { isRectangularElement } from "./typeChecks";
import { maxBindingDistance_simple } from "./binding";
import { isElbowArrow, isRectangularElement } from "./typeChecks";
import { getBindingGap, maxBindingDistance } from "./binding";
import {
getGlobalFixedPointForBindableElement,
normalizeFixedPoint,
} from "./binding";
import { getStickyNoteCornerRadius } from "./stickyNote";
import type {
ElementsMap,
@@ -59,6 +61,8 @@ import type {
ExcalidrawRectanguloidElement,
} from "./types";
export type LinearPathSegment = LineSegment<GlobalPoint> | Curve<GlobalPoint>;
type ElementShape = [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]];
const ElementShapesCache = new WeakMap<
@@ -125,7 +129,7 @@ const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
): ElementShape {
const cachedShape = getElementShapesCacheEntry(element, 0);
if (cachedShape) {
@@ -133,8 +137,8 @@ export function deconstructLinearOrFreeDrawElement(
}
const ops = generateLinearCollisionShape(element, elementsMap);
const lines = [];
const curves = [];
const lines: LineSegment<GlobalPoint>[] = [];
const curves: Curve<GlobalPoint>[] = [];
for (let idx = 0; idx < ops.length; idx += 1) {
const op = ops[idx];
@@ -193,12 +197,43 @@ export function deconstructLinearOrFreeDrawElement(
}
}
const shape = [lines, curves] as ElementShape;
const shape: ElementShape = [lines, curves];
setElementShapesCacheEntry(element, shape, 0);
return shape;
}
export function getLinearElementPathSegments(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
): LinearPathSegment[] {
// For now, model elbow arrows as their unrounded logical path. Rounded
// joints can be incorporated once the path model supports mixed straight
// and curved segments.
if (isElbowArrow(element)) {
return element.points
.slice(1)
.map((point, index) =>
lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + element.points[index][0],
element.y + element.points[index][1],
),
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
),
);
}
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
// Non-elbow paths currently contain only one segment type. Mixed paths
// should consume an ordered operation stream instead of these type buckets.
return curves.length > 0 ? curves : lines;
}
/**
* Get the building components of a rectanguloid element in the form of
* line segments and curves **unrotated**.
@@ -210,17 +245,17 @@ export function deconstructLinearOrFreeDrawElement(
export function deconstructRectanguloidElement(
element: ExcalidrawRectanguloidElement,
offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
): ElementShape {
const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
let radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
let radius =
element.type === "stickynote"
? getStickyNoteCornerRadius(element)
: getCornerRadius(Math.min(element.width, element.height), element);
if (radius === 0) {
radius = 0.01;
@@ -422,7 +457,7 @@ export function getDiamondBaseCorners(
export function deconstructDiamondElement(
element: ExcalidrawDiamondElement,
offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
): ElementShape {
const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
@@ -431,10 +466,20 @@ export function deconstructDiamondElement(
const baseCorners = getDiamondBaseCorners(element, offset);
const corners = baseCorners.map(
(corner) =>
curveCatmullRomCubicApproxPoints(curveOffsetPoints(corner, offset))!,
);
const corners =
offset > 0
? baseCorners.map(
(corner) =>
curveCatmullRomCubicApproxPoints(
curveOffsetPoints(corner, offset),
)!,
)
: [
[baseCorners[0]],
[baseCorners[1]],
[baseCorners[2]],
[baseCorners[3]],
];
const sides = [
lineSegment<GlobalPoint>(
@@ -587,67 +632,179 @@ const getDiagonalsForBindableElement = (
return [diagonalOne, diagonalTwo];
};
export const getSnapOutlineMidPoint = (
point: GlobalPoint,
/**
* `onAxis` is true for the midpoints on the element's axes (the side midpoints,
* or a diamond's vertices), whose direction from the center is unambiguous,
* and false for a diamond's edge midpoints
*/
const getSnappedMidpointForElbowArrow = (
element: ExcalidrawBindableElement,
point: GlobalPoint,
elementsMap: ElementsMap,
zoom: AppState["zoom"],
) => {
const center = elementCenterPoint(element, elementsMap);
const sideMidpoints =
element.type === "diamond"
? getDiamondBaseCorners(element).map((curve) => {
const point = bezierEquation(curve, 0.5);
const rotatedPoint = pointRotateRads(point, center, element.angle);
center: GlobalPoint,
horizontalThreshold: number,
verticalThreshold: number,
): { point: GlobalPoint; onAxis: boolean } | undefined => {
const { x, y, width, height, angle } = element;
const nonRotated = pointRotateRads(point, center, -angle as Radians);
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
})
: [
// RIGHT midpoint
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width,
element.y + element.height / 2,
),
const bindingGap = getBindingGap(element);
if (pointDistance(center, nonRotated) < bindingGap) {
return undefined;
}
const [right, bottom, left, top] = getAllMidpoints(element, elementsMap);
if (
nonRotated[0] <= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThreshold
) {
return { point: left, onAxis: true };
} else if (
nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThreshold
) {
return { point: top, onAxis: true };
} else if (
nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThreshold
) {
return { point: right, onAxis: true };
} else if (
nonRotated[1] >= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThreshold
) {
return { point: bottom, onAxis: true };
} else if (element.type === "diamond") {
// Elbow arrows can also snap to the midpoints of a diamond's edges
const threshold = Math.max(horizontalThreshold, verticalThreshold);
const edgeMidpoints = [
// top-left, top-right, bottom-left, bottom-right
[x + width / 4, y + height / 4, -1, -1],
[x + (3 * width) / 4, y + height / 4, 1, -1],
[x + width / 4, y + (3 * height) / 4, -1, 1],
[x + (3 * width) / 4, y + (3 * height) / 4, 1, 1],
] as const;
for (const [edgeX, edgeY, dirX, dirY] of edgeMidpoints) {
// the snap zone sits a binding gap outside the edge
const zoneCenter = pointFrom<GlobalPoint>(
edgeX + dirX * bindingGap,
edgeY + dirY * bindingGap,
);
if (pointDistance(zoneCenter, nonRotated) < threshold) {
return {
point: pointRotateRads(
pointFrom<GlobalPoint>(edgeX, edgeY),
center,
element.angle,
angle,
),
// BOTTOM midpoint
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height,
),
center,
element.angle,
),
// LEFT midpoint
pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
center,
element.angle,
),
// TOP midpoint
pointRotateRads(
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
center,
element.angle,
),
];
const candidate = sideMidpoints.find(
(midpoint) =>
pointDistance(point, midpoint) <=
maxBindingDistance_simple(zoom) + element.strokeWidth / 2 &&
onAxis: false,
};
}
}
}
return undefined;
};
const getSnappedMidpointIndexForSimpleArrow = (
element: ExcalidrawBindableElement,
point: GlobalPoint,
elementsMap: ElementsMap,
threshold: number,
) => {
const baseMidpoints = getAllMidpoints(element, elementsMap);
for (let i = 0; i < baseMidpoints.length; i++) {
if (
pointDistance(baseMidpoints[i], point) <= threshold &&
!hitElementItself({
point,
element,
threshold: 0,
elementsMap,
overrideShouldTestInside: true,
}),
})
) {
return i;
}
}
return -1;
};
export const getAllMidpoints = (
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): GlobalPoint[] => {
const center = elementCenterPoint(element, elementsMap);
if (element.type === "diamond") {
return getDiamondBaseCorners(element).map((curve) =>
pointRotateRads(bezierEquation(curve, 0.5), center, element.angle),
);
}
return [
pointFrom(element.width, element.height / 2),
pointFrom(element.width / 2, element.height),
pointFrom(0, element.height / 2),
pointFrom(element.width / 2, 0),
].map(([x, y]) =>
pointRotateRads(
pointFrom<GlobalPoint>(element.x + x, element.y + y),
center,
element.angle,
),
);
};
export const getElbowArrowSnapMidPoint = (
point: GlobalPoint,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom: AppState["zoom"],
) => {
const TOLERANCE = 0.05;
const maxDistance = maxBindingDistance(zoom) + element.strokeWidth / 2;
return getSnappedMidpointForElbowArrow(
element,
point,
elementsMap,
elementCenterPoint(element, elementsMap),
clamp(TOLERANCE * element.width, 5, maxDistance),
clamp(TOLERANCE * element.height, 5, maxDistance),
);
};
export const getSnapOutlineMidPoint = (
point: GlobalPoint,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom: AppState["zoom"],
arrow: { elbowed: boolean },
): GlobalPoint | undefined => {
if (arrow.elbowed) {
return getElbowArrowSnapMidPoint(point, element, elementsMap, zoom)?.point;
}
const maxDistance = maxBindingDistance(zoom) + element.strokeWidth / 2;
const idx = getSnappedMidpointIndexForSimpleArrow(
element,
point,
elementsMap,
maxDistance,
);
return candidate;
return idx === -1 ? undefined : getAllMidpoints(element, elementsMap)[idx];
};
export const projectFixedPointOntoDiagonal = (
@@ -660,22 +817,24 @@ export const projectFixedPointOntoDiagonal = (
isMidpointSnappingEnabled: boolean = true,
): GlobalPoint | null => {
invariant(arrow.points.length >= 2, "Arrow must have at least two points");
if (arrow.width < 3 && arrow.height < 3) {
return null;
}
if (isMidpointSnappingEnabled) {
const sideMidPoint = getSnapOutlineMidPoint(
point,
element,
elementsMap,
zoom,
arrow,
);
if (sideMidPoint) {
return sideMidPoint;
}
}
// Projection needs a meaningful arrow direction
if (arrow.width < 3 && arrow.height < 3) {
return null;
}
// Do the projection onto the diagonals (or center lines
// for non-rectangular shapes)
const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement(
+1 -1
View File
@@ -5,6 +5,7 @@ import {
pointFrom,
type GlobalPoint,
type LocalPoint,
type LineSegment,
} from "@excalidraw/math";
import { type Bounds, isBounds } from "@excalidraw/common";
import {
@@ -17,7 +18,6 @@ import {
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type { Curve } from "@excalidraw/math";
import type { LineSegment } from "@excalidraw/utils";
// The global data holder to collect the debug operations
declare global {
+71 -11
View File
@@ -1,5 +1,7 @@
import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
import { isFiniteNumber } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { GlobalPoint } from "@excalidraw/math";
@@ -8,7 +10,6 @@ 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 {
@@ -17,7 +18,6 @@ import type {
ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
Ordered,
OrderedExcalidrawElement,
} from "./types";
@@ -153,15 +153,11 @@ const getContiguousFrameRangeElements = (
export const moveArrowAboveBindable = (
point: GlobalPoint,
arrow: ExcalidrawArrowElement,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elements: readonly OrderedExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
hit?: NonDeletedExcalidrawElement,
hoveredElement: NonDeletedExcalidrawElement,
): readonly OrderedExcalidrawElement[] => {
const hoveredElement = hit
? hit
: getHoveredElementForBinding(point, elements, elementsMap);
if (!hoveredElement) {
return elements;
}
@@ -313,12 +309,46 @@ const getTargetElementsMap = <T extends ExcalidrawElement>(
}, new Map<string, ExcalidrawElement>());
};
const hasSameElementIds = (
prevElements: readonly ExcalidrawElement[],
nextElements: readonly ExcalidrawElement[],
) => {
if (prevElements.length !== nextElements.length) {
console.error(
"z-index reordering failed: resulting array have different lengths",
);
return false;
}
const prevElementIdCounts = new Map<ExcalidrawElement["id"], number>();
for (const element of prevElements) {
prevElementIdCounts.set(
element.id,
(prevElementIdCounts.get(element.id) || 0) + 1,
);
}
for (const element of nextElements) {
const count = prevElementIdCounts.get(element.id);
if (!count) {
console.error(
"z-index reordering failed: element id mismatch / duplicate ids",
);
return false;
}
prevElementIdCounts.set(element.id, count - 1);
}
return true;
};
const shiftElementsByOne = (
elements: readonly ExcalidrawElement[],
appState: AppState,
direction: "left" | "right",
scene: Scene,
) => {
const originalElements = elements;
const indicesToMove = getIndicesToMove(elements, appState);
const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
@@ -389,6 +419,10 @@ const shiftElementsByOne = (
];
});
if (!hasSameElementIds(originalElements, elements)) {
return originalElements;
}
syncMovedIndices(elements, targetElementsMap);
return elements;
@@ -402,11 +436,20 @@ const shiftElementsToEnd = (
elementsToBeMoved?: readonly ExcalidrawElement[],
) => {
const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved);
// Nothing to move (e.g. `elementsToBeMoved` is empty because all selected
// elements were frame children handled in a prior pass). Bail out early —
// otherwise `leadingIndex`/`trailingIndex` below resolve to `undefined` and
// the resulting `slice()` calls overlap, duplicating elements.
if (indicesToMove.length === 0) {
return elements;
}
const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
const displacedElements: ExcalidrawElement[] = [];
let leadingIndex: number;
let trailingIndex: number;
let leadingIndex: number | undefined;
let trailingIndex: number | undefined;
if (direction === "left") {
if (containingFrame) {
leadingIndex = findIndex(elements, (el) =>
@@ -451,6 +494,19 @@ const shiftElementsToEnd = (
leadingIndex = 0;
}
const isValidIndex = (index: number | undefined): index is number => {
return isFiniteNumber(index) && index >= 0;
};
if (
!isValidIndex(leadingIndex) ||
!isValidIndex(trailingIndex) ||
leadingIndex > trailingIndex ||
indicesToMove.some((index) => index < leadingIndex || index > trailingIndex)
) {
return elements;
}
for (let index = leadingIndex; index < trailingIndex + 1; index++) {
if (!indicesToMove.includes(index)) {
displacedElements.push(elements[index]);
@@ -475,6 +531,10 @@ const shiftElementsToEnd = (
...trailingElements,
];
if (!hasSameElementIds(elements, nextElements)) {
return elements;
}
syncMovedIndices(nextElements, targetElementsMap);
return nextElements;
@@ -543,7 +603,7 @@ function shiftElementsAccountingForFrames(
for (const [frameId, children] of frameChildrenSets) {
nextElements = shiftFunction(
allElements,
nextElements,
appState,
direction,
frameId,
@@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
class="excalidraw-wysiwyg"
data-type="wysiwyg"
dir="auto"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform-origin: 5px 12.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
tabindex="0"
wrap="off"
/>
+85
View File
@@ -1009,4 +1009,89 @@ describe("aligning", () => {
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(100);
});
const createAndSelectGroupWithBoundTextAndRectangle = () => {
const groupId = "group-with-bound-text";
// container is vertically/horizontally centered around its bound text
const container = API.createElement({
type: "rectangle",
id: "container",
x: 100,
y: 100,
width: 100,
height: 100,
groupIds: [groupId],
boundElements: [{ type: "text", id: "bound-text" }],
});
const boundText = API.createElement({
type: "text",
id: "bound-text",
x: 120,
y: 140,
width: 60,
height: 20,
containerId: container.id,
groupIds: [groupId],
});
const groupedRectangle = API.createElement({
type: "rectangle",
id: "grouped-rectangle",
x: 100,
y: 200,
width: 100,
height: 100,
groupIds: [groupId],
});
const standaloneRectangle = API.createElement({
type: "rectangle",
id: "standalone-rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
});
API.setElements([
container,
boundText,
groupedRectangle,
standaloneRectangle,
]);
API.setSelectedElements([container, groupedRectangle, standaloneRectangle]);
return { container, boundText, groupedRectangle, standaloneRectangle };
};
it("keeps a bound text within its container when aligning a group to the top", () => {
const { container, boundText, groupedRectangle, standaloneRectangle } =
createAndSelectGroupWithBoundTextAndRectangle();
API.executeAction(actionAlignTop);
expect(API.getElement(standaloneRectangle).y).toEqual(0);
expect(API.getElement(container).y).toEqual(0);
expect(API.getElement(groupedRectangle).y).toEqual(100);
expect(API.getElement(boundText).y).toEqual(40);
expect(API.getElement(boundText).x).toEqual(120);
});
it("keeps a bound text within its container when aligning a group to the left", () => {
const { container, boundText, groupedRectangle, standaloneRectangle } =
createAndSelectGroupWithBoundTextAndRectangle();
API.executeAction(actionAlignLeft);
expect(API.getElement(standaloneRectangle).x).toEqual(0);
expect(API.getElement(container).x).toEqual(0);
expect(API.getElement(groupedRectangle).x).toEqual(0);
expect(API.getElement(boundText).x).toEqual(20);
expect(API.getElement(boundText).y).toEqual(140);
});
});
+407 -1
View File
@@ -1,4 +1,4 @@
import { KEYS, arrayToMap } from "@excalidraw/common";
import { KEYS, ROUNDNESS, arrayToMap, DEFAULT_ZOOM } from "@excalidraw/common";
import { pointFrom } from "@excalidraw/math";
@@ -16,6 +16,14 @@ import {
import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n";
import type { Radians } from "@excalidraw/math";
import {
bindBindingElement,
getBindingGap,
updateBoundElements,
} from "../src/binding";
import { getAllMidpoints } from "../src/utils";
import { getTransformHandles } from "../src/transformHandles";
import {
getTextEditor,
@@ -24,8 +32,10 @@ import {
import type {
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawLinearElement,
FixedPointBinding,
NonDeleted,
} from "../src/types";
const { h } = window;
@@ -178,6 +188,343 @@ describe("binding for simple arrows", () => {
});
});
describe("self-binding (both ends to the same element) single-click finalize", () => {
// rect spans x:200..400, y:200..400; orbit ring is ~15px outside the outline
const INSIDE: [number, number] = [250, 250];
const ORBIT_LEFT: [number, number] = [187, 300];
const ORBIT_RIGHT: [number, number] = [413, 300];
const MIDDLE: [number, number] = [550, 100];
beforeEach(async () => {
mouse.reset();
await act(() => setLanguage(defaultLang));
await render(<Excalidraw handleKeyboardGlobally={true} />);
UI.createElement("rectangle", {
x: 200,
y: 200,
width: 200,
height: 200,
});
});
const drawSelfArrow = (start: [number, number], end: [number, number]) => {
UI.clickTool("arrow");
mouse.reset();
mouse.clickAt(...start);
mouse.moveTo(...MIDDLE);
mouse.clickAt(...MIDDLE); // commit a middle point so it's a multi-point arrow
mouse.moveTo(...end);
mouse.clickAt(...end); // single click at the end
};
it("orbit -> orbit finalizes on a single click", () => {
drawSelfArrow(ORBIT_LEFT, ORBIT_RIGHT);
const arrow = h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
expect(h.state.multiElement).toBe(null);
expect(h.state.activeTool.type).toBe("selection");
expect(arrow.startBinding?.elementId).toBe(arrow.endBinding?.elementId);
expect(arrow.endBinding?.elementId).not.toBe(undefined);
});
it("inside -> orbit finalizes on a single click", () => {
drawSelfArrow(INSIDE, ORBIT_RIGHT);
const arrow = h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
expect(h.state.multiElement).toBe(null);
expect(h.state.activeTool.type).toBe("selection");
expect(arrow.startBinding?.elementId).toBe(arrow.endBinding?.elementId);
expect(arrow.endBinding?.elementId).not.toBe(undefined);
});
it("inside -> inside keep in multi-point mode (no single-click finalize)", () => {
drawSelfArrow(INSIDE, [INSIDE[0] + 50, INSIDE[1] + 50]); // end dropped inside the rect
// ambiguous → must be confirmed with a second click, so still in progress
expect(h.state.multiElement).not.toBe(null);
expect(h.state.activeTool.type).toBe("arrow");
});
it("inside -> inside stays in multi-point mode when the pointer moves on", () => {
const end: [number, number] = [INSIDE[0] + 50, INSIDE[1] + 50];
drawSelfArrow(INSIDE, end);
// nudge within the commit zone of the point just placed
mouse.moveTo(end[0] + 2, end[1] + 1);
expect(h.state.multiElement).not.toBe(null);
expect(h.state.activeTool.type).toBe("arrow");
expect(h.state.multiElement!.points.length).toBe(3);
});
it("bend point inside a shape the arrow doesn't start on stays in multi-point mode when the pointer moves on", () => {
UI.clickTool("arrow");
mouse.reset();
mouse.clickAt(...MIDDLE);
mouse.moveTo(...INSIDE);
mouse.clickAt(...INSIDE);
mouse.moveTo(INSIDE[0] + 2, INSIDE[1] + 1);
expect(h.state.multiElement).not.toBe(null);
expect(h.state.activeTool.type).toBe("arrow");
expect(h.state.multiElement!.points.length).toBe(2);
});
});
describe("binding to frames", () => {
// frame spans x:200..600, y:200..600, child rect x:300..400, y:300..400
beforeEach(async () => {
mouse.reset();
await act(() => setLanguage(defaultLang));
await render(<Excalidraw handleKeyboardGlobally={true} />);
const frame = API.createElement({
id: "frame",
type: "frame",
x: 200,
y: 200,
width: 400,
height: 400,
});
const child = API.createElement({
id: "child",
type: "rectangle",
x: 300,
y: 300,
width: 100,
height: 100,
frameId: frame.id,
});
API.setElements([frame, child]);
});
const drawArrow = (end: [number, number]) => {
UI.clickTool("arrow");
mouse.reset();
mouse.downAt(50, 50);
mouse.moveTo(end[0] - 10, end[1] - 10);
mouse.moveTo(...end);
mouse.up();
return h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
};
it("doesn't bind an arrow ending in the frame's empty interior", () => {
const arrow = drawArrow([500, 500]);
expect(arrow.endBinding).toBe(null);
});
it("binds an arrow ending on a frame child to the child", () => {
const arrow = drawArrow([350, 350]);
expect(arrow.endBinding?.elementId).toBe("child");
});
it("binds an arrow ending just outside the frame to the frame", () => {
const arrow = drawArrow([500, 610]);
expect(arrow.endBinding?.elementId).toBe("frame");
expect(arrow.endBinding?.mode).toBe("orbit");
});
it("keeps an elbow arrow drawn inside the frame where it was drawn", () => {
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.downAt(250, 250);
mouse.moveTo(400, 450);
mouse.moveTo(550, 550);
mouse.up();
const arrow = h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
expect(arrow.elbowed).toBe(true);
// no self-orbit binding to the frame, which would route the arrow
// around the frame's outside
expect(arrow.startBinding).toBe(null);
expect(arrow.endBinding).toBe(null);
expect(arrow.frameId).toBe("frame");
expect([arrow.x, arrow.y]).toEqual([250, 250]);
expect(arrow.points[arrow.points.length - 1]).toEqual([300, 300]);
});
});
describe("binding at an ellipse's center", () => {
beforeEach(async () => {
mouse.reset();
await act(() => setLanguage(defaultLang));
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("binds an arrow dropped at a circle's exact center", () => {
const circle = API.createElement({
type: "ellipse",
x: 100,
y: -100,
width: 200,
height: 200,
});
API.setElements([circle]);
UI.clickTool("arrow");
mouse.reset();
mouse.downAt(-100, 0);
mouse.moveTo(100, 0);
mouse.moveTo(200, 0);
mouse.up();
const arrow = h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
expect(arrow.endBinding?.elementId).toBe(circle.id);
});
});
describe("midpoint snapping on diamonds", () => {
beforeEach(async () => {
mouse.reset();
await act(() => setLanguage(defaultLang));
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
for (const rounded of [false, true]) {
for (const elbowed of [false, true]) {
it(`${
elbowed ? "elbow" : "simple"
} arrow ends a binding gap outside the ${
rounded ? "rounded" : "sharp"
} left vertex`, () => {
const diamond = API.createElement({
type: "diamond",
x: 100,
y: -100,
width: 200,
height: 200,
roundness: rounded ? { type: ROUNDNESS.PROPORTIONAL_RADIUS } : null,
}) as ExcalidrawBindableElement;
API.setElements([diamond]);
const [, , left] = getAllMidpoints(diamond, arrayToMap([diamond]));
UI.clickTool("arrow");
if (elbowed) {
UI.clickOnTestId("elbow-arrow");
}
mouse.reset();
mouse.downAt(-200, left[1]);
mouse.moveTo(left[0] - 50, left[1]);
// near (not on) the vertex, so the end snaps to it
mouse.moveTo(left[0] - 4, left[1] + 2);
mouse.up();
const arrow = h.elements[
h.elements.length - 1
] as ExcalidrawArrowElement;
const [endX, endY] = arrow.points[arrow.points.length - 1];
expect(arrow.endBinding?.elementId).toBe(diamond.id);
expect(arrow.x + endX).toBeCloseTo(
left[0] - getBindingGap(diamond),
0,
);
expect(arrow.y + endY).toBeCloseTo(left[1], 0);
});
}
it(`elbow arrow binds above the ${
rounded ? "rounded" : "sharp"
} top vertex when the pointer is slightly inside`, () => {
const diamond = API.createElement({
type: "diamond",
x: 100,
y: -100,
width: 300,
height: 300,
roundness: rounded ? { type: ROUNDNESS.PROPORTIONAL_RADIUS } : null,
}) as ExcalidrawBindableElement;
API.setElements([diamond]);
const [, , , top] = getAllMidpoints(diamond, arrayToMap([diamond]));
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.downAt(top[0] - 300, top[1] - 100);
mouse.moveTo(top[0] - 50, top[1] - 50);
// inside the diamond, a bit right of the vertex
mouse.moveTo(top[0] + 3, top[1] + 10);
mouse.up();
const arrow = h.elements[
h.elements.length - 1
] as ExcalidrawArrowElement;
const points = arrow.points;
const [endX, endY] = points[points.length - 1];
expect(arrow.endBinding?.elementId).toBe(diamond.id);
expect(arrow.x + endX).toBeCloseTo(top[0], 0);
expect(arrow.y + endY).toBeCloseTo(top[1] - getBindingGap(diamond), 0);
// the last segment comes straight down into the vertex
expect(points[points.length - 2][0]).toBeCloseTo(endX);
});
}
it("elbow arrow binds next to the tip of a thin rotated diamond", () => {
const diamond = API.createElement({
type: "diamond",
x: 0,
y: 0,
width: 600,
height: 100,
angle: ((15 * Math.PI) / 180) as Radians,
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
}) as ExcalidrawBindableElement;
API.setElements([diamond]);
const [right] = getAllMidpoints(diamond, arrayToMap([diamond]));
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.downAt(right[0] + 300, right[1] + 300);
mouse.moveTo(right[0] + 50, right[1] + 50);
// just outside the right tip
mouse.moveTo(right[0] + 4, right[1] + 1);
mouse.up();
const arrow = h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
const [endX, endY] = arrow.points[arrow.points.length - 1];
expect(arrow.endBinding?.elementId).toBe(diamond.id);
// a binding gap away, not on another edge of the diamond
expect(
Math.hypot(arrow.x + endX - right[0], arrow.y + endY - right[1]),
).toBeLessThan(getBindingGap(diamond) + 2);
});
it("elbow arrow snaps to a diamond's edge midpoint", () => {
const diamond = API.createElement({
type: "diamond",
x: 100,
y: -100,
width: 200,
height: 200,
}) as ExcalidrawBindableElement;
API.setElements([diamond]);
// midpoint of the top-left edge
const edgeMidpoint = [150, -50] as const;
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.downAt(-200, -50);
mouse.moveTo(100, -60);
// near (not on) the edge midpoint
mouse.moveTo(edgeMidpoint[0] - 9, edgeMidpoint[1] - 3);
mouse.up();
const arrow = h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
const [, endY] = arrow.points[arrow.points.length - 1];
expect(arrow.endBinding?.elementId).toBe(diamond.id);
expect(arrow.y + endY).toBeCloseTo(edgeMidpoint[1], 0);
});
});
describe("when arrow is outside of shape", () => {
beforeEach(async () => {
mouse.reset();
@@ -403,6 +750,7 @@ describe("binding for simple arrows", () => {
mouse.moveTo(340, 251);
mouse.moveTo(410, 251);
mouse.clickAt(410, 251);
mouse.clickAt(410, 251);
const arrow = h.elements[h.elements.length - 1] as any;
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
@@ -418,6 +766,9 @@ describe("binding for simple arrows", () => {
const elX = handles[0] + handles[2] / 2;
const elY = handles[1] + handles[3] / 2;
// move first, as a real pointer would — the hover state from the spot
// where arrow creation finished must clear before the pointerdown
mouse.moveTo(elX, elY);
mouse.downAt(elX, elY);
mouse.moveTo(300, 400);
mouse.up();
@@ -447,6 +798,7 @@ describe("binding for simple arrows", () => {
mouse.moveTo(350, 251);
mouse.moveTo(410, 251);
mouse.clickAt(410, 251);
mouse.clickAt(410, 251);
const arrow = API.getSelectedElement() as ExcalidrawArrowElement;
@@ -695,3 +1047,57 @@ describe("binding for simple arrows", () => {
});
});
});
describe("binding to a point-like (sub-pixel) element", () => {
beforeEach(async () => {
mouse.reset();
await act(() => setLanguage(defaultLang));
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
// Regression: binding to a zero/near-zero-size element used to yield an
// enormous fixedPoint ratio (offset / ~0), which exploded into an arrow
// hundreds of thousands of px wide once the element was resized to a normal
// size
it.each([
["simple", false],
["elbow", true],
])(
"%s arrow binds to the center and stays put when the element grows",
(_label, elbowed) => {
const rect = API.createElement({
type: "rectangle",
x: 200,
y: 50,
width: 0.00005,
height: 0.00005,
}) as NonDeleted<ExcalidrawBindableElement>;
const arrow = API.createElement({
type: "arrow",
elbowed,
x: 0,
y: 50,
width: 195,
height: 0,
points: [pointFrom(0, 0), pointFrom(195, 0)],
}) as NonDeleted<ExcalidrawArrowElement>;
API.setElements([rect, arrow]);
bindBindingElement(arrow, rect, "orbit", "end", h.scene, DEFAULT_ZOOM);
const endBinding = arrow.endBinding as FixedPointBinding;
expect(endBinding.elementId).toBe(rect.id);
// Point-like element => bind to center (0.5 is normalized to 0.5001)
expect(endBinding.fixedPoint[0]).toBeCloseTo(0.5001);
expect(endBinding.fixedPoint[1]).toBeCloseTo(0.5001);
// Grow the element to a normal size and let bound arrows follow
h.scene.mutateElement(rect, { width: 300, height: 200 });
updateBoundElements(rect, h.scene);
// The arrow endpoint must track the element (its center), not fly off
expect(Math.abs(arrow.width)).toBeLessThan(1000);
expect(Math.abs(arrow.height)).toBeLessThan(1000);
},
);
});
+69 -3
View File
@@ -1,10 +1,14 @@
import { pointFrom } from "@excalidraw/math";
import { arrayToMap, ROUNDNESS } from "@excalidraw/common";
import { arrayToMap, type Bounds, ROUNDNESS } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import type { LocalPoint } from "@excalidraw/math";
import { getElementAbsoluteCoords, getElementBounds } from "../src/bounds";
import {
elementsOverlappingBBox,
getElementAbsoluteCoords,
getElementBounds,
} from "../src/bounds";
import type { ExcalidrawElement, ExcalidrawLinearElement } from "../src/types";
@@ -141,3 +145,65 @@ describe("getElementBounds", () => {
expect(y2).toEqual(319.8162855827246);
});
});
const makeElement = (x: number, y: number, width: number, height: number) =>
API.createElement({
type: "rectangle",
x,
y,
width,
height,
});
const makeBBox = (
minX: number,
minY: number,
maxX: number,
maxY: number,
): Bounds => [minX, minY, maxX, maxY];
describe("elementsOverlappingBBox()", () => {
it("should return elements that overlap bbox", () => {
const bbox = makeBBox(0, 0, 100, 100);
const rectOutside = makeElement(110, 110, 100, 100);
const rectInside = makeElement(10, 10, 85, 85);
const rectContainingBBox = makeElement(-10, -10, 110, 110);
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
expect(
elementsOverlappingBBox({
bounds: bbox,
type: "overlap",
elements: [
rectOutside,
rectInside,
rectContainingBBox,
rectOverlappingTopLeft,
],
}),
).toEqual([rectInside, rectOverlappingTopLeft]);
});
it("should return elements inside/containing bbox", () => {
const bbox = makeBBox(0, 0, 100, 100);
const rectOutside = makeElement(110, 110, 100, 100);
const rectInside = makeElement(10, 10, 85, 85);
const rectContainingBBox = makeElement(-10, -10, 110, 110);
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
expect(
elementsOverlappingBBox({
bounds: bbox,
type: "contain",
elements: [
rectOutside,
rectInside,
rectContainingBBox,
rectOverlappingTopLeft,
],
}),
).toEqual([rectInside]);
});
});

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