Compare commits

..
Author SHA1 Message Date
Mark Tolmacs 7a2f5e7eae Document-level schema versioning.
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-09-16 12:40:57 +02:00
Mark Tolmacs c61a483f88 feat: Element schema
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-09-16 12:23:41 +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
368 changed files with 35579 additions and 23579 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ VITE_APP_ENABLE_TRACKING=true
FAST_REFRESH=false
# The port the run the dev server
VITE_APP_PORT=3005
VITE_APP_PORT=3001
#Debug flags
+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`.
@@ -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.
+48 -2
View File
@@ -33,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";
@@ -55,6 +55,7 @@ import {
} from "@excalidraw/excalidraw/data/restore";
import { newElementWith } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element";
import { CURRENT_SCHEMA_VERSION, getSchemaVersion } from "@excalidraw/element";
import clsx from "clsx";
import {
parseLibraryTokensFromUrl,
@@ -97,6 +98,7 @@ import Collab, {
collabAPIAtom,
isCollaboratingAtom,
isOfflineAtom,
userToFollowAtom,
} from "./collab/Collab";
import { AppFooter } from "./components/AppFooter";
import { AppMainMenu } from "./components/AppMainMenu";
@@ -139,6 +141,7 @@ import DebugCanvas, {
isVisualDebuggerEnabled,
loadSavedDebugState,
} from "./components/DebugCanvas";
import { useSimulatedCollaborators } from "./debugCollaborators";
import { AIComponents } from "./components/AI";
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
@@ -241,6 +244,7 @@ const initializeScene = async (opts: {
elements: restoreElements(localDataState?.elements, null, {
repairBindings: true,
deleteInvisibleElements: true,
schemaVersion: localDataState?.schemaVersion,
}),
appState: restoreAppState(localDataState?.appState, null),
};
@@ -267,6 +271,7 @@ const initializeScene = async (opts: {
restoreElements(imported.elements, null, {
repairBindings: true,
deleteInvisibleElements: true,
schemaVersion: getSchemaVersion(imported),
}),
localDataState?.elements,
),
@@ -409,6 +414,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,
@@ -434,6 +470,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
// ---------------------------------------------------------------------------
@@ -545,8 +586,12 @@ const ExcalidrawWrapper = () => {
loadImages(data);
if (data.scene) {
excalidrawAPI.updateScene({
// NOTE: scene elements passed through restore during
// initialization, so they are already at the current
// schema version
elements: restoreElements(data.scene.elements, null, {
repairBindings: true,
schemaVersion: CURRENT_SCHEMA_VERSION,
}),
appState: restoreAppState(data.scene.appState, null),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@@ -908,7 +953,8 @@ const ExcalidrawWrapper = () => {
})}
>
<Excalidraw
// aiEnabled={false}
viewportStatusFrame={viewportStatusFrame}
userToFollow={userToFollow}
onChange={onChange}
onExport={onExport}
initialData={initialStatePromiseRef.current.promise}
+1
View File
@@ -38,6 +38,7 @@ export const ROOM_ID_BYTES = 10;
export const STORAGE_KEYS = {
LOCAL_STORAGE_ELEMENTS: "excalidraw",
LOCAL_STORAGE_SCHEMA_VERSION: "excalidraw-schema-version",
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
LOCAL_STORAGE_THEME: "excalidraw-theme",
+85 -24
View File
@@ -22,6 +22,7 @@ import { decryptData } from "@excalidraw/excalidraw/data/encryption";
import { getVisibleSceneBounds } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
import { isImageElement, isInitializedImageElement } from "@excalidraw/element";
import { CURRENT_SCHEMA_VERSION, getSchemaVersion } from "@excalidraw/element";
import { AbortError } from "@excalidraw/excalidraw/errors";
import { t } from "@excalidraw/excalidraw/i18n";
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
@@ -48,6 +49,7 @@ import type {
SocketId,
Collaborator,
Gesture,
UserToFollow,
} from "@excalidraw/excalidraw/types";
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
@@ -108,6 +110,7 @@ interface CollabState {
}
export const activeRoomLinkAtom = atom<string | null>(null);
export const userToFollowAtom = atom<UserToFollow | null>(null);
type CollabInstance = InstanceType<typeof Collab>;
@@ -123,6 +126,7 @@ export interface CollabAPI {
getUsername: CollabInstance["getUsername"];
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
setCollabError: CollabInstance["setErrorDialog"];
setUserToFollow: CollabInstance["setUserToFollow"];
}
interface CollabProps {
@@ -139,6 +143,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 +218,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 +246,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 +415,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,
@@ -587,8 +598,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
const remoteElements = toBrandedType<
readonly RemoteExcalidrawElement[]
>(decryptedData.payload.elements);
const reconciledElements =
this._reconcileElements(remoteElements);
const reconciledElements = this._reconcileElements(
remoteElements,
getSchemaVersion(decryptedData.payload),
);
this.handleRemoteSceneUpdate(reconciledElements);
// noop if already resolved via init from firebase
scenePromise.resolve({
@@ -604,6 +617,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
toBrandedType<readonly RemoteExcalidrawElement[]>(
decryptedData.payload.elements,
),
getSchemaVersion(decryptedData.payload),
),
);
break;
@@ -629,11 +643,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,13 +655,12 @@ 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,
@@ -689,9 +702,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 });
},
@@ -753,15 +764,27 @@ class Collab extends PureComponent<CollabProps, CollabState> {
private _reconcileElements = (
remoteElements: readonly RemoteExcalidrawElement[],
remoteSchemaVersion: number = 0,
): ReconciledExcalidrawElement[] => {
const appState = this.excalidrawAPI.getAppState();
const existingElements = this.getSceneElementsIncludingDeleted();
if (remoteSchemaVersion > CURRENT_SCHEMA_VERSION) {
// NOTE: remote client is newer than us; we can't downgrade its elements,
// so restore as-is and rely on unknown properties being preserved
console.warn(
`Received collab update with schema version ${remoteSchemaVersion} ` +
`(local: ${CURRENT_SCHEMA_VERSION})`,
);
}
// NOTE ideally we restore _after_ reconciliation but we can't do that
// as we'd regenerate even elements such as appState.newElement which would
// break the state
remoteElements = restoreElements(remoteElements, existingElements);
remoteElements = restoreElements(remoteElements, existingElements, {
schemaVersion: remoteSchemaVersion,
});
let reconciledElements = reconcileElements(
existingElements,
@@ -869,26 +892,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;
@@ -924,12 +959,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}`,
);
@@ -984,9 +1017,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;
+2
View File
@@ -2,6 +2,7 @@ import { CaptureUpdateAction } from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
import { newElementWith } from "@excalidraw/element";
import { CURRENT_SCHEMA_VERSION } from "@excalidraw/element";
import throttle from "lodash.throttle";
import type { UserIdleState } from "@excalidraw/common";
@@ -167,6 +168,7 @@ class Portal {
type: updateType,
payload: {
elements: syncableElements,
schemaVersion: CURRENT_SCHEMA_VERSION,
},
};
-45
View File
@@ -1,45 +0,0 @@
.tta-chat-empty-storage-notice {
font-size: 0.75rem;
padding: 0 2rem;
color: var(--text-secondary-color);
line-height: 1.4;
text-align: center;
}
.tta-chat-empty-storage-notice__more {
border: 0;
background: transparent;
color: inherit !important;
cursor: pointer;
font: inherit !important;
margin: 0;
padding: 0;
text-decoration-line: underline;
text-decoration-style: dashed;
text-decoration-color: #bbb;
text-decoration-thickness: from-font;
text-underline-offset: 0.2em;
@at-root .excalidraw.theme--dark #{&} {
text-decoration-color: #555;
}
&:hover {
color: var(--text-primary-color);
}
&:focus-visible {
outline: none;
box-shadow: 0 1px 0 0 currentColor;
}
}
.tta-chat-empty-storage-dialog {
color: var(--text-secondary-color);
font-size: 0.875rem;
line-height: 1.5;
p {
margin: 0;
}
}
+70 -239
View File
@@ -1,204 +1,30 @@
import { useState, type ReactNode } from "react";
import {
DiagramToCodePlugin,
exportToBlob,
getNonDeletedElements,
getTextFromElements,
MIME_TYPES,
TTADialog,
TTADefaultTransportAdapter,
TTDDefaultTransportAdapter,
parseSSEStream,
TTDDialog,
TTDRateLimitWarningContent,
useI18n,
TTDStreamFetch,
} from "@excalidraw/excalidraw";
import Trans from "@excalidraw/excalidraw/components/Trans";
import { Dialog } from "@excalidraw/excalidraw/components/Dialog";
import { getDataURL } from "@excalidraw/excalidraw/data/blob";
import { formatTimeToHourMinute, safelyParseJSON } from "@excalidraw/common";
import { safelyParseJSON } from "@excalidraw/common";
import type { StreamChunk } from "@excalidraw/excalidraw";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { TTDIndexedDBAdapter } from "../data/TTDStorage";
import "./AI.scss";
const TTA_LIMIT_UPSELL_URL = `${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=aiChatBanner#excalidraw-redirect`;
const TTD_LIMIT_UPSELL_URL = `${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=ttdChatBanner#excalidraw-redirect`;
const AI_BACKEND_BASE_URL = import.meta.env.VITE_APP_AI_BACKEND?.replace(
/\/$/,
"",
);
const postJson =
(url: string) =>
async ({
method,
headers,
payload,
signal,
}: {
method: "POST";
headers: Record<string, string>;
payload: unknown;
signal?: AbortSignal;
}) =>
fetch(url, {
method,
headers,
body: JSON.stringify(payload),
signal,
});
const postJsonWithoutSignal =
(url: string) =>
async ({
method,
headers,
payload,
}: {
method: "POST";
headers: Record<string, string>;
payload: unknown;
}) =>
fetch(url, {
method,
headers,
body: JSON.stringify(payload),
});
const ttdTransport = new TTDDefaultTransportAdapter({
stream: postJson(
`${AI_BACKEND_BASE_URL}/v1/ai/text-to-diagram/chat-streaming`,
),
});
const ttaTransport = new TTADefaultTransportAdapter({
stream: postJson(`${AI_BACKEND_BASE_URL}/v1/ai/tta/generate/stream`),
truncate: postJsonWithoutSignal(
`${AI_BACKEND_BASE_URL}/v1/ai/tta/chat/truncate`,
),
});
const TTASystemRateLimitWarning = ({
title,
timestamp,
children,
}: {
title: string;
timestamp: string;
children: ReactNode;
}) => (
<div
style={{
display: "flex",
justifyContent: "flex-start",
width: "100%",
paddingBottom: "0.5rem",
}}
>
<div
style={{
maxWidth: "80%",
minWidth: "6rem",
padding: "0.75rem 1rem",
borderRadius: "var(--border-radius-md)",
background: "var(--color-warning)",
color: "var(--color-warning-color)",
boxShadow: "var(--chat-msg-shadow)",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: "1rem",
marginBottom: "0.5rem",
fontSize: "0.75rem",
}}
>
<span style={{ fontWeight: 600 }}>{title}</span>
<span style={{ fontSize: "0.625rem" }}>{timestamp}</span>
</div>
<div style={{ fontFamily: "monospace" }}>{children}</div>
</div>
</div>
);
const TTAWelcomeScreen = () => {
const { t } = useI18n();
const [isStorageInfoDialogOpen, setIsStorageInfoDialogOpen] = useState(false);
return (
<>
<div className="tta-chat-empty-tagline">
{t("ai.chat.emptyState.guidance")}
</div>
<div className="tta-chat-empty-storage-notice">
{t("ai.chat.emptyState.storageNotice")}{" "}
<button
type="button"
className="tta-chat-empty-storage-notice__more"
onClick={() => setIsStorageInfoDialogOpen(true)}
>
{t("ai.chat.emptyState.seeMore")}
</button>
</div>
{isStorageInfoDialogOpen && (
<Dialog
title={t("ai.chat.emptyState.storageDialog.title")}
size="small"
onCloseRequest={() => setIsStorageInfoDialogOpen(false)}
>
<div className="tta-chat-empty-storage-dialog">
<p>
<Trans
i18nKey="ai.chat.emptyState.storageDialog.description"
link={(el) => (
<a
target="_blank"
rel="noopener"
href={`${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=aiUsageNotice#excalidraw-redirect`}
>
{el}
</a>
)}
br={() => (
<>
<br />
<br />
</>
)}
/>
</p>
</div>
</Dialog>
)}
</>
);
};
export const AIComponents = ({
excalidrawAPI,
}: {
excalidrawAPI: ExcalidrawImperativeAPI;
}) => {
const { t } = useI18n();
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
@@ -227,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({
@@ -270,74 +96,79 @@ 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,
};
}}
/>
<TTDDialog
transportAdapter={ttdTransport}
renderWarning={(warning) => {
return (
<TTDRateLimitWarningContent
warning={warning}
onUpgrade={() => {
window.open(TTD_LIMIT_UPSELL_URL, "_blank", "noopener");
}}
/>
);
onTextSubmit={async (props) => {
const { onChunk, onStreamCreated, signal, messages } = props;
const result = await TTDStreamFetch({
url: `${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/text-to-diagram/chat-streaming`,
messages,
onChunk,
onStreamCreated,
extractRateLimits: true,
signal,
});
return result;
}}
persistenceAdapter={TTDIndexedDBAdapter}
/>
<TTADialog
transportAdapter={ttaTransport}
maxImages={1}
renderWelcomeScreen={() => <TTAWelcomeScreen />}
onMaxImages={() => (
<Trans
i18nKey="ai.chat.imageLimit"
link={(el) => (
<a
target="_blank"
rel="noopener"
href={`${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=ttt-imageLimit#excalidraw-redirect`}
>
{el}
</a>
)}
/>
)}
renderWarning={(warning, message) => {
return (
<TTASystemRateLimitWarning
title={t("chat.role.system")}
timestamp={formatTimeToHourMinute(
message.createdAt ?? Date.now(),
)}
>
<TTDRateLimitWarningContent
warning={warning}
onUpgrade={() => {
window.open(TTA_LIMIT_UPSELL_URL, "_blank", "noopener");
}}
/>
</TTASystemRateLimitWarning>
);
}}
/>
</>
);
};
+3 -1
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";
@@ -22,6 +23,7 @@ export const AppMainMenu: React.FC<{
theme: Theme | "system";
refresh: () => void;
}> = React.memo((props) => {
const { t } = useI18n();
return (
<MainMenu>
<MainMenu.DefaultItems.LoadScene />
@@ -56,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
@@ -72,7 +72,7 @@ export const AppWelcomeScreen: React.FC<{
shortcut={null}
icon={loginIcon}
>
Sign up
{t("labels.signUp")}
</WelcomeScreen.Center.MenuItemLink>
)}
</WelcomeScreen.Center.Menu>
+6 -5
View File
@@ -27,6 +27,7 @@ import {
} from "idb-keyval";
import { getNonDeletedElements } from "@excalidraw/element";
import { CURRENT_SCHEMA_VERSION } from "@excalidraw/element";
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
@@ -46,8 +47,6 @@ import { FileStatusStore } from "./fileStatusStore";
import { Locker } from "./Locker";
import { updateBrowserStateVersion } from "./tabSync";
import { getPersistableElements } from "./index";
const filesStore = createStore("files-db", "files-store");
export const localStorageQuotaExceededAtom = atom(false);
@@ -91,9 +90,11 @@ const saveDataStateToLocalStorage = (
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
// like the collab/Firebase path, never persist TTA intermediate
// streaming-preview elements (tta_rewrite_final.md §2.4/N3)
JSON.stringify(getPersistableElements(getNonDeletedElements(elements))),
JSON.stringify(getNonDeletedElements(elements)),
);
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_SCHEMA_VERSION,
JSON.stringify(CURRENT_SCHEMA_VERSION),
);
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
+16 -16
View File
@@ -7,6 +7,7 @@ import {
} from "@excalidraw/excalidraw/data/encryption";
import { restoreElements } from "@excalidraw/excalidraw/data/restore";
import { getSceneVersion } from "@excalidraw/element";
import { CURRENT_SCHEMA_VERSION, getSchemaVersion } from "@excalidraw/element";
import { initializeApp } from "firebase/app";
import {
getFirestore,
@@ -32,7 +33,7 @@ import type {
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
import { getPersistableElements, getSyncableElements } from ".";
import { getSyncableElements } from ".";
import type { SyncableExcalidrawElement } from ".";
import type Portal from "../collab/Portal";
@@ -86,6 +87,7 @@ export const loadFirebaseStorage = async () => {
type FirebaseStoredScene = {
sceneVersion: number;
schemaVersion?: number;
iv: Bytes;
ciphertext: Bytes;
};
@@ -133,7 +135,7 @@ export const isSavedToFirebase = (
elements: readonly ExcalidrawElement[],
): boolean => {
if (portal.socket && portal.roomId && portal.roomKey) {
const sceneVersion = getSceneVersion(getPersistableElements(elements));
const sceneVersion = getSceneVersion(elements);
return FirebaseSceneVersionCache.get(portal.socket) === sceneVersion;
}
@@ -175,14 +177,11 @@ const createFirebaseSceneDocument = async (
elements: readonly SyncableExcalidrawElement[],
roomKey: string,
) => {
const persistableElements = getPersistableElements(elements);
const sceneVersion = getSceneVersion(persistableElements);
const { ciphertext, iv } = await encryptElements(
roomKey,
persistableElements,
);
const sceneVersion = getSceneVersion(elements);
const { ciphertext, iv } = await encryptElements(roomKey, elements);
return {
sceneVersion,
schemaVersion: CURRENT_SCHEMA_VERSION,
ciphertext: Bytes.fromUint8Array(new Uint8Array(ciphertext)),
iv: Bytes.fromUint8Array(iv),
} as FirebaseStoredScene;
@@ -204,7 +203,6 @@ export const saveToFirebase = async (
return null;
}
const persistableElements = getPersistableElements(elements);
const firestore = _getFirestore();
const docRef = doc(firestore, "scenes", roomId);
@@ -212,10 +210,7 @@ export const saveToFirebase = async (
const snapshot = await transaction.get(docRef);
if (!snapshot.exists()) {
const storedScene = await createFirebaseSceneDocument(
persistableElements,
roomKey,
);
const storedScene = await createFirebaseSceneDocument(elements, roomKey);
transaction.set(docRef, storedScene);
@@ -224,11 +219,13 @@ export const saveToFirebase = async (
const prevStoredScene = snapshot.data() as FirebaseStoredScene;
const prevStoredElements = getSyncableElements(
restoreElements(await decryptElements(prevStoredScene, roomKey), null),
restoreElements(await decryptElements(prevStoredScene, roomKey), null, {
schemaVersion: getSchemaVersion(prevStoredScene),
}),
);
const reconciledElements = getSyncableElements(
reconcileElements(
persistableElements,
elements,
prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
appState,
),
@@ -246,7 +243,9 @@ export const saveToFirebase = async (
});
const storedElements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null),
restoreElements(await decryptElements(storedScene, roomKey), null, {
schemaVersion: getSchemaVersion(storedScene),
}),
);
FirebaseSceneVersionCache.set(socket, storedElements);
@@ -269,6 +268,7 @@ export const loadFromFirebase = async (
const elements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null, {
deleteInvisibleElements: true,
schemaVersion: getSchemaVersion(storedScene),
}),
);
+5 -14
View File
@@ -8,9 +8,9 @@ import {
IV_LENGTH_BYTES,
} from "@excalidraw/excalidraw/data/encryption";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import { isIntermediatePreviewElement } from "@excalidraw/excalidraw/TTA/insertAISkeletons";
import { isInvisiblySmallElement } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element";
import { getSchemaVersion } from "@excalidraw/element";
import { t } from "@excalidraw/excalidraw/i18n";
import { bytesToHexString } from "@excalidraw/common";
@@ -44,19 +44,6 @@ import type { WS_SUBTYPES } from "../app_constants";
export type SyncableExcalidrawElement = OrderedExcalidrawElement &
MakeBrand<"SyncableExcalidrawElement">;
export { isIntermediatePreviewElement };
/**
* Filters out TTA intermediate streaming-preview elements — real scene
* elements that must never be persisted (tta_rewrite_final.md §2.4/N3).
*/
export const getPersistableElements = <TElement extends ExcalidrawElement>(
elements: readonly TElement[],
) =>
elements.filter(
(element) => !isIntermediatePreviewElement(element),
) as TElement[];
export const isSyncableElement = (
element: OrderedExcalidrawElement,
): element is SyncableExcalidrawElement => {
@@ -98,12 +85,14 @@ export type SocketUpdateDataSource = {
type: WS_SUBTYPES.INIT;
payload: {
elements: readonly OrderedExcalidrawElement[];
schemaVersion?: number;
};
};
SCENE_UPDATE: {
type: WS_SUBTYPES.UPDATE;
payload: {
elements: readonly OrderedExcalidrawElement[];
schemaVersion?: number;
};
};
MOUSE_LOCATION: {
@@ -210,6 +199,7 @@ const legacy_decodeFromBackend = async ({
return {
elements: data.elements || null,
appState: data.appState || null,
schemaVersion: getSchemaVersion(data),
};
};
@@ -240,6 +230,7 @@ export const importFromBackend = async (
return {
elements: data.elements || null,
appState: data.appState || null,
schemaVersion: getSchemaVersion(data),
};
} catch (error: any) {
console.warn(
+18 -1
View File
@@ -3,6 +3,8 @@ import {
getDefaultAppState,
} from "@excalidraw/excalidraw/appState";
import { getSchemaVersion } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { AppState } from "@excalidraw/excalidraw/types";
@@ -37,10 +39,14 @@ export const importUsernameFromLocalStorage = (): string | null => {
export const importFromLocalStorage = () => {
let savedElements = null;
let savedState = null;
let savedSchemaVersion = null;
try {
savedElements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
savedState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
savedSchemaVersion = localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_SCHEMA_VERSION,
);
} catch (error: any) {
// Unable to access localStorage
console.error(error);
@@ -56,6 +62,17 @@ export const importFromLocalStorage = () => {
}
}
let schemaVersion: number | undefined;
if (savedSchemaVersion) {
try {
schemaVersion = getSchemaVersion({
schemaVersion: JSON.parse(savedSchemaVersion),
});
} catch (error: any) {
console.error(error);
}
}
let appState = null;
if (savedState) {
try {
@@ -70,7 +87,7 @@ export const importFromLocalStorage = () => {
// Do nothing because appState is already null
}
}
return { elements, appState };
return { elements, appState, schemaVersion };
};
export const getElementsStorageSize = () => {
+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]);
};
+3 -4
View File
@@ -117,6 +117,9 @@
}
</script>
<!-- Following placeholder is replaced during the build step -->
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
<!-- Register Assistant as the UI font, before the scene inits -->
<link
rel="stylesheet"
@@ -130,10 +133,6 @@
</script>
<% } %>
<!-- Following placeholder is replaced during PROD build step -->
<!-- see scripts/woff2/woff2-vite-plugins.js -->
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
+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)", () => {
+104 -2
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,
@@ -96,12 +99,17 @@ export const applyDarkModeFilter = (color: string, enable = true): 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);
@@ -113,6 +121,44 @@ export const applyDarkModeFilter = (color: string, enable = true): 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
// ---------------------------------------------------------------------------
@@ -184,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,
@@ -202,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,
@@ -282,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
@@ -299,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
// -----------------------------------------------------------------------------
+51
View File
@@ -6,6 +6,8 @@ import type { AppProps, AppState } 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;
@@ -69,6 +71,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,6 +214,50 @@ 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;
export const DEFAULT_TEXT_ALIGN = "left";
export const DEFAULT_VERTICAL_ALIGN = "top";
@@ -371,6 +418,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
@@ -496,8 +544,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 = {
+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",
+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);
+53 -38
View File
@@ -48,26 +48,23 @@ export const getDateTime = () => {
export const capitalizeString = (str: string) =>
str.charAt(0).toUpperCase() + str.slice(1);
export const formatTimeToHourMinute = (
value: Date | number,
opts?: { locale?: string | string[]; hour12?: boolean },
) => {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return "";
}
return date.toLocaleTimeString(opts?.locale, {
hour: "2-digit",
minute: "2-digit",
...(opts?.hour12 === undefined ? {} : { hour12: opts.hour12 }),
});
};
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,
@@ -76,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"))
);
};
@@ -96,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,
@@ -491,12 +504,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 &&
@@ -508,7 +523,7 @@ export const getNearestScrollableContainer = (
}
parent = parent.parentElement;
}
return document;
return ownerDocument;
};
export const focusNearestParent = (element: HTMLInputElement) => {
@@ -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);
});
});
});
@@ -0,0 +1,88 @@
import {
CURRENT_SCHEMA_VERSION,
getSchemaVersion,
migrateElement,
migrateElementPartial,
migrateElements,
} from "../versioning";
describe("scene schema versioning", () => {
describe("getSchemaVersion", () => {
it("treats a container without schemaVersion as legacy (version 0)", () => {
expect(getSchemaVersion({})).toBe(0);
expect(getSchemaVersion({ schemaVersion: undefined })).toBe(0);
expect(getSchemaVersion(null)).toBe(0);
expect(getSchemaVersion(undefined)).toBe(0);
});
it("treats an invalid schemaVersion as legacy (version 0)", () => {
expect(getSchemaVersion({ schemaVersion: "2" })).toBe(0);
expect(getSchemaVersion({ schemaVersion: NaN })).toBe(0);
expect(getSchemaVersion({ schemaVersion: Infinity })).toBe(0);
});
it("returns the container's schemaVersion when present", () => {
expect(getSchemaVersion({ schemaVersion: 1 })).toBe(1);
expect(getSchemaVersion({ schemaVersion: 3 })).toBe(3);
});
});
describe("migrateElements", () => {
it("lifts legacy elements through the whole migration chain", () => {
const legacy = [{ id: "a", type: "rectangle", backgroundColor: "#fff" }];
const migrated = migrateElements(legacy, 0);
// v0 -> v1 migration keeps the element shape intact
expect(migrated).toEqual(legacy);
});
it("returns the input array unchanged when already current", () => {
const elements = [{ id: "a", type: "rectangle" }];
expect(migrateElements(elements, CURRENT_SCHEMA_VERSION)).toBe(elements);
});
it("returns the input array unchanged when coming from a newer client", () => {
const elements = [{ id: "a", type: "rectangle" }];
expect(migrateElements(elements, CURRENT_SCHEMA_VERSION + 1)).toBe(
elements,
);
});
});
describe("migrateElement", () => {
it("does not mutate the input element", () => {
const legacy = { id: "a", type: "rectangle" };
const migrated = migrateElement(legacy, 0);
expect(migrated).toEqual({ id: "a", type: "rectangle" });
expect(legacy).toEqual({ id: "a", type: "rectangle" });
});
});
describe("migrateElementPartial", () => {
it("lifts a legacy delta partial to the current schema version", () => {
const partial = { backgroundColor: "#fff", version: 2 };
expect(migrateElementPartial(partial, 0)).toEqual(partial);
});
it("returns the partial unchanged when already current", () => {
const partial = { backgroundColor: "#fff" };
expect(migrateElementPartial(partial, CURRENT_SCHEMA_VERSION)).toBe(
partial,
);
});
});
// Template for future migrations
it("runs migrations in sequence up to the current version", () => {
const migrated = migrateElement({ id: "a" }, 0);
expect(migrated).toEqual({ id: "a" });
});
});
+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 }, arrow);
// 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,
};
};
+18 -2
View File
@@ -28,6 +28,7 @@ import type {
ExcalidrawArrowElement,
ExcalidrawBindableElement,
NonDeleted,
FixedPointBinding,
NonDeletedSceneElementsMap,
PointsPositionUpdates,
} from "../types";
@@ -111,10 +112,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) {
@@ -164,7 +172,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",
@@ -340,6 +348,7 @@ export const handleFocusPointPointerDown = (
): {
hitFocusPoint: "start" | "end" | null;
pointerOffset: { x: number; y: number };
arrowOtherEndpointInitialBinding: FixedPointBinding | null;
} => {
const pointerPos = pointFrom(
pointerDownState.origin.x,
@@ -377,6 +386,7 @@ export const handleFocusPointPointerDown = (
x: pointerPos[0] - focusPoint[0],
y: pointerPos[1] - focusPoint[1],
},
arrowOtherEndpointInitialBinding: arrow.endBinding,
};
}
}
@@ -412,6 +422,7 @@ export const handleFocusPointPointerDown = (
x: pointerPos[0] - focusPoint[0],
y: pointerPos[1] - focusPoint[1],
},
arrowOtherEndpointInitialBinding: arrow.startBinding,
};
}
}
@@ -420,6 +431,7 @@ export const handleFocusPointPointerDown = (
return {
hitFocusPoint: null,
pointerOffset: { x: 0, y: 0 },
arrowOtherEndpointInitialBinding: null,
};
};
@@ -484,6 +496,10 @@ export const handleFocusPointPointerUp = (
],
});
}
return {
arrowOtherEndpointInitialBinding: null,
};
};
export const handleFocusPointHover = (
+126 -65
View File
@@ -13,6 +13,7 @@ import {
pointDistance,
pointDistanceSq,
pointFrom,
pointFromArray,
pointFromVector,
pointRotateRads,
pointsEqual,
@@ -122,7 +123,9 @@ export const FOCUS_POINT_SIZE = 10 / 1.5;
const MIN_BINDABLE_SIZE = 1;
export const getBindingGap = (
bindTarget: ExcalidrawBindableElement,
// only the stroke width is needed, so the gap can also be computed for a
// bind target that doesn't exist yet (see `getTextBindingForArrowEndpoint`)
bindTarget: Pick<ExcalidrawBindableElement, "strokeWidth">,
opts: Pick<ExcalidrawArrowElement, "elbowed">,
): number => {
return (
@@ -758,6 +761,9 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
threshold: 0,
overrideShouldTestInside: true,
});
const otherEndpointInitialBinding =
appState.selectedLinearElement?.initialState
.arrowOtherEndpointInitialBinding;
// Handle outside-outside binding to the same element
if (
@@ -773,10 +779,18 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
? globalPoint
: // NOTE: Can only affect the start point because new arrows always drag the end point
opts?.newArrow
? getGridPoint(
appState.selectedLinearElement!.initialState.origin![0],
appState.selectedLinearElement!.initialState.origin![1],
opts.gridSize as NullableGridSize,
? pointFromArray<GlobalPoint>(
getGridPoint(
appState.selectedLinearElement!.initialState.origin![0],
appState.selectedLinearElement!.initialState.origin![1],
opts.gridSize as NullableGridSize,
),
)!
: otherBindableElement
? getGlobalFixedPointForBindableElement(
otherBinding.fixedPoint,
otherBindableElement,
elementsMap,
)
: LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
@@ -789,6 +803,12 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
element: hit,
focusPoint: endDragged
? globalPoint
: otherBindableElement
? getGlobalFixedPointForBindableElement(
otherBinding.fixedPoint,
otherBindableElement,
elementsMap,
)
: LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
-1,
@@ -878,36 +898,53 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
threshold: maxBindingDistance_simple(appState.zoom),
overrideShouldTestInside: true,
});
const otherPointWasInsideAtStart =
otherEndpointInitialBinding?.mode === "inside";
const otherNeverOverride = opts?.newArrow
? appState.selectedLinearElement?.initialState.arrowStartIsInside
: otherBinding?.mode === "inside";
const other: BindingStrategy = !otherNeverOverride
? otherBindableElement &&
: otherBinding?.mode === "inside" && otherPointWasInsideAtStart;
let other: BindingStrategy = { mode: undefined };
if (!otherNeverOverride) {
if (
otherBinding?.mode === "inside" &&
!otherPointWasInsideAtStart &&
otherBindableElement &&
!opts?.newArrow
) {
other = {
mode: "orbit",
element: otherBindableElement,
focusPoint: otherFocusPoint || otherEndpoint,
};
} else if (
otherBindableElement &&
!otherFocusPointIsInElement &&
!pointIsCloseToOtherElement &&
appState.selectedLinearElement?.initialState.altFocusPoint
? {
mode: "orbit",
element: otherBindableElement,
focusPoint: appState.selectedLinearElement.initialState.altFocusPoint,
}
: opts?.angleLocked && otherBindableElement
? {
mode: "orbit",
element: otherBindableElement,
focusPoint:
projectFixedPointOntoDiagonal(
arrow,
otherEndpoint,
otherBindableElement,
startDragged ? "end" : "start",
elementsMap,
appState.zoom,
false,
) || otherEndpoint,
}
: { mode: undefined }
: { mode: undefined };
) {
other = {
mode: "orbit",
element: otherBindableElement,
focusPoint: appState.selectedLinearElement.initialState.altFocusPoint,
};
} else if (opts?.angleLocked && otherBindableElement) {
other = {
mode: "orbit",
element: otherBindableElement,
focusPoint:
projectFixedPointOntoDiagonal(
arrow,
otherEndpoint,
otherBindableElement,
startDragged ? "end" : "start",
elementsMap,
appState.zoom,
appState.isMidpointSnappingEnabled,
) || otherEndpoint,
};
}
}
return {
start: startDragged ? current : other,
@@ -1063,6 +1100,32 @@ export const bindOrUnbindBindingElements = (
});
};
/**
* Writes a binding onto the arrow and records the arrow on the bind target,
* keeping the two sides of the relationship in step.
*/
const applyBinding = (
arrow: NonDeleted<ExcalidrawArrowElement>,
bindableElement: NonDeleted<ExcalidrawBindableElement>,
binding: FixedPointBinding,
startOrEnd: "start" | "end",
scene: Scene,
): void => {
scene.mutateElement(arrow, {
[startOrEnd === "start" ? "startBinding" : "endBinding"]: binding,
});
const boundElementsMap = arrayToMap(bindableElement.boundElements || []);
if (!boundElementsMap.has(arrow.id)) {
scene.mutateElement(bindableElement, {
boundElements: (bindableElement.boundElements || []).concat({
id: arrow.id,
type: "arrow",
}),
});
}
};
export const bindBindingElement = (
arrow: NonDeleted<ExcalidrawArrowElement>,
hoveredElement: NonDeleted<ExcalidrawBindableElement>,
@@ -1104,19 +1167,7 @@ export const bindBindingElement = (
};
}
scene.mutateElement(arrow, {
[startOrEnd === "start" ? "startBinding" : "endBinding"]: binding,
});
const boundElementsMap = arrayToMap(hoveredElement.boundElements || []);
if (!boundElementsMap.has(arrow.id)) {
scene.mutateElement(hoveredElement, {
boundElements: (hoveredElement.boundElements || []).concat({
id: arrow.id,
type: "arrow",
}),
});
}
applyBinding(arrow, hoveredElement, binding, startOrEnd, scene);
};
export const unbindBindingElement = (
@@ -2220,33 +2271,17 @@ export const fixDuplicatedBindingsAfterDuplication = (
if (isElbowArrow(duplicateElement)) {
Object.assign(
duplicateElement,
normalizeElbowArrow(duplicateElement, duplicateElementsMap),
updateElbowArrowPoints(duplicateElement, duplicateElementsMap, {
points: [
duplicateElement.points[0],
duplicateElement.points[duplicateElement.points.length - 1],
],
}),
);
}
}
};
export const normalizeElbowArrow = (
arrow: ExcalidrawElbowArrowElement,
elementsMap: NonDeletedSceneElementsMap,
) =>
updateElbowArrowPoints(arrow, elementsMap, {
points: [arrow.points[0], arrow.points[arrow.points.length - 1]],
});
export const normalizeElbowArrows = <T extends ExcalidrawElement>(
elements: readonly T[],
elementsMap?: NonDeletedSceneElementsMap,
) => {
const map =
elementsMap ?? (arrayToMap(elements) as NonDeletedSceneElementsMap);
return elements.map((element) =>
isElbowArrow(element)
? { ...element, ...normalizeElbowArrow(element, map) }
: element,
);
};
export const fixBindingsAfterDeletion = (
sceneElements: readonly ExcalidrawElement[],
deletedElements: readonly ExcalidrawElement[],
@@ -3135,3 +3170,29 @@ export const getBindingSideMidPoint = (
const getMidPoint = (p1: GlobalPoint, p2: GlobalPoint): GlobalPoint => {
return pointFrom((p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2);
};
/**
* Binds an arrow endpoint to an explicit fixed point on a bindable element,
* bypassing the "derive the ratio from where the endpoint currently is"
* strategy used when dragging. Needed when the binding target is placed to fit
* the arrow rather than the other way round.
*/
export const bindBindingElementToFixedPoint = (
arrow: NonDeleted<ExcalidrawArrowElement>,
bindableElement: NonDeleted<ExcalidrawBindableElement>,
startOrEnd: "start" | "end",
fixedPoint: FixedPoint,
scene: Scene,
): void => {
applyBinding(
arrow,
bindableElement,
{
elementId: bindableElement.id,
fixedPoint: normalizeFixedPoint(fixedPoint),
mode: "orbit",
},
startOrEnd,
scene,
);
};
+1
View File
@@ -420,6 +420,7 @@ const _isRectanguloidElement = (
return (
element != null &&
(element.type === "rectangle" ||
element.type === "stickynote" ||
element.type === "image" ||
element.type === "iframe" ||
element.type === "embeddable" ||
File diff suppressed because it is too large Load Diff
+1
View File
@@ -467,6 +467,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":
+19 -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,11 @@ 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";
@@ -46,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;
};
+205 -37
View File
@@ -36,6 +36,8 @@ import type {
import { getObservedAppState } from "./store";
import { CURRENT_SCHEMA_VERSION, migrateElementPartial } from "./versioning";
import {
BoundElement,
BindableElement,
@@ -47,6 +49,7 @@ import { mutateElement, newElementWith } from "./mutateElement";
import { getBoundTextElementId, redrawTextBoundingBox } from "./textElement";
import {
hasBoundTextElement,
isArrowElement,
isBindableElement,
isBoundToContainer,
isTextElement,
@@ -1084,9 +1087,33 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
return delta;
}
public static restore(elementsDeltaDTO: DTO<ElementsDelta>): ElementsDelta {
public static restore(
elementsDeltaDTO: DTO<ElementsDelta>,
schemaVersion: number = CURRENT_SCHEMA_VERSION,
): ElementsDelta {
const { added, removed, updated } = elementsDeltaDTO;
return ElementsDelta.create(added, removed, updated);
return ElementsDelta.create(
ElementsDelta.migratePartialDeltas(added, schemaVersion),
ElementsDelta.migratePartialDeltas(removed, schemaVersion),
ElementsDelta.migratePartialDeltas(updated, schemaVersion),
);
}
private static migratePartialDeltas(
deltas: Record<string, Delta<ElementPartial>>,
fromVersion: number,
): Record<string, Delta<ElementPartial>> {
if (fromVersion >= CURRENT_SCHEMA_VERSION) {
return deltas;
}
return Object.entries(deltas).reduce((acc, [id, delta]) => {
acc[id] = Delta.create(
migrateElementPartial(delta.deleted, fromVersion),
migrateElementPartial(delta.inserted, fromVersion),
);
return acc;
}, {} as Record<string, Delta<ElementPartial>>);
}
private static satisfiesAddition = ({
@@ -1442,6 +1469,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(
@@ -1450,8 +1519,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,
@@ -1460,9 +1574,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 {
@@ -1625,12 +1739,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(
@@ -1687,17 +1797,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;
@@ -1707,29 +1808,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;
}
});
}
/**
+3 -6
View File
@@ -34,6 +34,7 @@ export const distanceToElement = (
switch (element.type) {
case "selection":
case "rectangle":
case "stickynote":
case "image":
case "text":
case "iframe":
@@ -75,9 +76,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 +103,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)),
);
};
+83 -27
View File
@@ -30,7 +30,7 @@ import {
import type { Scene } from "./Scene";
import type { ExcalidrawElement } from "./types";
import type { ExcalidrawElement, ExcalidrawTextElement } from "./types";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
@@ -228,6 +228,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 +359,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 +385,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 +401,6 @@ export const dragNewElement = ({
y: newY + (originOffset?.y ?? 0),
width,
height,
...textAutoResize,
...imageInitialDimension,
},
{ informMutation, isDragging: false },
+1
View File
@@ -75,6 +75,7 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
copy.id = randomId();
copy.updated = getUpdatedTimestamp();
copy.created = copy.updated;
if (randomizeSeed) {
copy.seed = randomInteger();
bumpVersion(copy);
+20 -5
View File
@@ -25,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 {
@@ -230,8 +234,7 @@ const cloneFlowchartNode = (
x: number,
y: number,
) => {
const node = newElement({
type: template.type,
const commonNodeProps = {
x,
y,
width: template.width,
@@ -244,7 +247,19 @@ const cloneFlowchartNode = (
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(node),
@@ -254,7 +269,7 @@ const cloneFlowchartNode = (
return node;
};
const addNewNodes = (
export const addNewNodes = (
startNode: NonDeleted<ExcalidrawFlowchartNodeElement>,
appState: AppState,
direction: LinkDirection,
+20 -4
View File
@@ -6,9 +6,11 @@ import {
} 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,
@@ -65,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];
@@ -81,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,
+5
View File
@@ -57,8 +57,10 @@ export const getNonDeletedElements = <T extends ExcalidrawElement>(
): 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";
@@ -92,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";
@@ -99,6 +102,8 @@ export * from "./transform";
export * from "./transformHandles";
export * from "./typeChecks";
export * from "./utils";
export * from "./versioning";
export * from "./zindex";
export * from "./arrows/helpers";
export * from "./arrowheads";
export * from "./convertToShape";
+368 -45
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";
@@ -30,8 +37,11 @@ import {
moveArrowAboveBindable,
projectFixedPointOntoDiagonal,
type Store,
getLinearElementPathSegments,
} from "@excalidraw/element";
import type { LinearPathSegment } from "@excalidraw/element";
import type { Radians } from "@excalidraw/math";
import type {
@@ -122,6 +132,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 +160,7 @@ export class LinearElementEditor {
};
arrowStartIsInside: boolean;
altFocusPoint: Readonly<GlobalPoint> | null;
arrowOtherEndpointInitialBinding: FixedPointBinding | null;
}>;
/** whether you're dragging a point */
@@ -193,6 +213,7 @@ export class LinearElementEditor {
added: false,
},
arrowStartIsInside: false,
arrowOtherEndpointInitialBinding: null,
altFocusPoint: null,
};
this.hoverPointIndex = -1;
@@ -777,6 +798,7 @@ export class LinearElementEditor {
...editingLinearElement.initialState,
origin: null,
arrowStartIsInside: false,
arrowOtherEndpointInitialBinding: null,
},
};
}
@@ -1028,6 +1050,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;
@@ -1036,6 +1060,7 @@ export class LinearElementEditor {
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
didAddPoint: false,
hitElement: null,
hitBoundText: false,
linearElementEditor: null,
};
@@ -1098,6 +1123,7 @@ export class LinearElementEditor {
!!app.state.newElement &&
(app.state.bindMode === "inside" || app.state.bindMode === "skip"),
altFocusPoint: null,
arrowOtherEndpointInitialBinding: element.startBinding,
},
selectedPointsIndices: [element.points.length - 1],
lastUncommittedPoint: null,
@@ -1108,16 +1134,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;
}
@@ -1160,6 +1221,9 @@ export class LinearElementEditor {
!!app.state.newElement &&
(app.state.bindMode === "inside" || app.state.bindMode === "skip"),
altFocusPoint: null,
arrowOtherEndpointInitialBinding: nextSelectedPointsIndices?.includes(0)
? element.endBinding
: element.startBinding,
},
selectedPointsIndices: nextSelectedPointsIndices,
pointerOffset: targetPoint
@@ -1167,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;
@@ -1348,6 +1412,19 @@ 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: ExcalidrawLinearElement,
elementsMap: ElementsMap,
@@ -1852,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 = (
@@ -2423,22 +2659,21 @@ 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
? updateBoundPoint(
nextArrow,
"endBinding",
nextArrow.endBinding,
endBindable,
elementsMap,
endIsDragged,
) || nextArrow.points[nextArrow.points.length - 1]
: nextArrow.points[nextArrow.points.length - 1];
const endLocalPoint =
endIsDraggingOverStartElement &&
app.state.bindMode !== "inside" &&
getFeatureFlag("COMPLEX_BINDINGS")
? nextArrow.points[0]
: endBindable
? updateBoundPoint(
nextArrow,
"endBinding",
nextArrow.endBinding,
endBindable,
elementsMap,
endIsDragged,
) || nextArrow.points[nextArrow.points.length - 1]
: nextArrow.points[nextArrow.points.length - 1];
// We need to keep the simulated next arrow up-to-date, because
// updateBoundPoint looks at the opposite point
@@ -2472,13 +2707,11 @@ const pointDraggingUpdates = (
: nextArrow.points[0];
const endChanged =
!startIsDraggingOverEndElement &&
!(
endIsDraggingOverStartElement &&
app.state.bindMode !== "inside" &&
getFeatureFlag("COMPLEX_BINDINGS")
) &&
!!endBindable;
) && !!endBindable;
const startChanged =
pointDistance(startLocalPoint, nextArrow.points[0]) !== 0;
@@ -2519,3 +2752,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);
+4 -1
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"
>;
/**
+114 -9
View File
@@ -3,6 +3,8 @@ 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,
@@ -22,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";
@@ -49,6 +55,7 @@ import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawLineElement,
ExcalidrawStickyNoteElement,
} from "./types";
export type ElementConstructorOpts = MarkOptional<
@@ -74,6 +81,7 @@ export type ElementConstructorOpts = MarkOptional<
| "locked"
| "opacity"
| "customData"
| "created"
>;
const _newElementBase = <T extends ExcalidrawElement>(
@@ -123,6 +131,8 @@ const _newElementBase = <T extends ExcalidrawElement>(
});
}
const timestamp = getUpdatedTimestamp();
// assign type to guard against excess properties
const element: Merge<
ExcalidrawGenericElement,
@@ -151,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,
@@ -166,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";
@@ -218,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: {
@@ -229,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,
};
};
@@ -251,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;
@@ -273,6 +367,7 @@ export const newTextElement = (
..._newElementBase<ExcalidrawTextElement>("text", opts),
text,
fontSize,
baseFontSize: opts.baseFontSize ?? null,
fontFamily,
textAlign,
verticalAlign,
@@ -284,6 +379,7 @@ export const newTextElement = (
originalText: opts.originalText ?? text,
autoResize: opts.autoResize ?? true,
lineHeight,
labelPosition: opts.labelPosition ?? null,
};
const textElement: NonDeleted<ExcalidrawTextElement> = newElementWith(
@@ -350,9 +446,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",
},
+341 -58
View File
@@ -1,6 +1,7 @@
import rough from "roughjs/bin/rough";
import {
clamp,
type GlobalPoint,
isRightAngleRads,
lineSegment,
@@ -24,6 +25,10 @@ import {
invariant,
applyDarkModeFilter,
isSafari,
STICKY_NOTE_EDGE_SHADOW_OPACITY,
STICKY_NOTE_EDGE_SHADOW_WIDTH,
STICKY_NOTE_FOOTER,
STICKY_NOTE_SHADOW_OPACITY,
} from "@excalidraw/common";
import type {
@@ -31,9 +36,8 @@ import type {
StaticCanvasAppState,
Zoom,
InteractiveCanvasAppState,
ElementsPendingErasure,
PendingExcalidrawElements,
NormalizedZoomValue,
ElementRenderOverrides,
} from "@excalidraw/excalidraw/types";
import type {
@@ -61,12 +65,18 @@ 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,
@@ -76,7 +86,6 @@ import type {
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
ExcalidrawTextElementWithContainer,
ExcalidrawFrameLikeElement,
NonDeletedSceneElementsMap,
ElementsMap,
} from "./types";
@@ -106,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 {
@@ -315,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,
@@ -322,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":
@@ -583,21 +731,59 @@ 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 [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);
@@ -612,7 +798,7 @@ const drawElementFromCanvas = (
);
// generously covers the arrow's blit at any rotation
const outerHalf =
Math.max(distance(x1, x2), distance(y1, y2)) * window.devicePixelRatio +
Math.max(distance(x1, x2), distance(y1, y2)) * devicePixelRatio +
padding * 10;
context.beginPath();
context.rect(cx - outerHalf, cy - outerHalf, outerHalf * 2, outerHalf * 2);
@@ -620,17 +806,17 @@ const drawElementFromCanvas = (
(boundTextCx -
boundTextElement.width / 2 -
BOUND_TEXT_PADDING +
positionOffset.x +
appState.scrollX) *
window.devicePixelRatio,
devicePixelRatio,
(boundTextCy -
boundTextElement.height / 2 -
BOUND_TEXT_PADDING +
positionOffset.y +
appState.scrollY) *
window.devicePixelRatio,
(boundTextElement.width + BOUND_TEXT_PADDING * 2) *
window.devicePixelRatio,
(boundTextElement.height + BOUND_TEXT_PADDING * 2) *
window.devicePixelRatio,
devicePixelRatio,
(boundTextElement.width + BOUND_TEXT_PADDING * 2) * devicePixelRatio,
(boundTextElement.height + BOUND_TEXT_PADDING * 2) * devicePixelRatio,
);
context.clip("evenodd");
}
@@ -654,16 +840,79 @@ const drawElementFromCanvas = (
// 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!,
(x1 + appState.scrollX) * window.devicePixelRatio -
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
(y1 + appState.scrollY) * window.devicePixelRatio -
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
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" &&
@@ -677,10 +926,10 @@ const drawElementFromCanvas = (
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,
(coords.x + positionOffset.x + appState.scrollX) * devicePixelRatio,
(coords.y + positionOffset.y + appState.scrollY) * devicePixelRatio,
getBoundTextMaxWidth(element, textElement) * devicePixelRatio,
getBoundTextMaxHeight(element, textElement) * devicePixelRatio,
);
}
context.restore();
@@ -696,7 +945,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
@@ -720,20 +972,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": {
@@ -808,12 +1096,14 @@ export const renderElement = (
renderConfig,
appState,
allElementsMap,
renderState.offset,
);
}
break;
}
case "rectangle":
case "stickynote":
case "diamond":
case "ellipse":
case "line":
@@ -918,17 +1208,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.
@@ -965,6 +1247,8 @@ export const renderElement = (
renderConfig,
appState,
allElementsMap,
// The crop editor's uncropped preview stays at document coordinates.
ZERO_RENDER_OFFSET,
);
}
@@ -977,6 +1261,7 @@ export const renderElement = (
renderConfig,
appState,
allElementsMap,
renderState.offset,
);
// reset
@@ -989,8 +1274,6 @@ export const renderElement = (
throw new Error(`Unimplemented type ${element.type}`);
}
}
context.globalAlpha = 1;
};
export function getFreedrawOutlineAsSegments(
+112 -29
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";
@@ -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,18 +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,
shouldResizeFromCenter,
);
updateBoundElements(latestElement, scene);
}
@@ -1441,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) {
@@ -1473,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) {
@@ -1491,7 +1569,11 @@ 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,
@@ -1502,6 +1584,7 @@ export const resizeMultipleElements = (
handleDirection,
true,
shouldResizeFromCenter,
flipByY,
);
}
}
+5 -2
View File
@@ -216,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,
+25 -1
View File
@@ -1,5 +1,5 @@
import { simplify } from "points-on-curve";
import { getStroke } from "perfect-freehand";
import { getStroke, getStrokePoints } from "perfect-freehand";
import { LaserPointer } from "@excalidraw/laser-pointer";
import {
@@ -979,6 +979,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":
@@ -1073,6 +1076,7 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
): GeometricShape<Point> => {
switch (element.type) {
case "rectangle":
case "stickynote":
case "diamond":
case "frame":
case "magicframe":
@@ -1253,6 +1257,26 @@ export const getFreedrawOutlinePoints = (
: getVariableWidthFreedrawOutline(element);
};
/**
* 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
+3 -3
View File
@@ -62,12 +62,12 @@ const defragmentGroups = (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 = arrayToMap(elements);
const sortedElements = new Set<ExcalidrawElement>();
const sortedElements = new Set<T>();
for (const element of elements) {
if (sortedElements.has(element)) {
+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);
};
+18 -10
View File
@@ -19,6 +19,8 @@ import { newElementWith } from "./mutateElement";
import { ElementsDelta, AppStateDelta, Delta } from "./delta";
import { CURRENT_SCHEMA_VERSION, getSchemaVersion } from "./versioning";
import {
syncInvalidIndicesImmutable,
hashElementsVersion,
@@ -499,6 +501,7 @@ export class StoreDelta {
public readonly id: string,
public readonly elements: ElementsDelta,
public readonly appState: AppStateDelta,
public readonly schemaVersion: number = CURRENT_SCHEMA_VERSION,
) {}
/**
@@ -513,7 +516,7 @@ export class StoreDelta {
id: randomId(),
},
) {
return new this(opts.id, elements, appState);
return new this(opts.id, elements, appState, CURRENT_SCHEMA_VERSION);
}
/**
@@ -535,26 +538,31 @@ export class StoreDelta {
}
/**
* Restore a store delta instance from a DTO.
* Restore a store delta instance from a DTO, lifting the contained element
* partials to the current schema version.
*/
public static restore(storeDeltaDTO: DTO<StoreDelta>) {
const { id, elements, appState } = storeDeltaDTO;
return new this(
id,
ElementsDelta.restore(elements),
ElementsDelta.restore(elements, getSchemaVersion(storeDeltaDTO)),
AppStateDelta.restore(appState),
);
}
/**
* Parse and load the delta from the remote payload.
* Parse and load the delta from the remote payload, lifting the contained
* element partials to the current schema version.
*/
public static load({
id,
elements: { added, removed, updated },
appState: { delta: appStateDelta },
}: DTO<StoreDelta>) {
const elements = ElementsDelta.create(added, removed, updated);
public static load(storeDeltaDTO: DTO<StoreDelta>) {
const {
id,
appState: { delta: appStateDelta },
} = storeDeltaDTO;
const elements = ElementsDelta.restore(
storeDeltaDTO.elements,
getSchemaVersion(storeDeltaDTO),
);
const appState = AppStateDelta.create(appStateDelta);
return new this(id, elements, appState);
+84 -48
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,12 +21,15 @@ 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";
@@ -52,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()) {
@@ -147,7 +158,14 @@ export const handleBindTextResize = (
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) {
@@ -192,24 +210,24 @@ export const handleBindTextResize = (
container.type,
);
const diff = containerHeight - container.height;
// fix the y coord when resizing from ne/nw/n
// Crossing the opposite edge swaps the anchor for text-driven growth.
const shouldResizeFromTop =
transformHandleType === "n" ||
transformHandleType === "ne" ||
transformHandleType === "nw";
(transformHandleType === "n" ||
transformHandleType === "ne" ||
transformHandleType === "nw") !== flipByY;
let offsetY = 0;
if (!isArrowElement(container)) {
if (shouldResizeFromCenter) {
offsetY = diff / 2;
} else if (shouldResizeFromTop) {
offsetY = diff;
}
}
scene.mutateElement(container, {
height: containerHeight,
y: container.y - offsetY,
...(!isArrowElement(container) &&
getPositionAfterHeightChange(
container,
containerHeight,
shouldResizeFromCenter
? "center"
: shouldResizeFromTop
? "bottom"
: "top",
)),
});
}
@@ -250,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 +
@@ -266,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,
@@ -333,9 +370,12 @@ export const getContainerElement = <
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)) {
@@ -344,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: ExcalidrawElement) => {
let offsetX = BOUND_TEXT_PADDING;
let offsetY = BOUND_TEXT_PADDING;
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
@@ -455,6 +478,7 @@ export const suppportsHorizontalAlign = (
const VALID_CONTAINER_TYPES = new Set([
"rectangle",
"stickynote",
"ellipse",
"diamond",
"arrow",
@@ -506,7 +530,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 = (
@@ -514,6 +544,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) {
@@ -550,3 +584,5 @@ export const getTextFromElements = (
.join(separator);
return text;
};
export const DEFAULT_BOUND_TEXT_LABEL_POSITION = 0.5;
+5 -9
View File
@@ -103,7 +103,7 @@ export const getApproxMinLineHeight = (
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
};
const PROVIDER_KEY = Symbol.for("excalidraw.textMetricsProvider");
let textMetricsProvider: TextMetricsProvider | undefined;
/**
* Set a custom text metrics provider.
@@ -111,7 +111,7 @@ const PROVIDER_KEY = Symbol.for("excalidraw.textMetricsProvider");
* Useful for overriding the width calculation algorithm where canvas API is not available / desired.
*/
export const setCustomTextMetricsProvider = (provider: TextMetricsProvider) => {
(globalThis as any)[PROVIDER_KEY] = provider;
textMetricsProvider = provider;
};
export interface TextMetricsProvider {
@@ -150,15 +150,11 @@ class CanvasTextMetricsProvider implements TextMetricsProvider {
}
export const getLineWidth = (text: string, font: FontString) => {
let provider: TextMetricsProvider | undefined = (globalThis as any)[
PROVIDER_KEY
];
if (!provider) {
provider = new CanvasTextMetricsProvider();
if (!textMetricsProvider) {
textMetricsProvider = new CanvasTextMetricsProvider();
}
return provider.getLineWidth(text, font);
return textMetricsProvider.getLineWidth(text, font);
};
export const getTextWidth = (text: string, font: FontString) => {
+110 -109
View File
@@ -11,30 +11,42 @@ import {
assertNever,
cloneJSON,
getFontString,
getUpdatedTimestamp,
isDevEnv,
toBrandedType,
getLineHeight,
DEFAULT_STICKY_NOTE_SIZE,
isTransparent,
} from "@excalidraw/common";
import type { MarkOptional, Mutable } from "@excalidraw/common/utility-types";
import type { MarkOptional } from "@excalidraw/common/utility-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, isBindableElement } 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";
@@ -63,8 +75,19 @@ import type {
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;
@@ -75,7 +98,7 @@ export type ValidLinearElement = {
fontFamily?: FontFamilyValues;
textAlign?: TextAlign;
verticalAlign?: VerticalAlign;
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
} & FragmentConstructorOpts;
end?:
| (
| (
@@ -115,9 +138,9 @@ export type ValidLinearElement = {
text: string;
}
) &
Partial<ExcalidrawTextElement>)
Partial<Omit<ExcalidrawTextElement, "created">>)
) &
MarkOptional<ElementConstructorOpts, "x" | "y">;
FragmentConstructorOpts;
start?:
| (
| (
@@ -157,9 +180,9 @@ export type ValidLinearElement = {
text: string;
}
) &
Partial<ExcalidrawTextElement>)
Partial<Omit<ExcalidrawTextElement, "created">>)
) &
MarkOptional<ElementConstructorOpts, "x" | "y">;
FragmentConstructorOpts;
} & Partial<ExcalidrawLinearElement>;
export type ValidContainer =
@@ -172,9 +195,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>,
@@ -186,6 +221,7 @@ export type ExcalidrawElementSkeleton =
y: number;
} & Partial<ExcalidrawLinearElement>)
| ValidContainer
| ValidStickyNote
| ValidLinearElement
| ({
type: "text";
@@ -220,9 +256,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,
@@ -230,7 +276,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, {
@@ -238,6 +287,9 @@ const bindTextToContainer = (
type: "text",
id: textElement.id,
}),
...(stickyInk && stickyInk !== container.strokeColor
? { strokeColor: stickyInk }
: null),
});
redrawTextBoundingBox(textElement, container, scene);
@@ -256,8 +308,8 @@ const bindLinearElementToElement = (
startBoundElement?: ExcalidrawElement;
endBoundElement?: ExcalidrawElement;
} => {
let startBoundElement: ExcalidrawElement | undefined;
let endBoundElement: ExcalidrawElement | undefined;
let startBoundElement;
let endBoundElement;
Object.assign(linearElement, {
startBinding: linearElement?.startBinding || null,
@@ -322,12 +374,6 @@ const bindLinearElementToElement = (
});
break;
}
case "frame": {
if (existingElement && isBindableElement(existingElement)) {
startBoundElement = existingElement;
}
break;
}
default: {
assertNever(
linearElement as never,
@@ -337,9 +383,7 @@ const bindLinearElementToElement = (
}
}
}
}
if (startBoundElement && isBindableElement(startBoundElement)) {
bindBindingElement(
linearElement,
startBoundElement as NonDeleted<ExcalidrawBindableElement>,
@@ -406,12 +450,6 @@ const bindLinearElementToElement = (
});
break;
}
case "frame": {
if (existingElement && isBindableElement(existingElement)) {
endBoundElement = existingElement;
}
break;
}
default: {
assertNever(
linearElement as never,
@@ -421,9 +459,7 @@ const bindLinearElementToElement = (
}
}
}
}
if (endBoundElement && isBindableElement(endBoundElement)) {
bindBindingElement(
linearElement,
endBoundElement as NonDeleted<ExcalidrawBindableElement>,
@@ -541,11 +577,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) {
@@ -644,10 +683,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;
}
@@ -684,8 +745,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,
@@ -784,9 +848,7 @@ export const convertToExcalidrawElements = (
Object.assign(elementInFrame, { frameId: frame.id });
elementInFrame?.boundElements?.forEach((boundElement) => {
const boundElementId =
oldToNewElementIdMap.get(boundElement.id) ?? boundElement.id;
const ele = elementStore.getElement(boundElementId);
const ele = elementStore.getElement(boundElement.id);
if (!ele) {
throw new Error(
`Bound element with id ${boundElement.id} doesn't exist`,
@@ -807,10 +869,10 @@ export const convertToExcalidrawElements = (
maxX = maxX + PADDING;
maxY = maxY + PADDING;
const frameX = frame?.x ?? minX;
const frameY = frame?.y ?? minY;
const frameWidth = frame?.width ?? maxX - minX;
const frameHeight = frame?.height ?? maxY - minY;
const frameX = frame?.x || minX;
const frameY = frame?.y || minY;
const frameWidth = frame?.width || maxX - minX;
const frameHeight = frame?.height || maxY - minY;
Object.assign(frame, {
x: frameX,
@@ -818,75 +880,14 @@ export const convertToExcalidrawElements = (
width: frameWidth,
height: frameHeight,
});
}
// remap old ids of pre-existing relationships (i.e. those not created through
// skeleton transformation)
if (opts?.regenerateIds && oldToNewElementIdMap.size) {
const remapId = (id?: string | null) =>
id ? oldToNewElementIdMap.get(id) ?? id : id;
const remapBoundElements = (
boundElements?: ExcalidrawElement["boundElements"],
) => {
if (!boundElements?.length) {
return boundElements;
}
let didChange = false;
const nextBoundElements = boundElements.map((boundElement) => {
const nextId = remapId(boundElement.id);
if (nextId !== boundElement.id) {
didChange = true;
return { ...boundElement, id: nextId };
}
return boundElement;
});
return didChange ? nextBoundElements : boundElements;
};
for (const element of elementStore.excalidrawElements.values()) {
const mutableElement = element as Mutable<ExcalidrawElement>;
if ("containerId" in mutableElement && mutableElement.containerId) {
mutableElement.containerId =
remapId(mutableElement.containerId) ?? null;
}
if (mutableElement.boundElements?.length) {
mutableElement.boundElements = remapBoundElements(
mutableElement.boundElements,
) as typeof mutableElement.boundElements;
}
if (
"startBinding" in mutableElement &&
mutableElement.startBinding?.elementId
) {
const nextId =
remapId(mutableElement.startBinding.elementId) ??
mutableElement.startBinding.elementId;
mutableElement.startBinding = {
...mutableElement.startBinding,
elementId: nextId,
};
}
if (
"endBinding" in mutableElement &&
mutableElement.endBinding?.elementId
) {
const nextId =
remapId(mutableElement.endBinding.elementId) ??
mutableElement.endBinding.elementId;
mutableElement.endBinding = {
...mutableElement.endBinding,
elementId: nextId,
};
}
if ("frameId" in mutableElement && mutableElement.frameId) {
mutableElement.frameId = remapId(mutableElement.frameId) ?? null;
}
if (
isDevEnv() &&
element.children.length &&
(frame?.x || frame?.y || frame?.width || frame?.height)
) {
console.info(
"User provided frame attributes are being considered, if you find this inaccurate, please remove any of the attributes - x, y, width and height so frame coordinates and dimensions are calculated automatically",
);
}
}
+18 -1
View File
@@ -29,6 +29,7 @@ import type {
ExcalidrawLineElement,
ExcalidrawFlowchartNodeElement,
ExcalidrawLinearElementSubType,
ExcalidrawStickyNoteElement,
} from "./types";
export const isInitializedImageElement = <T extends ExcalidrawElement>(
@@ -69,6 +70,12 @@ export const isTextElement = <T extends ExcalidrawElement>(
return element != null && element.type === "text";
};
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 => {
@@ -182,6 +189,7 @@ export const isBindableElement = <T extends ExcalidrawElement>(
element != null &&
(!element.locked || includeLocked === true) &&
(element.type === "rectangle" ||
element.type === "stickynote" ||
element.type === "diamond" ||
element.type === "ellipse" ||
element.type === "image" ||
@@ -199,6 +207,7 @@ export const isRectanguloidElement = <T extends ExcalidrawElement>(
return (
element != null &&
(element.type === "rectangle" ||
element.type === "stickynote" ||
element.type === "diamond" ||
element.type === "image" ||
element.type === "iframe" ||
@@ -217,6 +226,7 @@ export const isRectangularElement = <T extends ExcalidrawElement>(
return (
element != null &&
(element.type === "rectangle" ||
element.type === "stickynote" ||
element.type === "image" ||
element.type === "text" ||
element.type === "iframe" ||
@@ -235,6 +245,7 @@ export const isTextBindableContainer = <T extends ExcalidrawElement>(
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":
@@ -276,6 +288,7 @@ export const isFlowchartNodeElement = <T extends ExcalidrawElement>(
): element is T & ExcalidrawFlowchartNodeElement => {
return (
element.type === "rectangle" ||
element.type === "stickynote" ||
element.type === "ellipse" ||
element.type === "diamond"
);
@@ -313,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,
@@ -397,6 +413,7 @@ export const canBecomePolygon = (
export const isEligibleFrameChildType = (type: ElementOrToolType) => {
switch (type) {
case "rectangle":
case "stickynote":
case "diamond":
case "ellipse":
case "arrow":
+37 -1
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
@@ -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;
+59 -15
View File
@@ -40,13 +40,14 @@ import { generateLinearCollisionShape } from "./shape";
import { hitElementItself, isPointInElement } from "./collision";
import { LinearElementEditor } from "./linearElementEditor";
import { isRectangularElement } from "./typeChecks";
import { isElbowArrow, isRectangularElement } from "./typeChecks";
import { maxBindingDistance_simple } from "./binding";
import {
getGlobalFixedPointForBindableElement,
normalizeFixedPoint,
} from "./binding";
import { getStickyNoteCornerRadius } from "./stickyNote";
import type {
ElementsMap,
@@ -59,6 +60,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 +128,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 +136,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 +196,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 +244,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 +456,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 +465,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>(
+87
View File
@@ -0,0 +1,87 @@
/**
* The schema version the current in-memory element shape corresponds to.
*/
export const CURRENT_SCHEMA_VERSION = 1;
type SchemaMigration = {
// Upgrades a full element from schema version n to n + 1.
element: (element: any) => any;
// Upgrades a partial element (durable undo/redo).
elementPartial: (partial: any) => any;
};
const identity = <T>(value: T): T => value;
/**
* migrations[n] upgrades data from schema version to n + 1.
*
* NOTE: Index 0 migrates legacy (unversioned) data to version 1.
*
* IMPORTANT: data of unknown provenance (API input, clipboard from older
* builds etc.) is considered 'legacy' v0!
*/
const migrations: Record<number, SchemaMigration> = {
0: { element: identity, elementPartial: identity },
};
/**
* Returns the schema version indicated by a container.
*/
export const getSchemaVersion = (source: unknown): number => {
const schemaVersion = (source as { schemaVersion?: unknown } | null)
?.schemaVersion;
return typeof schemaVersion === "number" && Number.isFinite(schemaVersion)
? schemaVersion
: 0;
};
const runMigrations = <T>(
kind: keyof SchemaMigration,
value: T,
fromVersion: number,
): T => {
let version = fromVersion;
let migrated: any = value;
// NOTE: data from a newer client (fromVersion > current) is returned as-is;
// unknown properties are preserved down the line for forward-compatibility
while (version < CURRENT_SCHEMA_VERSION) {
const migration = migrations[version];
if (!migration) {
break;
}
migrated = migration[kind](migrated);
version += 1;
}
return migrated;
};
/**
* Lifts a full element from `fromVersion` up to
* {@link CURRENT_SCHEMA_VERSION} by running each migration in sequence.
*/
export const migrateElement = <T>(element: T, fromVersion: number): T =>
runMigrations("element", element, fromVersion);
/**
* Lifts all elements of a container from `fromVersion` up to
* {@link CURRENT_SCHEMA_VERSION}.
*/
export const migrateElements = <T>(
elements: readonly T[],
fromVersion: number,
): readonly T[] =>
fromVersion >= CURRENT_SCHEMA_VERSION
? elements
: elements.map((element) => migrateElement(element, fromVersion));
/**
* Lifts a partial element shape (as contained in deltas) from `fromVersion`
* up to {@link CURRENT_SCHEMA_VERSION}.
*/
export const migrateElementPartial = <T>(partial: T, fromVersion: number): T =>
fromVersion >= CURRENT_SCHEMA_VERSION
? partial
: runMigrations("elementPartial", partial, fromVersion);
+3
View File
@@ -480,6 +480,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();
File diff suppressed because it is too large Load Diff
+281 -1
View File
@@ -1,12 +1,292 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { arrayToMap } from "@excalidraw/common";
import { pointFrom } from "@excalidraw/math";
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
import type { LinearElementEditor } from "@excalidraw/element";
import type { SceneElementsMap } from "@excalidraw/element/types";
import type {
ExcalidrawTextElement,
FractionalIndex,
SceneElementsMap,
} from "@excalidraw/element/types";
import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
import { mutateElement, newElementWith } from "../src/mutateElement";
describe("ElementsDelta", () => {
describe("visible changes", () => {
const apply = (before: SceneElementsMap, after: SceneElementsMap) =>
ElementsDelta.calculate(before, after).applyTo(before, before, {
excludedProperties: new Set(["version", "versionNonce"]),
});
const boundText = (
type: "rectangle" | "stickynote" | "arrow",
text = "",
) => {
const container = API.createElement({
type,
id: "container",
index: "a0" as FractionalIndex,
width: 250,
height: type === "arrow" ? 0 : 250,
baseHeight: type === "stickynote" ? 250 : undefined,
points:
type === "arrow" ? [pointFrom(0, 0), pointFrom(250, 0)] : undefined,
boundElements: [{ id: "label", type: "text" }],
});
const label: ExcalidrawTextElement = {
...API.createElement({
type: "text",
id: "label",
index: "a1" as FractionalIndex,
containerId: container.id,
fontSize: 20,
textAlign: "center",
verticalAlign: "middle",
}),
// API.createElement substitutes "test" for an empty string.
text,
originalText: text,
};
const before = arrayToMap([container, label]) as SceneElementsMap;
const after = arrayToMap([
newElementWith(container, { boundElements: [] }),
newElementWith(label, { isDeleted: true }),
]) as SceneElementsMap;
return { container, label, before, after };
};
it.each(["rectangle", "stickynote"] as const)(
"ignores empty-label deletion and restoration on a %s",
(type) => {
const { before, after, container, label } = boundText(type);
const [deleted, deletionIsVisible] = apply(before, after);
expect(deletionIsVisible).toBe(false);
expect(deleted.get(label.id)?.isDeleted).toBe(true);
expect(deleted.get(container.id)?.boundElements).toEqual([]);
const [restored, restorationIsVisible] = apply(after, before);
expect(restorationIsVisible).toBe(false);
expect(restored.get(label.id)?.isDeleted).toBe(false);
expect(restored.get(container.id)?.boundElements).toEqual([
{ id: label.id, type: "text" },
]);
},
);
it("keeps deletion and restoration of nonempty labels visible", () => {
const { before, after } = boundText("stickynote", "hello");
expect(apply(before, after)[1]).toBe(true);
expect(apply(after, before)[1]).toBe(true);
});
it.each(["excluded", "already applied"])(
"ignores %s properties when deciding visibility",
(mode) => {
const element = API.createElement({
type: "rectangle",
index: "a0" as FractionalIndex,
});
const before = arrayToMap([element]) as SceneElementsMap;
const after = arrayToMap([
newElementWith(element, { strokeColor: "red" }),
]) as SceneElementsMap;
const delta = ElementsDelta.calculate(before, after);
const current = mode === "excluded" ? before : after;
const [elements, isVisible] = delta.applyTo(current, current, {
excludedProperties: new Set([
"version",
"versionNonce",
...(mode === "excluded" ? ["strokeColor" as const] : []),
]),
});
expect(elements.get(element.id)?.strokeColor).toBe(
current.get(element.id)?.strokeColor,
);
expect(isVisible).toBe(false);
},
);
it("keeps snapshot restoration visible for a metadata-only update", () => {
const element = API.createElement({
type: "rectangle",
index: "a0" as FractionalIndex,
});
const before = arrayToMap([element]) as SceneElementsMap;
const after = arrayToMap([
newElementWith(element, { version: element.version + 1 }),
]) as SceneElementsMap;
const delta = ElementsDelta.calculate(before, after);
const [elements, isVisible] = delta.applyTo(
new Map() as SceneElementsMap,
before,
);
expect(elements.get(element.id)?.isDeleted).toBe(false);
expect(isVisible).toBe(true);
});
it("keeps an arrow's empty-label gap visible", () => {
const { before, after } = boundText("arrow");
expect(apply(before, after)[1]).toBe(true);
expect(apply(after, before)[1]).toBe(true);
});
it("detects a container resized by restoring an empty label", () => {
const { container, label } = boundText("rectangle");
const smallContainer = newElementWith(container, {
width: 20,
height: 10,
boundElements: [],
});
const before = arrayToMap([
smallContainer,
newElementWith(label, { isDeleted: true }),
]) as SceneElementsMap;
const after = arrayToMap([
newElementWith(smallContainer, {
boundElements: [{ id: label.id, type: "text" }],
}),
newElementWith(label, { isDeleted: false }),
]) as SceneElementsMap;
const [elements, isVisible] = apply(before, after);
expect(elements.get(container.id)?.height).toBeGreaterThan(10);
expect(isVisible).toBe(true);
});
it("detects a bound label repositioned by a container's redraw", () => {
const { container, label } = boundText("rectangle", "hello");
const misplacedLabel = newElementWith(label, { x: -100, y: -100 });
const before = arrayToMap([
container,
misplacedLabel,
]) as SceneElementsMap;
const after = arrayToMap([
newElementWith(container, { version: container.version + 1 }),
misplacedLabel,
]) as SceneElementsMap;
const [elements, isVisible] = apply(before, after);
expect(elements.get(container.id)?.height).toBe(container.height);
expect(elements.get(label.id)?.x).not.toBe(-100);
expect(isVisible).toBe(true);
});
it("detects a bound arrow repositioned by a container's redraw", () => {
const container = API.createElement({
type: "rectangle",
id: "container",
index: "a0" as FractionalIndex,
boundElements: [{ id: "arrow", type: "arrow" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow",
index: "a1" as FractionalIndex,
x: 200,
y: 50,
points: [pointFrom(0, 0), pointFrom(100, 0)],
startBinding: {
elementId: container.id,
mode: "orbit",
fixedPoint: [1, 0.5],
},
});
const before = arrayToMap([container, arrow]) as SceneElementsMap;
const after = arrayToMap([
newElementWith(container, { version: container.version + 1 }),
arrow,
]) as SceneElementsMap;
const [elements, isVisible] = apply(before, after);
expect(elements.get(container.id)?.width).toBe(container.width);
expect(elements.get(arrow.id)?.x).not.toBe(200);
expect(isVisible).toBe(true);
});
it.each(["test", "development", "production"])(
"guards against untracked layout mutations in %s mode",
(mode) => {
const element = API.createElement({
type: "rectangle",
id: "changed",
index: "a0" as FractionalIndex,
});
const unrelated = API.createElement({
type: "rectangle",
id: "unrelated",
index: "a1" as FractionalIndex,
});
const before = arrayToMap([element, unrelated]) as SceneElementsMap;
const after = arrayToMap([
newElementWith(element, { strokeColor: "red" }),
unrelated,
]) as SceneElementsMap;
// Simulate a future layout dependency missing from idsToCheck.
// Mutate the shared instance so the guard must snapshot version values.
const redraw = vi
.spyOn(ElementsDelta, "redrawElements")
.mockImplementationOnce((elements) => {
mutateElement(elements.get(unrelated.id)!, elements, {
x: unrelated.x + 1,
});
return elements;
});
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
vi.stubEnv("MODE", mode);
try {
if (mode === "production") {
expect(apply(before, after)[1]).toBe(true);
expect(consoleError).not.toHaveBeenCalled();
} else {
expect(() => apply(before, after)).toThrow(
'Redrawn element "unrelated" is missing from idsToCheck',
);
}
} finally {
redraw.mockRestore();
consoleError.mockRestore();
vi.unstubAllEnvs();
}
},
);
it("keeps an empty text element's arrow bindings visible", () => {
const label: ExcalidrawTextElement = {
...API.createElement({
type: "text",
id: "label",
index: "a0" as FractionalIndex,
boundElements: [{ id: "arrow", type: "arrow" }],
}),
text: "",
originalText: "",
};
const arrow = API.createElement({
type: "arrow",
id: "arrow",
index: "a1" as FractionalIndex,
startBinding: {
elementId: label.id,
mode: "orbit",
fixedPoint: [1, 0.5],
},
});
const before = arrayToMap([label, arrow]) as SceneElementsMap;
const after = arrayToMap([
newElementWith(label, { isDeleted: true, boundElements: [] }),
newElementWith(arrow, { startBinding: null }),
]) as SceneElementsMap;
expect(apply(before, after)[1]).toBe(true);
});
});
describe("elements delta calculation", () => {
it("should not throw when element gets removed but was already deleted", () => {
const element = API.createElement({
+24 -1
View File
@@ -5,6 +5,7 @@ import {
ORIG_ID,
ROUNDNESS,
isPrimitive,
getUpdatedTimestamp,
} from "@excalidraw/common";
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
@@ -24,7 +25,11 @@ import {
import type { LocalPoint } from "@excalidraw/math";
import { duplicateElement, duplicateElements } from "../src/duplicate";
import {
deepCopyElement,
duplicateElement,
duplicateElements,
} from "../src/duplicate";
import type { ExcalidrawLinearElement } from "../src/types";
@@ -43,6 +48,24 @@ const assertCloneObjects = (source: any, clone: any) => {
};
describe("duplicating single elements", () => {
it.each([123, 0, null])(
"preserves created=%s on a deep copy and resets it for a new instance",
(created) => {
const element = API.createElement({ type: "rectangle", created });
const cloned = deepCopyElement(element);
const duplicate = duplicateElement(null, new Map(), element);
expect(cloned).toEqual(element);
expect(duplicate).toMatchObject({
created: getUpdatedTimestamp(),
version: element.version,
versionNonce: element.versionNonce,
});
expect(duplicate.id).not.toBe(element.id);
expect(element.created).toBe(created);
},
);
it("clones arrow element", () => {
const element = API.createElement({
type: "arrow",
@@ -3,6 +3,7 @@ import { act, queryByTestId, queryByText } from "@testing-library/react";
import { vi } from "vitest";
import {
CURSOR_TYPE,
ROUNDNESS,
VERTICAL_ALIGN,
KEYS,
@@ -26,11 +27,12 @@ import {
import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
import { wrapText } from "../src";
import { hasBoundingBox, wrapText } from "../src";
import * as textElementUtils from "../src/textElement";
import { getBoundTextElementPosition, getBoundTextMaxWidth } from "../src";
import { LinearElementEditor } from "../src";
import { newArrowElement } from "../src";
import { getLinearElementPathSegments } from "../src/utils";
import {
getTextEditor,
@@ -213,14 +215,16 @@ describe("Test Linear Elements", () => {
createTwoPointerLinearElement("line");
const line = h.elements[0] as ExcalidrawLinearElement;
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect((h.elements[0] as ExcalidrawLinearElement).points.length).toEqual(2);
// drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`10`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(3);
expect(line.points).toMatchInlineSnapshot(`
[
@@ -240,6 +244,53 @@ describe("Test Linear Elements", () => {
`);
});
it("should hide the bounding box while creating or dragging a point", () => {
const line = createTwoPointerLinearElement("line");
const editorInterface = {
userAgent: { isMobileDevice: false },
} as Parameters<typeof hasBoundingBox>[2];
fireEvent.pointerDown(interactiveCanvas, {
clientX: midpoint[0],
clientY: midpoint[1],
});
fireEvent.pointerMove(interactiveCanvas, {
clientX: midpoint[0] + delta,
clientY: midpoint[1] + delta,
});
expect(line.points.length).toBe(3);
expect(h.state.selectedLinearElement?.isDragging).toBe(true);
expect(hasBoundingBox([line], h.state, editorInterface)).toBe(false);
fireEvent.pointerUp(interactiveCanvas, {
clientX: midpoint[0] + delta,
clientY: midpoint[1] + delta,
});
const addedPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
line,
1,
h.scene.getNonDeletedElementsMap(),
);
fireEvent.pointerDown(interactiveCanvas, {
clientX: addedPoint[0],
clientY: addedPoint[1],
});
fireEvent.pointerMove(interactiveCanvas, {
clientX: addedPoint[0] + 1,
clientY: addedPoint[1] + 1,
});
expect(h.state.selectedLinearElement?.isDragging).toBe(true);
expect(hasBoundingBox([line], h.state, editorInterface)).toBe(false);
fireEvent.pointerUp(interactiveCanvas, {
clientX: addedPoint[0],
clientY: addedPoint[1],
});
});
it("should allow entering and exiting line editor via context menu", () => {
createTwoPointerLinearElement("line");
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
@@ -260,6 +311,46 @@ describe("Test Linear Elements", () => {
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should allow editing an arrow from the context menu when its bound text appears earlier in the elements array", () => {
const arrow = createTwoPointerLinearElement("arrow");
const textElement = API.createElement({
type: "text",
x: 0,
y: 0,
text: "abc",
containerId: arrow.id,
width: 30,
height: 20,
});
const updatedArrow = {
...arrow,
boundElements: [{ type: "text" as const, id: textElement.id }],
};
// text element intentionally placed before its container in the array
API.setElements([textElement, updatedArrow]);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: midpoint[0],
clientY: midpoint[1],
});
const contextMenu = document.querySelector(".context-menu");
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: midpoint[0],
clientY: midpoint[1],
});
expect(() =>
fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit arrow")!),
).not.toThrow();
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(updatedArrow.id);
});
it("should enter line editor via enter (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
@@ -335,36 +426,14 @@ describe("Test Linear Elements", () => {
await getTextEditor();
});
describe("Arrowhead toggle on endpoint dblclick", () => {
// the toggle replaces the element (immutable update), so always re-read
// the arrow from the scene instead of holding on to a stale reference
const getArrow = () => h.elements[0] as ExcalidrawArrowElement;
it("should toggle end arrowhead between default and none", () => {
createTwoPointerLinearElement("arrow");
expect(getArrow().endArrowhead).toBe(null);
mouse.doubleClickAt(p2[0], p2[1]);
expect(getArrow().endArrowhead).toBe("arrow");
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
mouse.doubleClickAt(p2[0], p2[1]);
expect(getArrow().endArrowhead).toBe(null);
});
it("should toggle start arrowhead between default and none", () => {
createTwoPointerLinearElement("arrow");
expect(getArrow().startArrowhead).toBe(null);
mouse.doubleClickAt(p1[0], p1[1]);
expect(getArrow().startArrowhead).toBe("arrow");
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
mouse.doubleClickAt(p1[0], p1[1]);
expect(getArrow().startArrowhead).toBe(null);
});
it("should restore the arrowhead removed by the previous toggle", () => {
it.each([
["start", null],
["start", "triangle"],
["end", null],
["end", "triangle"],
] as const)(
"should not toggle the %s arrowhead from %s on endpoint dblclick",
async (side, arrowhead) => {
const arrow = API.createElement({
type: "arrow",
x: p1[0],
@@ -372,26 +441,25 @@ describe("Test Linear Elements", () => {
width: p2[0] - p1[0],
height: 0,
points: [pointFrom(0, 0), pointFrom(p2[0] - p1[0], p2[1] - p1[1])],
endArrowhead: "triangle",
startArrowhead: side === "start" ? arrowhead : null,
endArrowhead: side === "end" ? arrowhead : null,
});
API.setElements([arrow]);
mouse.clickAt(midpoint[0], midpoint[1]);
mouse.doubleClickAt(p2[0], p2[1]);
expect(getArrow().endArrowhead).toBe(null);
const endpoint = side === "start" ? p1 : p2;
mouse.doubleClickAt(endpoint[0], endpoint[1]);
mouse.doubleClickAt(p2[0], p2[1]);
expect(getArrow().endArrowhead).toBe("triangle");
});
const updatedArrow = h.elements[0] as ExcalidrawArrowElement;
expect(
side === "start"
? updatedArrow.startArrowhead
: updatedArrow.endArrowhead,
).toBe(arrowhead);
it("should not toggle arrowheads on dblclick outside endpoints", () => {
createTwoPointerLinearElement("arrow");
mouse.doubleClickAt(midpoint[0], midpoint[1]);
expect(getArrow().startArrowhead).toBe(null);
expect(getArrow().endArrowhead).toBe(null);
});
});
await getTextEditor();
},
);
it("shouldn't create text element on double click in line editor (arrow)", async () => {
createTwoPointerLinearElement("arrow");
@@ -438,7 +506,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`,
`14`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -479,7 +547,7 @@ describe("Test Linear Elements", () => {
fireEvent.click(screen.getByTitle("Round"));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`10`,
`11`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@@ -540,7 +608,7 @@ describe("Test Linear Elements", () => {
drag(startPoint, endPoint);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`,
`13`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -608,7 +676,7 @@ describe("Test Linear Elements", () => {
);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`15`,
`18`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
@@ -659,7 +727,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`,
`14`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -700,7 +768,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`,
`14`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -748,7 +816,7 @@ describe("Test Linear Elements", () => {
deletePoint(points[2]);
expect(line.points.length).toEqual(3);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`18`,
`22`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`);
@@ -806,7 +874,7 @@ describe("Test Linear Elements", () => {
),
);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`15`,
`18`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
expect(line.points.length).toEqual(5);
@@ -904,7 +972,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`,
`14`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -1138,6 +1206,93 @@ describe("Test Linear Elements", () => {
});
});
describe("Test getPointAtPathParameter", () => {
it("should survive midpoint insertion", () => {
const arrow = API.createElement({
type: "arrow",
x: 0,
y: 0,
width: 100,
height: 0,
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(100, 0)],
roundness: null,
});
API.setElements([arrow]);
const before = LinearElementEditor.getPointAtPathParameter(
arrow,
0.25,
arrayToMap(h.elements),
)!;
expect(before[0]).toBeCloseTo(25);
expect(before[1]).toBeCloseTo(0);
// inserting a midpoint re-segments the path but must not move the
// point at the same normalized arc-length parameter
act(() => {
h.app.scene.mutateElement(arrow, {
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(50, 0),
pointFrom<LocalPoint>(100, 0),
],
});
});
const after = LinearElementEditor.getPointAtPathParameter(
arrow,
0.25,
arrayToMap(h.elements),
)!;
expect(after[0]).toBeCloseTo(before[0]);
expect(after[1]).toBeCloseTo(before[1]);
});
it("should use the unrounded logical path of elbow arrows", () => {
const arrow = API.createElement({
type: "arrow",
x: 0,
y: 0,
width: 100,
height: 100,
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(100, 0),
pointFrom<LocalPoint>(100, 100),
],
elbowed: true,
});
API.setElements([arrow]);
const elementsMap = arrayToMap(h.elements);
// Until mixed paths are supported, elbow arrows are treated as the
// straight segments between their logical points.
const segments = getLinearElementPathSegments(arrow, elementsMap);
expect(segments).toHaveLength(2);
for (let i = 1; i < segments.length; i++) {
const prevEnd = segments[i - 1][segments[i - 1].length - 1];
expect(segments[i][0][0]).toBeCloseTo(prevEnd[0]);
expect(segments[i][0][1]).toBeCloseTo(prevEnd[1]);
}
const expectedPoints = [
pointFrom<GlobalPoint>(0, 0),
pointFrom<GlobalPoint>(50, 0),
pointFrom<GlobalPoint>(100, 0),
pointFrom<GlobalPoint>(100, 50),
pointFrom<GlobalPoint>(100, 100),
];
for (const [index, parameter] of [0, 0.25, 0.5, 0.75, 1].entries()) {
const point = LinearElementEditor.getPointAtPathParameter(
arrow,
parameter,
elementsMap,
);
expect(point).toEqual(expectedPoints[index]);
}
});
});
it("should match styles for text editor", async () => {
createTwoPointerLinearElement("arrow");
Keyboard.keyPress(KEYS.ENTER);
@@ -1261,7 +1416,7 @@ describe("Test Linear Elements", () => {
expect({ width: container.width, height: container.height })
.toMatchInlineSnapshot(`
{
"height": 130,
"height": 150,
"width": "366.11716",
}
`);
@@ -1290,11 +1445,11 @@ describe("Test Linear Elements", () => {
).toMatchInlineSnapshot(`
[
20,
35,
45,
"501.11716",
95,
220,
"205.45894",
"52.50000",
145,
]
`);
});
@@ -1405,6 +1560,7 @@ describe("Test Linear Elements", () => {
"nw",
false,
false,
false,
);
expect(
wrapText(
@@ -1513,6 +1669,270 @@ describe("Test Linear Elements", () => {
expect(label.x).toBe(0);
expect(label.y).toBe(0);
});
describe("dragging the label along the arrow", () => {
// a tall label, so its lower half is beyond the arrow's hit threshold:
// there the pointer is over the label but not over the arrow's path
const createArrowWithTallLabel = () => {
const arrow = API.createElement({
type: "arrow",
x: p1[0],
y: p1[1],
width: p2[0] - p1[0],
height: 0,
points: [pointFrom(0, 0), pointFrom(p2[0] - p1[0], 0)],
});
const label = {
...API.createElement({
type: "text",
text: "label",
containerId: arrow.id,
width: 30,
height: 60,
}),
labelPosition: 0.5,
} as ExcalidrawTextElementWithContainer;
return {
label,
arrow: {
...arrow,
boundElements: [{ type: "text", id: label.id } as const],
},
};
};
// covers the label and the arrow's segment midpoint handle, but not the
// arrow's start point, so the arrow can still be selected by clicking it
const createOccluder = () =>
API.createElement({
type: "rectangle",
x: 30,
y: 5,
width: 30,
height: 50,
backgroundColor: "#ff0000",
fillStyle: "solid",
});
// inside the label, off the arrow's path
const labelOnlyPoint = pointFrom<GlobalPoint>(40, 45);
it("treats a point handle under the label as the handle, on hover and on pointer down", () => {
const arrow = API.createElement({
type: "arrow",
x: p1[0],
y: p1[1],
width: p2[0] - p1[0],
height: 0,
points: [pointFrom(0, 0), pointFrom(p2[0] - p1[0], 0)],
});
const label = {
...API.createElement({
type: "text",
text: "label",
containerId: arrow.id,
width: 30,
height: 30,
}),
// centered on the end point
labelPosition: 1,
} as ExcalidrawTextElementWithContainer;
API.setElements([
{
...arrow,
boundElements: [{ type: "text", id: label.id } as const],
},
label,
]);
mouse.reset();
mouse.clickAt(p1[0], p1[1]);
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
// inside the label and within the end point's handle radius, but
// farther from the arrow's path than the arrow's own hit threshold
const point = pointFrom<GlobalPoint>(p2[0], p2[1] + 9);
mouse.moveTo(point[0], point[1]);
expect(h.state.selectedLinearElement?.hoverPointIndex).toBe(1);
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
CURSOR_TYPE.POINTER,
);
drag(point, pointFrom<GlobalPoint>(point[0] + 40, point[1]));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).labelPosition,
).toBe(1);
expect(
(h.elements[0] as ExcalidrawLinearElement).points[1][0],
).toBeCloseTo(p2[0] - p1[0] + 40);
});
it("keeps the arrow selected when a point handle is grabbed beyond the arrow's tip", () => {
const arrow = API.createElement({
type: "arrow",
x: p1[0],
y: p1[1],
width: p2[0] - p1[0],
height: 0,
points: [pointFrom(0, 0), pointFrom(p2[0] - p1[0], 0)],
});
API.setElements([arrow]);
mouse.reset();
mouse.clickAt(p1[0], p1[1]);
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
// past the end point along the arrow: inside the handle radius, but
// outside the arrow's own hit area
const point = pointFrom<GlobalPoint>(p2[0] + 9, p2[1]);
mouse.moveTo(point[0], point[1]);
expect(h.state.selectedLinearElement?.hoverPointIndex).toBe(1);
drag(point, pointFrom<GlobalPoint>(point[0] + 40, point[1]));
expect(h.state.selectionElement).toBeNull();
expect(h.state.selectedElementIds[arrow.id]).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
expect(
(h.elements[0] as ExcalidrawLinearElement).points[1][0],
).toBeCloseTo(p2[0] - p1[0] + 40);
});
it("grabs the label when the covering element is below the arrow", () => {
const { arrow, label } = createArrowWithTallLabel();
API.setElements([createOccluder(), arrow, label]);
mouse.reset();
mouse.clickAt(p1[0], p1[1]);
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
drag(labelOnlyPoint, pointFrom<GlobalPoint>(50, 45));
expect(
(h.elements[2] as ExcalidrawTextElementWithContainer).labelPosition,
).toBeCloseTo(0.75);
});
it("does not grab the label when it is covered by an element above the arrow", () => {
const { arrow, label } = createArrowWithTallLabel();
API.setElements([arrow, label, createOccluder()]);
mouse.reset();
mouse.clickAt(p1[0], p1[1]);
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
drag(labelOnlyPoint, pointFrom<GlobalPoint>(50, 45));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).labelPosition,
).toBe(0.5);
});
it("grabs an elbow arrow's label lying over an intermediate route point", () => {
const arrow = API.createElement({
type: "arrow",
x: 0,
y: 0,
width: 100,
height: 100,
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(100, 0),
pointFrom<LocalPoint>(100, 100),
],
elbowed: true,
});
const label = {
...API.createElement({
type: "text",
text: "label",
containerId: arrow.id,
width: 30,
height: 30,
}),
// the arc-length middle of this symmetric elbow sits on the
// rounded corner, right next to the route point at (100, 0)
labelPosition: 0.5,
} as ExcalidrawTextElementWithContainer;
API.setElements([
{
...arrow,
boundElements: [{ type: "text", id: label.id } as const],
},
label,
]);
mouse.reset();
mouse.clickAt(0, 0);
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
// inside the label AND within the hit radius of the route point at
// (100, 0) — pre-fix the route point swallowed this pointerdown and
// the label never moved
const grabPoint = pointFrom<GlobalPoint>(97, 3);
drag(
grabPoint,
pointFrom<GlobalPoint>(grabPoint[0] - 40, grabPoint[1]),
);
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).labelPosition,
).not.toBe(0.5);
});
it("gives the segment midpoint handle precedence over the label on top of it", () => {
const { arrow, label } = createArrowWithTallLabel();
API.setElements([arrow, label]);
mouse.reset();
mouse.clickAt(p1[0], p1[1]);
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
expect((h.elements[0] as ExcalidrawLinearElement).points.length).toBe(
2,
);
// the label sits centered on the midpoint knob; dragging there must
// bend the arrow, not slide the label
drag(midpoint, pointFrom<GlobalPoint>(midpoint[0], midpoint[1] + 40));
expect((h.elements[0] as ExcalidrawLinearElement).points.length).toBe(
3,
);
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).labelPosition,
).toBe(0.5);
});
it("leaves the segment midpoint handle grabbable under a covered label", () => {
const { arrow, label } = createArrowWithTallLabel();
API.setElements([arrow, label, createOccluder()]);
mouse.reset();
mouse.clickAt(p1[0], p1[1]);
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
expect((h.elements[0] as ExcalidrawLinearElement).points.length).toBe(
2,
);
// the midpoint handle sits under the label, which is itself covered
drag(midpoint, pointFrom<GlobalPoint>(midpoint[0], midpoint[1] + 40));
expect(
h.state.selectedLinearElement?.initialState.segmentMidpoint.value,
).not.toBeNull();
expect((h.elements[0] as ExcalidrawLinearElement).points.length).toBe(
3,
);
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).labelPosition,
).toBe(0.5);
});
});
});
describe("Test moving linear element points", () => {
+52
View File
@@ -0,0 +1,52 @@
import * as common from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { newElement, newLinearElement } from "../src/newElement";
import { mutateElement, newElementWith } from "../src/mutateElement";
describe("element creation time", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it.each([
"rectangle",
"diamond",
"ellipse",
"text",
"line",
"arrow",
"freedraw",
"image",
"frame",
"magicframe",
"embeddable",
"iframe",
] as const)("initializes a new %s before any capture", (type) => {
vi.spyOn(common, "getUpdatedTimestamp").mockReturnValue(123);
const element = API.createElement({ type });
expect(element.created).toBe(123);
vi.mocked(common.getUpdatedTimestamp).mockReturnValue(456);
const edited = newElementWith(element, { x: 50 });
mutateElement(edited, new Map(), { y: 50 });
expect(edited).toMatchObject({ created: 123, updated: 456 });
});
it.each([123, 0, null])(
"preserves explicit created=%s when reconstructing the same element",
(created) => {
const original = newElement({
type: "rectangle",
x: 0,
y: 0,
created,
});
const converted = newLinearElement({ ...original, type: "line" });
expect(converted).toMatchObject({ id: original.id, created });
},
);
});
@@ -0,0 +1,327 @@
import type { GlobalPoint } from "@excalidraw/math";
import { recognizeShape } from "../src/convertToShape";
// Deterministic jitter, so a run that passes keeps passing.
let seed = 1;
const random = () => {
seed = (seed * 1103515245 + 12345) % 2147483648;
return seed / 2147483648 - 0.5;
};
// Draw the shape somewhere else, at another size, turned by `angle`, with a
// wobbly hand.
const sketch = (
points: readonly [number, number][],
{ angle = 0, scale = 1, noise = 0 } = {},
): GlobalPoint[] =>
points.map(([x, y]) => {
const nx = x * scale + random() * noise;
const ny = y * scale + random() * noise;
return [
nx * Math.cos(angle) - ny * Math.sin(angle) + 600,
nx * Math.sin(angle) + ny * Math.cos(angle) + 600,
] as GlobalPoint;
});
const edge = (
from: readonly [number, number],
to: readonly [number, number],
steps: number,
): [number, number][] =>
Array.from({ length: steps }, (_, i) => [
from[0] + ((to[0] - from[0]) * i) / steps,
from[1] + ((to[1] - from[1]) * i) / steps,
]);
const outline = (corners: readonly [number, number][]): [number, number][] => [
...corners.flatMap((corner, i) => edge(corner, corners[(i + 1) % 4], 18)),
corners[0],
];
const rectangle = (w: number, h: number) =>
outline([
[-w / 2, -h / 2],
[w / 2, -h / 2],
[w / 2, h / 2],
[-w / 2, h / 2],
]);
const diamond = (w: number, h: number) =>
outline([
[0, -h / 2],
[w / 2, 0],
[0, h / 2],
[-w / 2, 0],
]);
const ellipse = (rx: number, ry: number): [number, number][] => {
const points: [number, number][] = [];
for (let a = 0; a <= Math.PI * 2 + 0.05; a += 0.1) {
points.push([Math.cos(a) * rx, Math.sin(a) * ry]);
}
return points;
};
// Moving-average smoothing: rounds every corner the way a relaxed pen does.
const smooth = (
pts: readonly [number, number][],
window: number,
): [number, number][] =>
pts.map((_, i) => {
let sx = 0;
let sy = 0;
for (let k = -window; k <= window; k++) {
const [x, y] = pts[(i + k + pts.length) % pts.length];
sx += x;
sy += y;
}
return [sx / (2 * window + 1), sy / (2 * window + 1)];
});
// A sloppy hand-drawn "rectangle" that tapers, bucket-like: wider at the top
// than the bottom. Its hull fill ratio, (topW + bottomW) / (2 * topW), lands
// right at an ellipse's PI/4 — only its sharp corners tell the two apart.
const taperedRectangle = (
topW: number,
bottomW: number,
h: number,
): [number, number][] =>
outline([
[-topW / 2, -h / 2],
[topW / 2, -h / 2],
[bottomW / 2, h / 2],
[-bottomW / 2, h / 2],
]);
// A rectangle drawn with broad, pen-like rounded corners. Its outline is a
// superellipse midway between an ellipse (exponent 2) and a sharp rectangle.
const roundedRectangle = (rx: number, ry: number): [number, number][] => {
const points: [number, number][] = [];
for (let a = 0; a <= Math.PI * 2 + 0.05; a += 0.1) {
const cos = Math.cos(a);
const sin = Math.sin(a);
points.push([
Math.sign(cos) * Math.sqrt(Math.abs(cos)) * rx,
Math.sign(sin) * Math.sqrt(Math.abs(sin)) * ry,
]);
}
return points;
};
const line = (length: number) => edge([-length / 2, 0], [length / 2, 0], 40);
/** A shaft, then a V head traced back from the tip and over itself. */
const arrow = (length: number, headRatio: number): [number, number][] => {
const tip: [number, number] = [length / 2, 0];
const headLength = length * headRatio;
const arm = (side: number): [number, number] => [
tip[0] - headLength * Math.cos(Math.PI / 6),
tip[1] - side * headLength * Math.sin(Math.PI / 6),
];
return [
...edge([-length / 2, 0], tip, 24),
...edge(tip, arm(1), 8),
...edge(arm(1), tip, 8),
...edge(tip, arm(-1), 8),
];
};
const SCALES = [0.5, 1, 2, 4];
describe("recognizeShape", () => {
describe("is invariant to where, how big and how rotated a shape is drawn", () => {
// A rectangle turned 45° *is* a diamond, so these two are only swept over
// the right angles that map them onto themselves. Every other class is
// swept over arbitrary angles.
const RIGHT_ANGLES = [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2];
// 4.7 rad is within a degree of vertical — the size gate compares the
// larger bounding-box dimension, so thin (near-)axis-aligned strokes are
// recognized like any other.
const ANY_ANGLES = [0, 0.3, Math.PI / 4, 1.2, Math.PI / 2, 2.5, 4.7];
const cases: [string, [number, number][], number[], string][] = [
["a square", rectangle(200, 200), RIGHT_ANGLES, "rectangle"],
["a wide rectangle", rectangle(300, 150), RIGHT_ANGLES, "rectangle"],
["a thin rectangle", rectangle(500, 100), RIGHT_ANGLES, "rectangle"],
[
"a tapered rectangle",
taperedRectangle(170, 100, 200),
RIGHT_ANGLES,
"rectangle",
],
["a diamond", diamond(200, 200), RIGHT_ANGLES, "diamond"],
["a wide diamond", diamond(300, 150), RIGHT_ANGLES, "diamond"],
["a circle", ellipse(100, 100), ANY_ANGLES, "ellipse"],
["an ellipse", ellipse(150, 75), ANY_ANGLES, "ellipse"],
["a line", line(300), ANY_ANGLES, "line"],
["an arrow with a small head", arrow(300, 0.15), ANY_ANGLES, "arrow"],
["an arrow with a large head", arrow(300, 0.45), ANY_ANGLES, "arrow"],
];
it.each(cases)("recognizes %s", (_name, points, angles, expected) => {
for (const angle of angles) {
for (const scale of SCALES) {
for (const noise of [0, 3]) {
expect({
angle,
scale,
noise,
type: recognizeShape(
sketch(points, { angle, scale, noise }),
null,
).type,
}).toEqual({ angle, scale, noise, type: expected });
}
}
}
});
});
it("tells an arrow from a line by the points its head piles up", () => {
const options = { angle: 0.3 };
expect(recognizeShape(sketch(line(300), options), null).type).toBe("line");
expect(recognizeShape(sketch(arrow(300, 0.3), options), null).type).toBe(
"arrow",
);
});
it("recognizes an outline the user didn't quite close", () => {
const gapped = rectangle(200, 200).slice(0, -6);
expect(recognizeShape(sketch(gapped), null).type).toBe("rectangle");
});
it("recognizes a pen-drawn outline with rounded corners as a rectangle", () => {
expect(recognizeShape(sketch(roundedRectangle(100, 70)), null).type).toBe(
"rectangle",
);
// ...while a true ellipse of the same proportions remains an ellipse
expect(recognizeShape(sketch(ellipse(100, 70)), null).type).toBe("ellipse");
});
it("tells a tapered, bucket-like rectangle from an ellipse by its corners", () => {
// proportions traced from real sketches that used to recognize as
// ellipses: hull fill ≈ 0.8 ≈ PI/4, so corner sharpness must decide
const buckets: [number, number, number][] = [
[110, 65, 220],
[110, 65, 130],
[75, 50, 115],
[120, 60, 250],
];
for (const [topW, bottomW, h] of buckets) {
// lightly rounded corners + wobble, like the real strokes
const points = sketch(smooth(taperedRectangle(topW, bottomW, h), 2), {
noise: 2,
});
expect({
bucket: [topW, bottomW, h],
type: recognizeShape(points, null).type,
}).toEqual({ bucket: [topW, bottomW, h], type: "rectangle" });
}
});
it("does not read sharply bent open strokes as lines or arrows", () => {
const semicircle: [number, number][] = Array.from(
{ length: 40 },
(_, i) => {
const a = (Math.PI * i) / 39;
return [Math.cos(a) * 150, Math.sin(a) * 150];
},
);
const sawtooth: [number, number][] = Array.from({ length: 31 }, (_, i) => [
i * 10,
(i % 2) * 60,
]);
const bent: [string, [number, number][]][] = [
[
"a right-angle elbow",
[...edge([0, 0], [110, 110], 20), ...edge([110, 110], [220, 0], 20)],
],
[
"an L shape",
[...edge([0, 0], [0, 150], 20), ...edge([0, 150], [150, 150], 20)],
],
[
"a checkmark",
[...edge([0, 60], [60, 120], 12), ...edge([60, 120], [200, 0], 24)],
],
["a semicircular arc", semicircle],
["a sawtooth", sawtooth],
];
for (const [name, points] of bent) {
expect({
name,
type: recognizeShape(sketch(points, { noise: 2 }), null).type,
}).toEqual({ name, type: "freedraw" });
}
});
it("still reads a lazily bowed or squiggly stroke as a line", () => {
const bowed: [number, number][] = Array.from({ length: 40 }, (_, i) => {
const t = i / 39;
return [t * 300, Math.sin(t * Math.PI) * 24];
});
const squiggly: [number, number][] = Array.from({ length: 40 }, (_, i) => {
const t = i / 39;
return [t * 300, Math.sin(t * Math.PI * 4) * 12];
});
expect(recognizeShape(sketch(bowed, { noise: 2 }), null).type).toBe("line");
expect(recognizeShape(sketch(squiggly, { noise: 2 }), null).type).toBe(
"line",
);
});
it("leaves a stroke that resembles no known shape as freedraw", () => {
const squiggle: [number, number][] = Array.from({ length: 60 }, (_, i) => [
i * 4 - 120,
Math.sin(i / 2) * 70 + random() * 20,
]);
expect(recognizeShape(sketch(squiggle), null).type).toBe("freedraw");
});
it("only extends an arrow with another arrow", () => {
const previous = { type: "arrow" } as Parameters<typeof recognizeShape>[1];
expect(
recognizeShape(sketch(arrow(300, 0.3), { angle: 0.3 }), previous).type,
).toBe("arrow");
expect(recognizeShape(sketch(rectangle(200, 200)), previous).type).toBe(
"freedraw",
);
});
it("ignores a stroke too small to be a shape", () => {
const tiny: [number, number][] = [
[0, 0],
[1, 1],
[2, 0],
[1, -1],
];
expect(recognizeShape(sketch(tiny), null).type).toBe("freedraw");
});
it("gates on apparent (screen) size, so zoom decides what is too small", () => {
const smallCircle = ellipse(8, 8); // 16 scene units across
expect(recognizeShape(sketch(smallCircle), null).type).toBe("freedraw");
expect(recognizeShape(sketch(smallCircle), null, 10).type).toBe("ellipse");
// 300 apparent px at 0.1 zoom
expect(
recognizeShape(sketch(line(3000), { angle: 0.3 }), null, 0.1).type,
).toBe("line");
// 10 apparent px at 0.1 zoom
expect(
recognizeShape(sketch(line(100), { angle: 0.3 }), null, 0.1).type,
).toBe("freedraw");
});
});
+188 -25
View File
@@ -1,4 +1,9 @@
import { pointFrom } from "@excalidraw/math";
import {
bezierEquation,
isCurve,
pointDistance,
pointFrom,
} from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw";
import {
@@ -12,14 +17,19 @@ import {
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI, Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
act,
render,
unmountComponent,
} from "@excalidraw/excalidraw/tests/test-utils";
import type { GlobalPoint } from "@excalidraw/math";
import type { LocalPoint } from "@excalidraw/math";
import { isLinearElement } from "../src/typeChecks";
import { resizeSingleElement } from "../src/resizeElements";
import { distanceToElement } from "../src/distance";
import { getLinearElementPathSegments } from "../src/utils";
import { LinearElementEditor } from "../src/linearElementEditor";
import { getElementPointsCoords } from "../src/bounds";
import { computeContainerDimensionForBoundText } from "../src/textElement";
@@ -229,6 +239,45 @@ describe("generic element", () => {
expect(label.fontSize).toEqual(20);
});
describe.each(["rectangle", "ellipse", "diamond"] as const)(
"%s with a label",
(type) => {
it.each([false, true])(
"flips horizontally while resizing (proportional: %s)",
async (proportional) => {
const container = UI.createElement(type, {
width: 200,
height: 100,
});
const label = await UI.editText(container, "Hello");
UI.resize(container, proportional ? "se" : "e", [-500, 0], {
shift: proportional,
});
expect(container.x).toBeCloseTo(-300);
expect(container.y).toBeCloseTo(0);
expect(container.width).toBeCloseTo(300);
expect(container.height).toBeCloseTo(proportional ? 150 : 100);
expect(label.x + label.width / 2).toBeCloseTo(
container.x + container.width / 2,
0,
);
expect(label.y + label.height / 2).toBeCloseTo(
container.y + container.height / 2,
0,
);
expect(label.angle).toBe(0);
if (proportional) {
expect(label.fontSize).toBeGreaterThan(20);
} else {
expect(label.fontSize).toBe(20);
}
},
);
},
);
it.each<{ handle: TransformHandleDirection; move: [number, number] }>([
{ handle: "n", move: [0, 100] },
{ handle: "s", move: [0, -100] },
@@ -258,6 +307,47 @@ describe("generic element", () => {
expect(rectangle.height).toBeCloseTo(minContainerHeight);
},
);
it("keeps the flipped corner anchored while wrapped text sets the minimum height", async () => {
const rectangle = UI.createElement("rectangle", {
width: 200,
height: 200,
});
const label = await UI.editText(rectangle, "first second third fourth");
const originalElements = arrayToMap(
h.app.scene.getNonDeletedElements().map((element) => ({ ...element })),
);
const originalRectangle = originalElements.get(rectangle.id)!;
let minHeight = 0;
// One SE drag crosses the top edge, stays below the wrapped text's
// minimum height, then grows far enough for the pointer to control it.
for (const nextHeight of [-10, -40, -80, -200]) {
act(() => {
resizeSingleElement(
80,
nextHeight,
h.app.scene.getNonDeletedElement(rectangle.id)!,
originalRectangle,
originalElements,
h.app.scene,
"se",
);
});
expect(rectangle.x).toBe(originalRectangle.x);
expect(rectangle.y + rectangle.height).toBe(originalRectangle.y);
expect(label.y).toBeGreaterThan(rectangle.y);
expect(label.y + label.height).toBeLessThan(
rectangle.y + rectangle.height,
);
if (nextHeight === -10) {
minHeight = rectangle.height;
expect(minHeight).toBeGreaterThan(80);
}
expect(rectangle.height).toBe(Math.max(minHeight, -nextHeight));
}
});
});
describe.each(["line", "freedraw"] as const)("%s element", (type) => {
@@ -490,37 +580,86 @@ describe("arrow element", () => {
});
const label = await UI.editText(arrow, "Hello");
const elementsMap = arrayToMap(h.elements);
UI.resize(arrow, "se", [50, 30]);
let labelPos = LinearElementEditor.getBoundTextElementPosition(
arrow,
label,
elementsMap,
);
expect(labelPos.x + label.width / 2).toBeCloseTo(
arrow.x + arrow.points[2][0],
);
expect(labelPos.y + label.height / 2).toBeCloseTo(
arrow.y + arrow.points[2][1],
);
// independently locate the arc-length midpoint by densely sampling the
// path, so a regression in the arc-length inversion the production code
// uses cannot cancel out of the comparison
const sampledPathMidpoint = (): GlobalPoint => {
const samples: GlobalPoint[] = [];
for (const segment of getLinearElementPathSegments(arrow, elementsMap)) {
for (let i = 0; i <= 1000; i++) {
const t = i / 1000;
samples.push(
isCurve(segment)
? bezierEquation(segment, t)
: pointFrom<GlobalPoint>(
segment[0][0] + t * (segment[1][0] - segment[0][0]),
segment[0][1] + t * (segment[1][1] - segment[0][1]),
),
);
}
}
const cumulative = [0];
for (let i = 1; i < samples.length; i++) {
cumulative.push(
cumulative[i - 1] + pointDistance(samples[i - 1], samples[i]),
);
}
const half = cumulative[cumulative.length - 1] / 2;
return samples[cumulative.findIndex((length) => length >= half)];
};
// the label defaults to the arc-length midpoint of the arrow's path and
// keeps that normalized position through resizes
const expectLabelAtPathMidpoint = () => {
const labelPos = LinearElementEditor.getBoundTextElementPosition(
arrow,
label,
elementsMap,
);
const centerX = labelPos.x + label.width / 2;
const centerY = labelPos.y + label.height / 2;
const pathMidpoint = sampledPathMidpoint();
expect(label.labelPosition).toBe(0.5);
expect(centerX).toBeCloseTo(pathMidpoint[0], 0);
expect(centerY).toBeCloseTo(pathMidpoint[1], 0);
expect(
distanceToElement(arrow, elementsMap, pointFrom(centerX, centerY)),
).toBeLessThan(0.5);
};
expectLabelAtPathMidpoint();
UI.resize(arrow, "se", [50, 30]);
expectLabelAtPathMidpoint();
expect(label.angle).toBeCloseTo(0);
expect(label.fontSize).toEqual(20);
UI.resize(arrow, "w", [20, 0]);
labelPos = LinearElementEditor.getBoundTextElementPosition(
arrow,
label,
elementsMap,
);
expect(labelPos.x + label.width / 2).toBeCloseTo(
arrow.x + arrow.points[2][0],
);
expect(labelPos.y + label.height / 2).toBeCloseTo(
arrow.y + arrow.points[2][1],
);
expectLabelAtPathMidpoint();
expect(label.angle).toBeCloseTo(0);
expect(label.fontSize).toEqual(20);
const { x, width, height } = arrow;
const latestArrow = h.app.scene.getNonDeletedElement(arrow.id)!;
act(() => {
resizeSingleElement(
-2 * width,
2 * height,
latestArrow,
{ ...latestArrow },
arrayToMap(h.elements.map((element) => ({ ...element }))),
h.app.scene,
"se",
{ shouldMaintainAspectRatio: true },
);
});
expect(getBoundsFromPoints(arrow)[2]).toBeCloseTo(x);
expect(arrow.width).toBeCloseTo(2 * width);
expect(label.fontSize).toBeCloseTo(40);
expect(label.angle).toBeCloseTo(0);
expectLabelAtPathMidpoint();
});
it("flips the fixed point binding on negative resize for single bindable", () => {
@@ -866,6 +1005,30 @@ describe("image element", () => {
});
describe("multiple selection", () => {
it("keeps a flipped text container anchored when its label requires extra height", async () => {
const rectangle = UI.createElement("rectangle", {
position: 0,
width: 200,
height: 35,
});
const label = await UI.editText(rectangle, "hello");
const other = UI.createElement("rectangle", {
x: 300,
y: 0,
size: 100,
});
const originalHeight = rectangle.height;
// Halve the selection and flip vertically. Padding does not scale with
// the font, so even a single-line label needs extra container height.
UI.resize([rectangle, other], "se", [-200, -150], { shift: true });
expect(label.fontSize).toBeCloseTo(10);
expect(label.text).toBe("hello");
expect(rectangle.height).toBeGreaterThan(originalHeight / 2);
expect(rectangle.y + rectangle.height).toBeCloseTo(0);
});
it("resizes with generic elements", async () => {
const rectangle = UI.createElement("rectangle", {
position: 0,
+16
View File
@@ -13,6 +13,22 @@ Please add the latest change on the top under the correct section.
## Unreleased
### Wheel navigation (2026-09-13) [#XXXX](https://github.com/excalidraw/excalidraw/pull/XXXX)
- Scrolling while holding the wheel (middle) mouse button zooms the canvas around the pointer, the same as ctrl/cmd+wheel — a wheel-button pan can be zoomed one-handed, without reaching for a modifier.
- New `appState.inputDevice` preference (`"auto" | "mouse" | "trackpad"`, default `"auto"`, persisted in browser storage) selecting the wheel mappings. With `"trackpad"` a plain wheel pans and ctrl/cmd+wheel (how a pinch is delivered) zooms, as before; with `"mouse"` a plain wheel zooms and ctrl/cmd+wheel pans vertically instead, while shift+wheel keeps panning horizontally and the wheel-button zoom above is unaffected. `"auto"` is reserved for detecting the device from the wheel events and resolves to `"trackpad"` until that is implemented (`resolveInputDevice()`). Set it from the new "Input device" radio in the main menu's preferences submenu, also exposed as `MainMenu.DefaultItems.Preferences.InputDevice`.
### Sticky notes (2026-09-06) [#XXXX](https://github.com/excalidraw/excalidraw/pull/XXXX)
- New `stickynote` element type and toolbar tool (`N`): an always-filled, flat-rendered note whose label auto-fits — the font shrinks from the user's size down to a minimum, and only then does the note grow in height (it never shrinks below the height the user gave it). Click to place a 250×250 note, drag to size it (previewed at the dragged size, snapped to a font-aware minimum on release); text editing starts right away.
- Element model: the note carries `baseHeight` (the user's height; `height ≥ baseHeight`), its label carries `baseFontSize` (the user's font ceiling, `null`/absent on any other text) while `fontSize` holds the fitted size. Read a text element's user-facing size through `getBaseFontSize(text, elementsMap)`.
- Sticky notes are their own color domain: new `appState.currentItemStickynoteStrokeColor` (the note's text color) and `appState.currentItemStickynoteBackgroundColor` defaults, and new customizable top-pick slots `appState.colorTopPicks.stickyNoteStroke` / `stickyNoteBackground`. The stroke and background pickers, both eyedroppers and paste-styles resolve their target (regular / sticky / mixed) at execution time. A note and its label share one ink (the stroke picker reads "Text color" for notes): coloring the label while editing colors the note too, binding a colored text gives the note its color, and the creation-date footer always paints in that ink.
- Skeleton API: `convertToExcalidrawElements([{ type: "stickynote", x, y, label?: { text, fontSize? } }])` creates a note (and fitted label); see the element-skeleton docs.
- Tools can be dragged out of the toolbar: drag the sticky note button onto the canvas to drop a default-sized note where you release it, previewed translucently while dragging (nothing reaches the scene, history or collaborators until the drop; Escape cancels). The mechanism is generic — other tools can opt in.
- Toolbar: on desktop and tablet the image tool moved into the "More tools" menu (still `9`; `UIOptions.tools.image` keeps hiding it; the mobile toolbar keeps its image slot), and tool badges now show the letter shortcuts (`R`, `T`, `N`, …) instead of the numbers, which are no longer contiguous. The number keys keep working.
- Each note shows its creation date (`element.created`) in a footer band — "7 Sep", or "7 Sep 2025" once the year differs — on canvas and in SVG/PNG exports. The band is reserved in the note's layout, so the label never overlaps it; notes without a timestamp keep the band empty.
- Resize semantics: a note's corners resize proportionally by default and scale the label's font ceiling with the note (Shift frees them); its edges resize freely by default (Shift constrains them). Drag-creation is proportional by default too (Shift frees it) and never touches the font ceiling. Width-only gestures keep the base height, height gestures set it, aspect-locked multi-select and Stats group edits scale the ceiling; flips preserve everything. Arrows bound to a note follow it as it grows.
## Excalidraw API
### Host-controlled active tool (2026-07-14) [#11665](https://github.com/excalidraw/excalidraw/pull/11665)
@@ -1,98 +0,0 @@
import { formatTimeToHourMinute } from "@excalidraw/common";
import { useI18n } from "../i18n";
import { getConversationPreviewMessage } from "./chatHelpers";
import { useAIAssistantPreview } from "./useAIAssistantPreview";
import type {
AssistantMessage,
ChatConversation,
TTADialogRenderWelcomeScreen,
TTARateLimits,
} from "./types";
type TTAChatEmptyStateProps = {
latestHistoryChat: ChatConversation | null;
onSelectHistoryChat: (chat: ChatConversation) => void;
rateLimits: TTARateLimits | null;
renderWelcomeScreen?: TTADialogRenderWelcomeScreen;
};
export const TTAChatEmptyState = ({
latestHistoryChat,
onSelectHistoryChat,
rateLimits,
renderWelcomeScreen,
}: TTAChatEmptyStateProps) => {
const { t } = useI18n();
const previewMessage = latestHistoryChat
? getConversationPreviewMessage(latestHistoryChat.messages)
: null;
const renderedWelcomeScreen = renderWelcomeScreen?.({ rateLimits });
return (
<div className="tta-chat-empty-state">
{latestHistoryChat && (
<div className="tta-chat-empty-top">
<button
type="button"
className="tta-chat-empty-resume-card"
onClick={() => onSelectHistoryChat(latestHistoryChat)}
>
<div className="tta-chat-empty-resume-card__preview">
{previewMessage ? (
<TTAChatEmptyStatePreview
message={previewMessage}
alt={t("ai.chat.preview")}
/>
) : (
<div className="tta-chat-empty-resume-card__placeholder" />
)}
</div>
<div className="tta-chat-empty-resume-card__content">
<div className="tta-chat-empty-resume-card__label">
{t("ai.chat.emptyState.resume")}
</div>
<div className="tta-chat-empty-resume-card__title">
{latestHistoryChat.title}
</div>
<div className="tta-chat-empty-resume-card__meta">
{t("ai.chat.emptyState.lastUpdated", {
time: formatTimeToHourMinute(latestHistoryChat.updatedAt),
})}
</div>
</div>
</button>
</div>
)}
<div className="tta-chat-empty-middle">
<div className="tta-chat-empty-intro">
{renderedWelcomeScreen !== undefined ? (
renderedWelcomeScreen
) : (
<div className="tta-chat-empty-tagline">
{t("ai.chat.emptyState.guidance")}
</div>
)}
</div>
</div>
</div>
);
};
const TTAChatEmptyStatePreview = ({
message,
alt,
}: {
message: AssistantMessage;
alt: string;
}) => {
const { previewSvg } = useAIAssistantPreview(message);
return previewSvg ? (
<img src={previewSvg} alt={alt} />
) : (
<div className="tta-chat-empty-resume-card__placeholder" />
);
};
-537
View File
@@ -1,537 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useI18n } from "../i18n";
import {
RetryIcon,
copyIcon,
tablerCheckIcon as checkIcon,
TrashIcon,
stackPushIcon,
} from "../components/icons";
import { ChatMessage, ChatMessageActionButton } from "../components/AI";
import { getAIErrorMessageKey } from "./utils";
import { useAIAssistantPreview } from "./useAIAssistantPreview";
import type {
AssistantMessage,
AssistantStatus,
ChatMessage as ChatMessageType,
SystemWarningMessage,
TTAChatScrollOptions,
TTADialogRenderWarning,
TTARateLimits,
UserMessage,
} from "./types";
import type { AIRateLimitWarningDescriptor } from "../aiWarnings";
export interface TTAChatMessageProps {
message: ChatMessageType;
onInsert: () => void;
onRetry: () => void;
showRetry?: boolean;
onDelete: () => void;
showDelete?: boolean;
onPreview: (url: string) => void;
/**
* Re-run affordance for a trailing prompt-only user turn
* (tta_rewrite_final.md §5.8): a reload or chat switch mid-generation
* leaves the chat ending on a user message with no assistant reply —
* re-run sends the same prompt + images as a fresh turn.
*/
onRerun?: () => void;
showRerun?: boolean;
scrollChatToBottom?: (options?: TTAChatScrollOptions) => void;
renderWarning?: TTADialogRenderWarning;
rateLimits?: TTARateLimits | null;
}
type TTAUserChatMessageProps = Pick<
TTAChatMessageProps,
"onPreview" | "onRerun" | "showRerun" | "rateLimits"
> & {
message: UserMessage;
};
const TTAUserChatMessage = ({
message,
onPreview,
onRerun,
showRerun = false,
rateLimits,
}: TTAUserChatMessageProps) => {
const { t } = useI18n();
const [isCopied, setIsCopied] = useState(false);
const copyTimeoutRef = useRef<number | null>(null);
const handleCopy = useCallback((content: UserMessage["content"]) => {
if (content) {
navigator.clipboard.writeText(content);
setIsCopied(true);
if (copyTimeoutRef.current) {
window.clearTimeout(copyTimeoutRef.current);
}
copyTimeoutRef.current = window.setTimeout(() => {
setIsCopied(false);
}, 2000);
}
}, []);
useEffect(() => {
return () => {
if (copyTimeoutRef.current) {
window.clearTimeout(copyTimeoutRef.current);
}
};
}, []);
const showCopy = Boolean(message.content);
const actionsNode =
showCopy || showRerun ? (
<>
{showCopy && (
<ChatMessageActionButton
icon={isCopied ? checkIcon : copyIcon}
onClick={() => handleCopy(message.content)}
ariaLabel={t("labels.copy")}
title={t("labels.copy")}
/>
)}
{showRerun && (
<ChatMessageActionButton
icon={RetryIcon}
label={t("ai.chat.actions.rerun")}
onClick={() => onRerun?.()}
ariaLabel={t("ai.chat.actions.rerun")}
title={t("ai.chat.actions.rerun")}
disabled={rateLimits?.rateLimitRemaining === 0}
/>
)}
</>
) : undefined;
return (
<ChatMessage
role="user"
roleLabel={t("ai.chat.roles.you")}
timestamp={message.createdAt}
content={message.content}
images={message.images}
onImageClick={onPreview}
actions={actionsNode}
/>
);
};
type TTASystemWarningMessageProps = Pick<
TTAChatMessageProps,
"renderWarning" | "rateLimits"
> & {
message: SystemWarningMessage;
};
const getRateLimitWarningDescriptor = (
variant: AIRateLimitWarningDescriptor["variant"],
rateLimits: TTARateLimits | null | undefined,
): AIRateLimitWarningDescriptor => ({
kind: "rateLimit",
variant,
rateLimit: rateLimits?.rateLimit,
rateLimitRemaining: rateLimits?.rateLimitRemaining,
});
const TTASystemWarningMessage = ({
message,
renderWarning,
rateLimits,
}: TTASystemWarningMessageProps) => {
const { t } = useI18n();
const customWarning = renderWarning?.(
getRateLimitWarningDescriptor(message.variant, rateLimits),
message,
);
if (customWarning !== undefined) {
return <>{customWarning}</>;
}
return (
<ChatMessage
role="system"
roleLabel={t("chat.role.system")}
timestamp={message.createdAt}
content={
message.variant === "messageLimitExceeded"
? t("chat.rateLimit.messageLimit")
: t("chat.rateLimit.generalRateLimit")
}
/>
);
};
type TTAAssistantChatMessageProps = Omit<
TTAChatMessageProps,
"message" | "onPreview"
> & {
message: AssistantMessage;
};
const formatElapsedTime = (elapsedMs?: number | null) => {
if (elapsedMs == null || !Number.isFinite(elapsedMs)) {
return null;
}
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
const seconds = totalSeconds % 60;
const totalMinutes = Math.floor(totalSeconds / 60);
const minutes = totalMinutes % 60;
const hours = Math.floor(totalMinutes / 60);
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, "0")}:${String(
seconds,
).padStart(2, "0")}`;
}
return `${minutes}:${String(seconds).padStart(2, "0")}`;
};
const useLiveNow = (enabled: boolean) => {
const [now, setNow] = useState(Date.now);
useEffect(() => {
if (!enabled) {
return;
}
const interval = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(interval);
}, [enabled]);
return now;
};
const getStreamingProgressLabel = (
status: Extract<AssistantStatus, { kind: "streaming" }>,
t: ReturnType<typeof useI18n>["t"],
): string | null => {
const statusText = status.statusText?.trim();
if (statusText) {
return statusText;
}
switch (status.phase) {
case "thinking":
return t("ai.chat.status.thinking");
case "finalizing":
return t("ai.chat.status.finalizing");
default:
return null;
}
};
const TTAAssistantChatMessage = ({
message,
onInsert,
onRetry,
showRetry = false,
onDelete,
showDelete = false,
scrollChatToBottom,
renderWarning,
rateLimits,
}: TTAAssistantChatMessageProps) => {
const { t } = useI18n();
const messageContainerRef = useRef<HTMLDivElement>(null);
const scrolledStreamingPreviewKeyRef = useRef<string | null>(null);
const pendingStreamingPreviewScrollKeyRef = useRef<string | null>(null);
const { previewSvg, status: previewStatus } = useAIAssistantPreview(message);
const status = message.status;
const isStreaming = status.kind === "streaming";
const hasCurrentPreview = Boolean(previewSvg);
const assistantError = status.kind === "error" ? status.error : null;
const liveNow = useLiveNow(isStreaming);
const handleGeneratedPreviewClick = useCallback(() => {
if (!message.skeletons?.length) {
return;
}
onInsert();
}, [message.skeletons?.length, onInsert]);
const previewKey = message.id;
const shouldScrollStreamingPreviewOnLoad =
isStreaming && previewStatus === "done" && Boolean(previewSvg);
useEffect(() => {
if (!shouldScrollStreamingPreviewOnLoad) {
return;
}
if (scrolledStreamingPreviewKeyRef.current === previewKey) {
return;
}
pendingStreamingPreviewScrollKeyRef.current = previewKey;
}, [previewKey, shouldScrollStreamingPreviewOnLoad]);
const handleGeneratedPreviewLoad = useCallback(() => {
if (scrolledStreamingPreviewKeyRef.current === previewKey) {
return;
}
if (
pendingStreamingPreviewScrollKeyRef.current !== previewKey &&
!shouldScrollStreamingPreviewOnLoad
) {
return;
}
pendingStreamingPreviewScrollKeyRef.current = null;
scrolledStreamingPreviewKeyRef.current = previewKey;
requestAnimationFrame(() => {
scrollChatToBottom?.({
keepElementTopVisible: messageContainerRef.current,
behavior: "auto",
});
});
}, [previewKey, scrollChatToBottom, shouldScrollStreamingPreviewOnLoad]);
// --- Content visibility ---
// Terminal without a preview: the outcome label ("Generated response." /
// "(empty response)"). Streaming and errors render no content text.
const visibleContent =
status.kind === "done" && !hasCurrentPreview
? status.outcome === "generated"
? t("ai.chat.status.generatedResponse")
: t("ai.chat.status.emptyResponse")
: undefined;
// A failed generation may still carry the partial skeletons streamed before
// the failure (e.g. connection interruptions — C2 in tta.md). Surface them
// so the user can preview/insert the partial result alongside the error.
const hasSalvageablePartialResult = Boolean(
assistantError && message.skeletons?.length,
);
const assistantOutputExists =
(!assistantError || hasSalvageablePartialResult) &&
!visibleContent &&
hasCurrentPreview;
// --- Error presentation ---
const isRateLimitError = assistantError?.code === 429;
const isQuotaExhausted = rateLimits?.rateLimitRemaining === 0;
const errorPresentation = assistantError
? isRateLimitError
? isQuotaExhausted
? t("chat.rateLimit.messageLimit")
: t("chat.rateLimit.generalRateLimit")
: t(
getAIErrorMessageKey(assistantError, {
isOffline:
typeof navigator !== "undefined" ? !navigator.onLine : false,
}),
)
: null;
// A generation rejected with 429 keeps the host warning treatment: the
// whole row is replaced, exactly like a SystemWarningMessage row. The
// rate-limit numbers come from the rate-limits atom, not the message.
const rateLimitVariant: AIRateLimitWarningDescriptor["variant"] =
isQuotaExhausted ? "messageLimitExceeded" : "rateLimitExceeded";
const customWarning = isRateLimitError
? renderWarning?.(
getRateLimitWarningDescriptor(rateLimitVariant, rateLimits),
{
role: "system",
id: message.id,
createdAt: message.createdAt,
variant: rateLimitVariant,
},
)
: undefined;
const retryActionLabel = !assistantError
? t("ai.chat.actions.regenerate")
: t("ai.chat.actions.retry");
const runningElapsedMs =
status.kind === "streaming" ? liveNow - status.startedAt : undefined;
const elapsedLabel = formatElapsedTime(
status.kind === "streaming" ? runningElapsedMs : status.elapsedMs,
);
const stopReasonText =
status.kind === "stopped"
? t(`ai.chat.stopReason.${status.reason}`)
: undefined;
const truncationWarningText =
status.kind === "done" && status.warning
? t("ai.chat.status.truncatedResponse")
: undefined;
const activeProgressLabel =
status.kind === "streaming" ? getStreamingProgressLabel(status, t) : null;
const headerProgress = isStreaming ? (
<div
className="tta-chat-message__header-progress"
aria-label={
activeProgressLabel
? `${activeProgressLabel} ${elapsedLabel ?? ""}`.trim()
: elapsedLabel ?? undefined
}
title={activeProgressLabel ?? undefined}
>
<span className="tta-chat-message__progress-spinner" aria-hidden />
{elapsedLabel && (
<span className="tta-chat-message__progress-time">{elapsedLabel}</span>
)}
</div>
) : undefined;
const statusLine = (() => {
if (isStreaming) {
if (!activeProgressLabel) {
return undefined;
}
return (
<div
className="tta-chat-message__progress tta-chat-message__progress--message"
aria-live="polite"
>
<span className="tta-chat-message__progress-label">
{activeProgressLabel}
</span>
</div>
);
}
const elapsedSummary = elapsedLabel
? status.kind === "error"
? t("ai.chat.status.failedAfter", { time: elapsedLabel })
: status.kind === "stopped"
? t("ai.chat.status.stoppedAfter", { time: elapsedLabel })
: t("ai.chat.status.completedIn", { time: elapsedLabel })
: undefined;
if (!stopReasonText && !elapsedSummary && !truncationWarningText) {
return undefined;
}
return (
<div className="tta-chat-message__progress tta-chat-message__progress--complete">
{truncationWarningText && (
<span className="tta-chat-message__progress-label">
{truncationWarningText}
</span>
)}
{stopReasonText && (
<span className="tta-chat-message__progress-label">
{stopReasonText}
</span>
)}
{elapsedSummary && (
<span className="tta-chat-message__progress-time">
{elapsedSummary}
</span>
)}
</div>
);
})();
if (customWarning !== undefined) {
return <>{customWarning}</>;
}
// --- Error node ---
const errorNode = errorPresentation ? (
<div
className="tta-chat-message__error-details"
role="alert"
aria-live="polite"
>
<div className="tta-chat-message__error-message">{errorPresentation}</div>
</div>
) : undefined;
// --- Actions ---
const shouldShowActions = status.kind !== "streaming";
const actionsNode = shouldShowActions ? (
<>
{assistantOutputExists && (
<ChatMessageActionButton
icon={stackPushIcon}
label="To canvas"
onClick={onInsert}
ariaLabel={t("ai.chat.actions.addToCanvas")}
title={t("ai.chat.actions.addToCanvas")}
disabled={!message.skeletons?.length}
/>
)}
{(assistantError || showRetry) && (
<ChatMessageActionButton
icon={RetryIcon}
label="Retry"
onClick={onRetry}
ariaLabel={retryActionLabel}
title={retryActionLabel}
disabled={isQuotaExhausted}
/>
)}
{showDelete && (
<ChatMessageActionButton
icon={TrashIcon}
onClick={onDelete}
ariaLabel={t("ai.chat.actions.delete")}
title={t("ai.chat.actions.delete")}
danger
/>
)}
</>
) : undefined;
return (
<ChatMessage
role="assistant"
containerRef={messageContainerRef}
roleLabel={t("ai.chat.roles.assistant")}
timestamp={message.createdAt}
content={visibleContent}
isGenerating={isStreaming}
previewSvg={assistantOutputExists ? previewSvg : undefined}
onPreviewClick={handleGeneratedPreviewClick}
onPreviewLoad={handleGeneratedPreviewLoad}
previewLabel={t("ai.chat.preview")}
error={errorNode}
statusLine={statusLine}
headerEnd={headerProgress}
actions={actionsNode}
/>
);
};
export const TTAChatMessage: React.FC<TTAChatMessageProps> = (props) => {
if (props.message.role === "assistant") {
return <TTAAssistantChatMessage {...props} message={props.message} />;
}
if (props.message.role === "system") {
return (
<TTASystemWarningMessage
message={props.message}
renderWarning={props.renderWarning}
rateLimits={props.rateLimits}
/>
);
}
return (
<TTAUserChatMessage
message={props.message}
onPreview={props.onPreview}
onRerun={props.onRerun}
showRerun={props.showRerun}
rateLimits={props.rateLimits}
/>
);
};
-303
View File
@@ -1,303 +0,0 @@
@use "../css/variables.module" as *;
.tta-composer {
max-width: 48rem;
margin: 0 auto;
border-top: 1px solid var(--sidebar-border-color);
padding-top: 1rem;
}
.tta-composer__container {
--tta-composer-shadow: 0px 18px 35px rgba(15, 23, 42, 0.12);
position: relative;
display: flex;
flex-direction: column;
background: var(--island-bg-color);
border-radius: 12px;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.tta-composer__container:hover {
border-color: var(--color-primary-light-darker);
}
.tta-composer__container:focus-within {
border-color: #6965db;
}
.tta-composer__textarea-wrapper {
position: relative;
padding: 0 0 0.5rem;
display: flex;
}
.tta-composer__textarea {
flex: 1;
resize: none;
border: none !important;
background: var(--island-bg-color) !important;
color: var(--text-primary-color);
font-family: inherit;
font-size: 1rem;
line-height: 1.5;
padding: 0 !important;
max-height: 240px;
overflow-y: auto;
white-space: pre-wrap !important;
word-break: break-word !important;
overflow-wrap: anywhere;
scrollbar-width: none;
scrollbar-color: transparent transparent;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
&::placeholder {
color: var(--color-muted-darker);
}
&:focus {
outline: none;
}
&:is(:hover, :focus-visible, :active) {
scrollbar-width: thin;
scrollbar-color: rgba(105, 101, 219, 0.18) transparent;
}
&:is(:hover, :focus-visible, :active)::-webkit-scrollbar {
width: 0.35rem;
height: 0.35rem;
}
&:is(:hover, :focus-visible, :active)::-webkit-scrollbar-thumb {
background-color: var(--island-bg-color);
border-radius: 999px;
}
}
.tta-composer__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0 0.75rem;
}
.tta-composer__toolbar-section {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
}
.tta-composer__toolbar-section:last-child {
justify-content: flex-end;
}
.tta-composer__icon-button {
border: none;
background: transparent;
padding: 0.5rem;
border-radius: 8px;
color: var(--color-muted-darker);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
&:hover {
color: var(--color-primary);
background: var(--color-primary-light);
}
&:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--color-primary-light-darker);
}
}
.tta-composer__image-icon {
width: 1.25rem;
height: 1.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
transform: translateX(-1px);
}
.tta-composer__plus-icon {
width: 1.25rem;
height: 1.25rem;
}
.tta-composer__audio-btn {
display: none;
svg {
width: 1rem;
height: 1rem;
}
}
.tta-composer__char-count {
display: none;
font-size: 0.75rem;
color: var(--color-muted);
font-weight: 500;
@media (min-width: 640px) {
display: inline-block;
}
}
.tta-composer__send-btn {
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 8px;
background-color: var(--sidebar-border-color);
color: var(--color-muted-darker, #999);
transition: transform 0.2s ease, background 0.2s ease, color 0.2s ease,
box-shadow 0.2s ease;
&:disabled {
cursor: not-allowed;
color: var(--color-muted-darker, #999);
.tta-composer__send-icon {
color: var(--color-muted-darker, #999);
}
}
}
.tta-composer__send-btn--active {
background-color: var(--color-primary);
color: var(--color-icon-white);
cursor: pointer;
}
.tta-composer__send-btn--active:hover {
transform: scale(1.05) translateY(-1px);
background-color: var(--color-primary-darker);
}
.tta-composer__send-btn--active:active {
transform: scale(0.95);
}
.tta-composer__send-icon {
width: 1.1rem;
height: 1.1rem;
transition: margin-left 0.2s ease;
}
.tta-composer__send-icon--active {
/* margin-left: 0.125rem; */ /* Removed for up arrow alignment */
}
.tta-composer__stop-btn {
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 8px;
background-color: var(--text-primary-color);
color: var(--color-icon-white);
cursor: pointer;
transition: transform 0.2s ease, background 0.2s ease;
&:hover {
transform: scale(1.05);
opacity: 0.9;
}
&:active {
transform: scale(0.95);
}
}
.tta-composer__stop-icon {
width: 1rem;
height: 1rem;
}
.tta-composer__container:focus-within .tta-composer__focus-glow {
opacity: 1;
}
.tta-composer__disclaimer {
margin-top: 0.75rem;
text-align: center;
font-size: 0.75rem;
color: var(--color-muted-darker);
}
.tta-composer__preview {
padding: 0.5rem 1rem;
display: flex;
gap: 0.5rem;
overflow-x: auto;
scrollbar-width: none;
}
.tta-composer__preview-image-container {
position: relative;
width: 60px;
height: 60px;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--sidebar-border-color);
background: var(--default-bg-color);
flex-shrink: 0;
&:hover .tta-composer__preview-close {
opacity: 1;
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
.tta-composer__preview-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.tta-composer__preview-close {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
color: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
opacity: 0;
transition: opacity 0.2s ease, background 0.2s ease;
&:hover {
background: rgba(0, 0, 0, 0.8);
}
svg {
width: 100%;
height: 100%;
}
}
-397
View File
@@ -1,397 +0,0 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
import { IMAGE_MIME_TYPES, KEYS, MIME_TYPES } from "@excalidraw/common";
import {
ImageIcon,
SendIcon,
CloseIcon,
playerStopFilledIcon,
} from "../components/icons";
import { generateIdFromFile, getDataURL, resizeImageFile } from "../data/blob";
import { fileOpen } from "../data/filesystem";
import { useI18n } from "../i18n";
import { TTAWarningMessage } from "./TTAWarningMessage";
import "./TTAComposer.scss";
export type TTAComposerImage = {
hash: string;
image: string;
};
interface TTAComposerProps {
onSend: (message: string, images?: string[]) => void;
onStop?: () => void;
isSending?: boolean;
value: string;
onChange: (value: string) => void;
images: TTAComposerImage[];
onImagesChange: (images: TTAComposerImage[]) => void;
maxImages: number;
onMaxImages?: (maxImages: number) => React.ReactNode;
placeholder?: string;
onPreviewImage?: (url: string) => void;
disabled?: boolean;
}
const prepareImageAttachment = async (file: File) => {
const resizedFile = await resizeImageFile(file, {
outputType: MIME_TYPES.jpg,
maxWidthOrHeight: 1024,
});
return {
hash: await generateIdFromFile(resizedFile),
image: await getDataURL(resizedFile),
};
};
const TTAComposer: React.FC<TTAComposerProps> = ({
onSend,
onStop,
isSending = false,
value: inputValue,
onChange,
images: selectedImages,
onImagesChange,
maxImages: MAX_IMAGES,
onMaxImages,
placeholder,
onPreviewImage,
disabled = false,
}) => {
const { t } = useI18n();
const [maxImagesWarning, setMaxImagesWarning] =
useState<React.ReactNode>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// `appendImageFiles` reads the image list across `await`s, where the
// `images` prop may be a render behind — the ref always holds the latest.
const selectedImagesRef = useRef<readonly TTAComposerImage[]>([]);
useEffect(() => {
selectedImagesRef.current = selectedImages;
}, [selectedImages]);
const updateImages = useCallback(
(next: TTAComposerImage[]) => {
selectedImagesRef.current = next;
onImagesChange(next);
},
[onImagesChange],
);
const resetValue = useCallback(() => {
onChange("");
updateImages([]);
setMaxImagesWarning(null);
}, [onChange, updateImages]);
const showMaxImagesWarning = useCallback(() => {
setMaxImagesWarning(
onMaxImages
? onMaxImages(MAX_IMAGES)
: MAX_IMAGES === 1
? t("ai.input.maxImages_one")
: t("ai.input.maxImages", { maxImages: MAX_IMAGES }),
);
}, [MAX_IMAGES, onMaxImages, t]);
const handleInput = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
textarea.style.height = "auto";
const maxHeight = 240;
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
textarea.style.height = `${newHeight}px`;
}, []);
useEffect(() => {
handleInput();
}, [handleInput, inputValue]);
const appendImageFiles = useCallback(
async (files: File[]) => {
if (!files.length) {
return;
}
const currentImages = selectedImagesRef.current;
const remainingSlots = MAX_IMAGES - currentImages.length;
if (remainingSlots <= 0) {
showMaxImagesWarning();
return;
}
const currentImageHashes = new Set(
currentImages.map((image) => image.hash),
);
const candidateHashes = new Set<string>();
const imagesToAdd: TTAComposerImage[] = [];
let hasUniqueOverflow = false;
for (const file of files) {
try {
const { hash, image } = await prepareImageAttachment(file);
if (currentImageHashes.has(hash) || candidateHashes.has(hash)) {
continue;
}
if (imagesToAdd.length >= remainingSlots) {
hasUniqueOverflow = true;
break;
}
candidateHashes.add(hash);
imagesToAdd.push({ hash, image });
} catch (error) {
console.error("Failed to process image:", error);
}
}
if (!imagesToAdd.length) {
if (hasUniqueOverflow) {
showMaxImagesWarning();
}
return;
}
updateImages([...currentImages, ...imagesToAdd]);
if (hasUniqueOverflow) {
showMaxImagesWarning();
} else {
setMaxImagesWarning(null);
}
},
[MAX_IMAGES, showMaxImagesWarning, updateImages],
);
const handleSend = useCallback(() => {
if (disabled) {
return;
}
const trimmed = inputValue.trim();
if (!trimmed && !selectedImages.length) {
return;
}
onSend(
trimmed,
selectedImages.length
? selectedImages.map((image) => image.image)
: undefined,
);
resetValue();
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
}, 0);
}
}, [disabled, inputValue, selectedImages, onSend, resetValue]);
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (
event.key === KEYS.ENTER &&
!event.shiftKey &&
// don't submit on IME composition confirm
!event.nativeEvent.isComposing
) {
event.preventDefault();
handleSend();
}
};
const handleWheelCapture = (event: React.WheelEvent<HTMLTextAreaElement>) => {
event.stopPropagation();
event.nativeEvent.stopImmediatePropagation?.();
};
const handleOpenImagePicker = async () => {
if (disabled) {
return;
}
if (selectedImagesRef.current.length >= MAX_IMAGES) {
showMaxImagesWarning();
return;
}
try {
const files = await fileOpen({
description: "Image",
extensions: Object.keys(
IMAGE_MIME_TYPES,
) as (keyof typeof IMAGE_MIME_TYPES)[],
multiple: true,
});
await appendImageFiles(files);
} catch (error: any) {
if (error.name !== "AbortError") {
console.error("Failed to select image:", error);
}
}
};
const handlePaste = useCallback(
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (disabled) {
return;
}
const items = event.clipboardData?.items;
if (!items || items.length === 0) {
return;
}
const imageFiles = Array.from(items)
.filter((item) => item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
if (!imageFiles.length) {
return;
}
event.preventDefault();
await appendImageFiles(imageFiles);
},
[appendImageFiles, disabled],
);
const hasContent = inputValue.trim().length > 0 || selectedImages.length > 0;
const canSend = hasContent && !isSending && !disabled;
return (
<div className="tta-composer">
{maxImagesWarning && (
<TTAWarningMessage>{maxImagesWarning}</TTAWarningMessage>
)}
{selectedImages.length > 0 && (
<div className="tta-composer__preview">
{selectedImages.map((selectedImage, index) => (
<div
key={`${selectedImage.hash}-${index}`}
className="tta-composer__preview-image-container"
role={onPreviewImage ? "button" : undefined}
tabIndex={onPreviewImage ? 0 : undefined}
aria-label={
onPreviewImage ? t("ai.chat.enlargedPreview") : undefined
}
onClick={() => onPreviewImage?.(selectedImage.image)}
onKeyDown={(event) => {
if (
onPreviewImage &&
(event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
onPreviewImage(selectedImage.image);
}
}}
>
<img
src={selectedImage.image}
alt={t("ai.chat.attachedImage")}
className="tta-composer__preview-image"
/>
<button
className="tta-composer__preview-close"
onClick={(event) => {
event.stopPropagation();
updateImages(
selectedImages.filter(
(_, imageIndex) => imageIndex !== index,
),
);
setMaxImagesWarning(null);
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
aria-label={t("buttons.remove")}
>
{CloseIcon}
</button>
</div>
))}
</div>
)}
<div className="tta-composer__container">
<div className="tta-composer__textarea-wrapper">
<textarea
ref={textareaRef}
value={inputValue}
onChange={(event) => onChange(event.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onWheelCapture={handleWheelCapture}
placeholder={placeholder || t("ai.input.placeholder")}
rows={1}
className="tta-composer__textarea"
style={{ minHeight: "24px" }}
disabled={disabled}
/>
</div>
</div>
<div className="tta-composer__toolbar">
<div className="tta-composer__toolbar-section">
<button
type="button"
className="tta-composer__icon-button tta-composer__icon-button--image"
aria-label={t("ai.input.add")}
onClick={handleOpenImagePicker}
disabled={disabled}
>
<span className="tta-composer__image-icon">{ImageIcon}</span>
</button>
</div>
<div className="tta-composer__toolbar-section">
{isSending && onStop ? (
<button
type="button"
onClick={onStop}
className="tta-composer__stop-btn"
aria-label={t("ai.input.stop")}
>
<span className="tta-composer__stop-icon">
{playerStopFilledIcon}
</span>
</button>
) : (
<button
type="button"
onClick={handleSend}
disabled={!canSend}
className={`tta-composer__send-btn${
canSend ? " tta-composer__send-btn--active" : ""
}`}
aria-label={t("ai.input.send")}
>
<span
className={`tta-composer__send-icon${
hasContent ? " tta-composer__send-icon--active" : ""
}`}
>
{SendIcon}
</span>
</button>
)}
</div>
</div>
</div>
);
};
export default TTAComposer;
-634
View File
@@ -1,634 +0,0 @@
@import "../css/styles.scss";
// --- Floating Launcher & Button ---
.tta-floating-launcher {
position: fixed;
bottom: 4.5rem;
right: 1rem;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.75rem;
pointer-events: none;
z-index: var(--zIndex-ui-tta-chat);
@media (max-width: 768px) {
display: none;
}
}
.tta-floating-inline {
display: inline-flex;
align-items: center;
pointer-events: auto;
@media (max-width: 768px) {
display: none;
}
.tta-floating-button {
box-shadow: none;
}
}
.tta-floating-button {
pointer-events: auto;
border: none;
border-radius: var(--border-radius-lg);
padding: 0.65rem 1.25rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
background-color: var(--color-primary);
color: var(--color-icon-white);
box-shadow: 0px 14px 30px rgba(0, 0, 0, 0.2);
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
background: var(--color-primary-darker);
}
&--active {
background: var(--color-primary-darker);
}
}
.tta-floating-button__icon {
display: flex;
align-items: center;
justify-content: center;
line-height: 0;
width: 1rem;
height: 1rem;
}
// --- Floating Panel Surface ---
.tta-panel__surface {
pointer-events: auto;
background: var(--default-bg-color, #fff);
border-radius: var(--border-radius-lg);
width: min(400px, calc(100vw - 2.5rem));
height: min(720px, calc(100vh - 10rem));
box-shadow: var(--shadow-island);
position: relative;
overflow: hidden;
}
.tta-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.75rem;
border-bottom: 1px solid var(--sidebar-border-color);
}
.tta-panel__title {
font-size: 1rem;
font-weight: 600;
color: var(--color-primary);
}
.tta-panel__header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
margin-left: auto;
}
.tta-panel__icon-button {
border: 1px solid transparent;
background: transparent;
color: var(--text-primary-color);
padding: 0rem;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
svg {
width: 1rem;
height: 1rem;
}
&:hover {
color: var(--color-primary);
}
}
.tta-panel__new-chat-icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
.tta-panel__new-chat-text {
white-space: nowrap;
}
.tta-panel__icon-button--active {
background: transparent;
border-color: transparent;
color: var(--color-primary);
svg {
fill: currentColor;
}
&:hover {
background: var(--color-primary-light);
}
}
.tta-panel__body {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
// --- Chat Panel Structure ---
.tta-chat-panel {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
font-family: "Assistant", var(--ui-font, system-ui, sans-serif);
}
.tta-chat-panel--floating {
position: relative;
box-shadow: var(--shadow-island);
background-color: var(--island-bg-color);
}
// --- Chat Messages Area ---
.tta-chat-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
min-height: 0;
overflow: hidden;
}
.tta-chat-list {
flex: 1;
background-color: transparent;
border-radius: var(--border-radius-md);
padding: 0.75rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.tta-chat-empty-state {
display: flex;
flex: 1;
flex-direction: column;
gap: 1.1rem;
padding: 1rem;
}
.tta-chat-empty-middle {
flex: 1;
display: flex;
align-items: center;
}
.tta-chat-empty-intro {
width: 100%;
display: grid;
gap: 0.55rem;
}
.tta-chat-empty-top {
width: 100%;
}
.tta-chat-empty-resume-card {
width: 100%;
border: 1px solid var(--sidebar-border-color);
background-color: transparent;
border-radius: var(--border-radius-lg);
padding: 0.65rem;
color: inherit;
display: grid;
grid-template-columns: 88px 1fr;
gap: 0.75rem;
align-items: center;
cursor: pointer;
transition: border-color 0.2s ease, transform 0.2s ease;
&:hover {
border-color: var(--color-primary);
transform: translateY(-0.5px);
}
&:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(105, 101, 219, 0.35);
}
}
.tta-chat-empty-resume-card__preview {
width: 88px;
height: 62px;
border-radius: var(--border-radius-lg);
overflow: hidden;
border: 1px solid var(--sidebar-border-color);
background: var(--sidebar-bg-color);
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.tta-chat-empty-resume-card__placeholder {
width: 100%;
height: 100%;
background: linear-gradient(
135deg,
var(--button-gray-1),
var(--button-gray-2, var(--sidebar-border-color))
);
}
.tta-chat-empty-resume-card__content {
min-width: 0;
text-align: left;
}
.tta-chat-empty-resume-card__label {
font-size: 0.7rem;
font-weight: 600;
color: var(--color-primary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.tta-chat-empty-resume-card__title {
margin-top: 0.1rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-primary-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tta-chat-empty-resume-card__meta {
margin-top: 0.15rem;
font-size: 0.7rem;
color: var(--text-secondary-color);
}
.tta-chat-empty-how-it-works {
width: 100%;
max-width: 100%;
box-sizing: border-box;
background: var(--sidebar-bg-color);
padding: 0.7rem 0.75rem;
}
.tta-chat-empty-how-it-works__title {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-primary-color);
}
.tta-chat-empty-how-it-works__steps {
margin: 0.45rem 0 0;
padding-left: 1rem;
display: grid;
gap: 0.3rem;
font-size: 0.75rem;
color: var(--text-secondary-color);
}
.tta-chat-empty-tagline {
font-size: 0.9rem;
padding: 0 4rem;
color: var(--text-primary-color);
line-height: 1.4;
text-align: center;
}
.tta-panel__support-banner {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 2.4rem;
padding: 0.65rem 0.75rem;
border-top: 1px solid var(--sidebar-border-color);
background: var(--sidebar-bg-color);
font-size: 0.75rem;
}
.tta-panel__support-banner-text {
color: var(--text-secondary-color);
flex: 1;
min-width: 0;
}
.tta-panel__support-banner-links {
display: flex;
align-items: center;
gap: 0.4rem;
white-space: nowrap;
a {
color: var(--color-primary);
text-decoration: underline;
}
}
.tta-panel__support-banner-dismiss {
border: none;
background: transparent;
color: var(--text-secondary-color);
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 0.8rem;
padding: 0;
cursor: pointer;
svg {
width: 0.9rem;
height: 0.9rem;
}
&:hover {
color: var(--text-primary-color);
}
}
.tta-panel__footer {
padding: 0 0.75rem 0.75rem;
}
// --- Preview Modal ---
.tta-preview-modal {
display: flex;
flex-direction: column;
gap: 1rem;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0.85rem;
}
&__title {
font-size: 1.25rem;
font-weight: 600;
}
&__body {
max-height: 70vh;
overflow: auto;
padding: 0.5rem;
background: var(--sidebar-bg-color);
}
img {
width: 100%;
height: auto;
}
&__close {
border: none;
background: transparent;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
color: inherit;
padding: 0.25rem;
}
}
// --- History ---
.tta-history {
position: absolute;
inset: 0;
z-index: 20;
border-radius: inherit;
background-color: var(--island-bg-color);
padding: 0.75rem 0.75rem 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
color: var(--text-primary-color);
}
.tta-history__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.tta-history__title {
font-size: 1rem;
font-weight: 600;
}
.tta-history__back-button {
border: none;
background: transparent;
color: var(--text-primary-color);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: var(--border-radius-lg);
height: 2.25rem;
&:hover {
background-color: var(--button-hover-bg);
}
}
.tta-history__controls {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tta-history__search {
border-radius: var(--border-radius-lg);
padding: 0.85rem 0.6rem;
background: rgba(99, 102, 241, 0.05);
color: var(--text-primary-color);
display: flex;
align-items: center;
gap: 0.4rem;
input {
flex: 1;
border: none;
background: transparent;
font-size: 0.9rem;
color: inherit;
outline: none;
&:focus,
&:focus-visible,
&:active {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
}
}
.tta-history__search-icon {
font-size: 0.9rem;
width: 0.875rem;
height: 0.875rem;
opacity: 0.7;
}
.tta-history__empty {
color: var(--fg-muted);
}
.tta-history__list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-right: -0.75rem;
padding-right: 0.75rem;
}
.tta-history__item {
padding: 0.75rem;
border-radius: var(--border-radius-lg);
border: 1px solid var(--sidebar-border-color);
background-color: transparent;
color: var(--text-primary-color);
display: flex;
flex-direction: column;
gap: 0.75rem;
height: auto;
position: relative;
@at-root .excalidraw.theme--light #{&} {
border-color: rgba(0, 0, 0, 0.06);
}
&:hover {
background-color: rgba(99, 102, 241, 0.05);
cursor: pointer;
.tta-history__actions {
display: flex;
}
}
}
.tta-history__details {
display: flex;
flex-direction: column;
width: 100%;
}
.tta-history__header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
height: 2.25rem;
}
.tta-history__actions {
display: none;
align-items: center;
gap: 0.35rem;
}
.tta-history__action-button--delete:hover {
color: var(--color-danger);
}
.tta-history__edit-actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
.tta-history__edit-actions .tta-panel__icon-button--cancel:hover {
color: var(--color-danger);
}
.tta-history__item-title-edit {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
height: 100%;
}
.tta-history__rename-input {
flex: 1;
font-size: 0.95rem;
padding: 0 0.25rem !important;
height: 1.75rem;
background: transparent !important;
color: var(--text-primary-color);
min-width: 0;
&:focus,
&:focus-visible,
&:active {
background: transparent !important;
border-width: 1px;
box-shadow: none !important;
}
}
.tta-history__item-title {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tta-history__thumb {
width: 100%;
height: 12rem;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
border-bottom: solid 1px var(--sidebar-border-color);
padding-bottom: 0.75rem;
img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 0.5rem;
}
}
-613
View File
@@ -1,613 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React from "react";
import { atom, useAtom } from "../editor-jotai";
import { useApp } from "../components/App";
import ConfirmDialog from "../components/ConfirmDialog";
import { useI18n } from "../i18n";
import { useAppStateValue } from "../hooks/useAppStateValue";
import "./TTADialog.scss";
import TTAComposer, { type TTAComposerImage } from "./TTAComposer";
import { TTADialogTrigger } from "./TTADialogTrigger";
import { TTAIndexedDBAdapter } from "./history";
import {
TTADialogPanel,
type TTADialogPanelActions,
type TTADialogPanelView,
type TTAPreviewModalState,
} from "./TTADialogPanel";
import { getTurnStartIndexForAssistantDelete } from "./chatHelpers";
import { useCanvasDraft } from "./useCanvasDraft";
import { useTTAChatActions } from "./useTTAChatActions";
import { useTTAChatHistory } from "./useTTAChatHistory";
import type {
ChatMessage,
TTAChatScrollOptions,
TTADialogRenderWelcomeScreen,
TTADialogRenderWarning,
TTAPersistenceAdapter,
TTARateLimits,
} from "./types";
import type { TTATransportAdapter } from "./client";
// --- Constants & Helpers ---
const DEFAULT_MAX_IMAGES = 4;
// Atoms for state persistence across component mounts
const ttaChatMessagesAtom = atom<ChatMessage[]>([]);
const ttaRateLimitsAtom = atom<TTARateLimits | null>(null);
export interface TTADialogProps {
maxImages?: number;
onMaxImages?: (maxImages: number) => React.ReactNode;
renderWelcomeScreen?: TTADialogRenderWelcomeScreen;
renderWarning?: TTADialogRenderWarning;
persistenceAdapter?: TTAPersistenceAdapter;
transportAdapter: TTATransportAdapter;
}
const TTADialogContent = ({
maxImages = DEFAULT_MAX_IMAGES,
onMaxImages,
renderWelcomeScreen,
renderWarning,
persistenceAdapter = TTAIndexedDBAdapter,
transportAdapter,
}: TTADialogProps) => {
const [isOpen, setIsOpen] = useState(false);
const [isPinned, setIsPinned] = useState(true);
const app = useApp();
const { t } = useI18n();
const [chatMessages, setChatMessages] = useAtom(ttaChatMessagesAtom);
const [rateLimits, setRateLimits] = useAtom(ttaRateLimitsAtom);
const [composerInputValue, setComposerInputValue] = useState("");
const [composerImages, setComposerImages] = useState<TTAComposerImage[]>([]);
// History Overlay State
const [isHistoryVisible, setIsHistoryVisible] = useState(false);
const history = useTTAChatHistory({
chatMessages,
persistenceAdapter,
});
const { chatHistory, latestHistoryChat, renameChat } = history;
const openSidebar = useAppStateValue("openSidebar");
const chatHistoryRef = useRef<HTMLDivElement>(null);
const previousIsOpenRef = useRef(isOpen);
const previousChatMessageCountRef = useRef(chatMessages.length);
const hasConversation = chatMessages.length > 0;
const [previewModal, setPreviewModal] = useState<TTAPreviewModalState | null>(
null,
);
const [pendingDeleteMessageId, setPendingDeleteMessageId] = useState<
string | null
>(null);
const [pendingDeleteChatId, setPendingDeleteChatId] = useState<string | null>(
null,
);
const pendingDeleteClearsConversation = useMemo(() => {
if (!pendingDeleteMessageId) {
return false;
}
const messageIndex = chatMessages.findIndex(
(message) => message.id === pendingDeleteMessageId,
);
if (messageIndex === -1) {
return false;
}
if (chatMessages[messageIndex].role !== "assistant") {
return false;
}
return (
getTurnStartIndexForAssistantDelete(chatMessages, messageIndex) === 0
);
}, [chatMessages, pendingDeleteMessageId]);
const [dismissedSupportMessageId, setDismissedSupportMessageId] = useState<
string | null
>(null);
const latestAssistantErrorMessageId = useMemo(() => {
const latestMessage = chatMessages.at(-1);
if (
latestMessage &&
latestMessage.role === "assistant" &&
latestMessage.status.kind === "error"
) {
return latestMessage.id;
}
return null;
}, [chatMessages]);
const shouldShowSupportBanner = Boolean(
latestAssistantErrorMessageId &&
latestAssistantErrorMessageId !== dismissedSupportMessageId,
);
const dismissSupportBanner = useCallback(() => {
if (latestAssistantErrorMessageId) {
setDismissedSupportMessageId(latestAssistantErrorMessageId);
}
}, [latestAssistantErrorMessageId]);
const scrollChatToBottom = useCallback((options?: TTAChatScrollOptions) => {
const element = chatHistoryRef.current;
if (!element) {
return;
}
const maxScrollTop = Math.max(
0,
element.scrollHeight - element.clientHeight,
);
const keepElementTopVisible = options?.keepElementTopVisible;
let nextScrollTop = maxScrollTop;
if (keepElementTopVisible) {
const containerRect = element.getBoundingClientRect();
const keepElementRect = keepElementTopVisible.getBoundingClientRect();
const keepElementTopScroll =
element.scrollTop + keepElementRect.top - containerRect.top;
nextScrollTop = Math.min(maxScrollTop, Math.max(0, keepElementTopScroll));
}
if (typeof element.scrollTo === "function") {
element.scrollTo({
top: nextScrollTop,
...(options?.behavior &&
typeof document !== "undefined" &&
"scrollBehavior" in document.documentElement.style
? { behavior: options.behavior }
: null),
});
return;
}
element.scrollTop = nextScrollTop;
}, []);
const handleRateLimitInfo = useCallback(
({
rateLimit,
rateLimitRemaining,
}: {
rateLimit?: number | null;
rateLimitRemaining?: number | null;
}) => {
if (
typeof rateLimit !== "number" ||
!Number.isFinite(rateLimit) ||
typeof rateLimitRemaining !== "number" ||
!Number.isFinite(rateLimitRemaining)
) {
return;
}
setRateLimits({ rateLimit, rateLimitRemaining });
},
[setRateLimits],
);
// --- Layout & Visibility Effects ---
const prevOpenSidebarNameRef = useRef<string | null>(null);
useEffect(() => {
const nextOpenSidebarName = openSidebar?.name ?? null;
const prevOpenSidebarName = prevOpenSidebarNameRef.current;
// If the sidebar has just been opened and the AI chat isn't pinned,
// close the floating panel to avoid overlapping UI.
if (!prevOpenSidebarName && nextOpenSidebarName && !isPinned) {
setIsOpen(false);
}
prevOpenSidebarNameRef.current = nextOpenSidebarName;
}, [openSidebar?.name, isPinned]);
const interactiveCanvas = app?.interactiveCanvas ?? null;
const staticCanvas = app?.canvas ?? null;
// Close panel on canvas click (unless pinned)
useEffect(() => {
if (!isOpen || isPinned) {
return;
}
const canvas = interactiveCanvas ?? staticCanvas;
if (!canvas) {
return;
}
const handlePointerDown = () => {
setIsOpen(false);
};
canvas.addEventListener("pointerdown", handlePointerDown);
return () => {
canvas.removeEventListener("pointerdown", handlePointerDown);
};
}, [interactiveCanvas, staticCanvas, isOpen, isPinned]);
const handleClose = () => setIsOpen(false);
const focusComposerInput = useCallback(() => {
if (typeof document === "undefined") {
return;
}
const input = document.querySelector<HTMLTextAreaElement>(
"#tta-floating-panel .tta-composer__textarea",
);
input?.focus();
}, []);
const closePreviewModal = useCallback(() => {
setPreviewModal(null);
requestAnimationFrame(() => {
focusComposerInput();
});
}, [focusComposerInput]);
const openPreviewModal = useCallback(
(
image: string,
titleKey: TTAPreviewModalState["titleKey"] = "ai.chat.generatedResult",
) => {
setPreviewModal({
image,
titleKey,
});
},
[],
);
useEffect(() => {
if (!previewModal) {
return;
}
const handleEscapeKey = (event: KeyboardEvent) => {
if (
event.key !== "Escape" &&
event.key !== "Esc" &&
event.code !== "Escape"
) {
return;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation?.();
closePreviewModal();
};
document.addEventListener("keydown", handleEscapeKey, true);
return () => {
document.removeEventListener("keydown", handleEscapeKey, true);
};
}, [previewModal, closePreviewModal]);
useEffect(() => {
if (!isOpen) {
return;
}
const handleTabToComposer = (event: KeyboardEvent) => {
if (event.key !== "Tab") {
return;
}
if (
document.activeElement instanceof HTMLElement &&
document.activeElement.closest(
"#tta-floating-panel .tta-composer__textarea",
)
) {
return;
}
event.preventDefault();
focusComposerInput();
};
document.addEventListener("keydown", handleTabToComposer, true);
return () => {
document.removeEventListener("keydown", handleTabToComposer, true);
};
}, [isOpen, focusComposerInput]);
useEffect(() => {
if (isOpen) {
requestAnimationFrame(() => {
focusComposerInput();
});
}
}, [isOpen, focusComposerInput]);
// Scroll to the latest message when the panel (re)opens onto an existing
// conversation and whenever a message is appended.
useEffect(() => {
const wasOpen = previousIsOpenRef.current;
previousIsOpenRef.current = isOpen;
const previousMessageCount = previousChatMessageCountRef.current;
previousChatMessageCountRef.current = chatMessages.length;
const didJustOpen = isOpen && !wasOpen && chatMessages.length > 0;
const didAppendMessage = chatMessages.length > previousMessageCount;
if (!didJustOpen && !didAppendMessage) {
return;
}
const frameId = requestAnimationFrame(() => {
scrollChatToBottom();
});
return () => {
cancelAnimationFrame(frameId);
};
}, [chatMessages.length, isOpen, scrollChatToBottom]);
// --- Chat Actions ---
// The single canvas-draft owner (tta_rewrite_final.md §2.4): streaming
// renders, the commit dance, and the generation-replacement queue, keyed by
// the local generation id (`message.id`).
const canvasDraft = useCanvasDraft({ app });
const clearComposer = useCallback(() => {
setComposerInputValue("");
setComposerImages([]);
}, []);
const hideHistory = useCallback(() => {
setIsHistoryVisible(false);
}, []);
const {
isSendingChat,
latestRetryableAssistantMessageId,
sendChatPrompt,
stopActiveGeneration,
handleStartNewChat,
handleSelectChat,
handleDeleteChat,
handleRetry,
executeDelete,
handleInsertResult,
} = useTTAChatActions({
app,
t,
chatMessages,
setChatMessages,
canvasDraft,
transportAdapter,
history,
rateLimits,
onRateLimitInfo: handleRateLimitInfo,
isPanelOpen: isOpen,
composerText: composerInputValue,
clearComposer,
hideHistory,
scrollChatToBottom,
focusComposerInput,
});
useEffect(() => {
if (!isOpen) {
return;
}
const handleNewChatShortcut = (event: KeyboardEvent) => {
const isShortcutPressed =
(event.metaKey || event.ctrlKey) &&
event.shiftKey &&
!event.altKey &&
!event.repeat &&
event.key.toLowerCase() === "o";
if (
!isShortcutPressed ||
!hasConversation ||
isSendingChat ||
previewModal
) {
return;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation?.();
handleStartNewChat();
};
document.addEventListener("keydown", handleNewChatShortcut, true);
return () => {
document.removeEventListener("keydown", handleNewChatShortcut, true);
};
}, [
hasConversation,
handleStartNewChat,
isOpen,
isSendingChat,
previewModal,
]);
const hideHistoryAndFocusComposer = useCallback(() => {
setIsHistoryVisible(false);
requestAnimationFrame(() => {
focusComposerInput();
});
}, [focusComposerInput]);
const requestDelete = useCallback((messageId: string) => {
setPendingDeleteMessageId(messageId);
}, []);
const cancelDelete = useCallback(() => {
setPendingDeleteMessageId(null);
}, []);
const confirmDelete = useCallback(() => {
if (!pendingDeleteMessageId) {
return;
}
const messageId = pendingDeleteMessageId;
setPendingDeleteMessageId(null);
executeDelete(messageId);
}, [executeDelete, pendingDeleteMessageId]);
// History chat delete goes through the same confirm pattern as message
// delete (tta_rewrite_final.md §8.2) — the delete itself is handled by the
// actions hook (incl. auto-stopping an in-flight generation, N2).
const requestDeleteChat = useCallback((chatId: string) => {
setPendingDeleteChatId(chatId);
}, []);
const cancelDeleteChat = useCallback(() => {
setPendingDeleteChatId(null);
}, []);
const confirmDeleteChat = useCallback(() => {
if (!pendingDeleteChatId) {
return;
}
const chatId = pendingDeleteChatId;
setPendingDeleteChatId(null);
handleDeleteChat(chatId);
}, [handleDeleteChat, pendingDeleteChatId]);
const handleStopGeneration = useCallback(() => {
stopActiveGeneration("user");
}, [stopActiveGeneration]);
// --- Render Sub-components ---
const composerJSX = (
<TTAComposer
onSend={(message, images) => sendChatPrompt(message, images)}
onStop={handleStopGeneration}
isSending={isSendingChat}
value={composerInputValue}
onChange={setComposerInputValue}
images={composerImages}
onImagesChange={setComposerImages}
maxImages={maxImages}
onMaxImages={onMaxImages}
onPreviewImage={(image) =>
openPreviewModal(image, "ai.chat.attachedImage")
}
placeholder={
rateLimits?.rateLimitRemaining === 0
? t("chat.rateLimit.messageLimitInputPlaceholder")
: hasConversation
? t("ai.input.placeholderRefine")
: undefined
}
disabled={rateLimits?.rateLimitRemaining === 0}
/>
);
const panelView: TTADialogPanelView = {
hasConversation,
isSendingChat,
isHistoryVisible,
isPinned,
shouldShowSupportBanner,
composerInputValue,
previewModal,
chatMessages,
chatHistory,
latestHistoryChat,
latestRetryableAssistantMessageId,
composerImages,
rateLimits,
isConfirmDialogOpen: Boolean(pendingDeleteMessageId || pendingDeleteChatId),
};
const panelActions: TTADialogPanelActions = {
onStartNewChat: () => {
handleStartNewChat();
},
onToggleHistory: () => {
if (isHistoryVisible) {
hideHistoryAndFocusComposer();
return;
}
setIsHistoryVisible(true);
},
onTogglePinned: () => {
setIsPinned((prev) => !prev);
},
onClose: handleClose,
onClosePreviewModal: closePreviewModal,
onOpenPreviewModal: openPreviewModal,
onInsertResult: handleInsertResult,
onRetry: (messageId) => {
handleRetry(messageId);
},
// §5.8 re-run: a fresh turn with the same prompt and images — never a
// retry payload (the orphaned turn has no completed attempt to target).
onRerunMessage: (message) => {
if (message.role === "user") {
sendChatPrompt(message.content, message.images);
}
},
onRequestDelete: requestDelete,
scrollChatToBottom,
onDismissSupportBanner: dismissSupportBanner,
onSelectHistoryChat: handleSelectChat,
onDeleteHistoryChat: requestDeleteChat,
onRenameHistoryChat: renameChat,
onHideHistory: hideHistoryAndFocusComposer,
};
return (
<>
<TTADialogTrigger
isOpen={isOpen}
isPinned={isPinned}
hasOpenSidebar={Boolean(openSidebar)}
onToggle={() => {
setIsOpen((prev) => !prev);
}}
>
<TTADialogPanel
view={panelView}
actions={panelActions}
chatHistoryRef={chatHistoryRef}
composer={composerJSX}
renderWelcomeScreen={renderWelcomeScreen}
renderWarning={renderWarning}
/>
</TTADialogTrigger>
{pendingDeleteMessageId && (
<ConfirmDialog
title={t("ai.chat.actions.delete")}
closeOnClickOutside
onCancel={cancelDelete}
onConfirm={confirmDelete}
confirmText={t("ai.chat.actions.delete")}
>
<p>
{pendingDeleteClearsConversation
? t("ai.chat.actions.deletePromptFirstTurn")
: t("ai.chat.actions.deletePrompt")}
</p>
</ConfirmDialog>
)}
{pendingDeleteChatId && (
<ConfirmDialog
title={t("ai.chat.historyPanel.delete")}
closeOnClickOutside
onCancel={cancelDeleteChat}
onConfirm={confirmDeleteChat}
confirmText={t("ai.chat.historyPanel.delete")}
>
<p>{t("ai.chat.historyPanel.deletePrompt")}</p>
</ConfirmDialog>
)}
</>
);
};
export const TTADialog = React.memo((props: TTADialogProps) => {
const app = useApp();
if (app.props.aiEnabled === false) {
return null;
}
return <TTADialogContent {...props} />;
});
TTADialog.displayName = "TTADialog";
export default TTADialog;
@@ -1,151 +0,0 @@
import React from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorJotaiProvider } from "../editor-jotai";
import {
TTADialogPanel,
type TTADialogPanelActions,
type TTADialogPanelView,
} from "./TTADialogPanel";
import type { ChatMessage } from "./types";
// TTAChatMessage renders assistant thumbnails through the preview hook —
// irrelevant here, so stub it to the idle state.
vi.mock("./useAIAssistantPreview", () => ({
useAIAssistantPreview: () => ({ previewSvg: null, status: "idle" }),
evictAssistantPreviews: vi.fn(),
}));
const RERUN_LABEL = "Run again";
const user = (id: string, content: string, images?: string[]): ChatMessage => ({
id,
role: "user",
content,
images,
createdAt: 1,
});
const doneAssistant = (id: string): ChatMessage => ({
id,
role: "assistant",
createdAt: 2,
status: { kind: "done", elapsedMs: 10, outcome: "generated" },
});
const createView = (
overrides: Partial<TTADialogPanelView>,
): TTADialogPanelView => ({
hasConversation: true,
isSendingChat: false,
isHistoryVisible: false,
isPinned: true,
shouldShowSupportBanner: false,
composerInputValue: "",
composerImages: [],
previewModal: null,
chatMessages: [],
chatHistory: [],
latestHistoryChat: null,
latestRetryableAssistantMessageId: null,
rateLimits: null,
isConfirmDialogOpen: false,
...overrides,
});
const createActions = (): TTADialogPanelActions => ({
onStartNewChat: vi.fn(),
onToggleHistory: vi.fn(),
onTogglePinned: vi.fn(),
onClose: vi.fn(),
onClosePreviewModal: vi.fn(),
onOpenPreviewModal: vi.fn(),
onInsertResult: vi.fn(),
onRetry: vi.fn(),
onRerunMessage: vi.fn(),
onRequestDelete: vi.fn(),
scrollChatToBottom: vi.fn(),
onDismissSupportBanner: vi.fn(),
onSelectHistoryChat: vi.fn(),
onDeleteHistoryChat: vi.fn(),
onRenameHistoryChat: vi.fn(),
onHideHistory: vi.fn(),
});
const renderPanel = (view: TTADialogPanelView) => {
const actions = createActions();
render(
<EditorJotaiProvider>
<TTADialogPanel
view={view}
actions={actions}
chatHistoryRef={React.createRef<HTMLDivElement>()}
composer={null}
/>
</EditorJotaiProvider>,
);
return actions;
};
describe("TTADialogPanel re-run affordance (§5.8)", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("renders on a trailing prompt-only user turn and re-runs that exact message", () => {
const trailing = user("user-2", "add a dog", ["data:image/jpeg;base64,x"]);
const actions = renderPanel(
createView({
chatMessages: [
user("user-1", "draw a cat"),
doneAssistant("assistant-1"),
trailing,
],
}),
);
const rerunButtons = screen.getAllByRole("button", { name: RERUN_LABEL });
// exactly one — never on mid-list user turns
expect(rerunButtons).toHaveLength(1);
fireEvent.click(rerunButtons[0]);
expect(actions.onRerunMessage).toHaveBeenCalledTimes(1);
expect(actions.onRerunMessage).toHaveBeenCalledWith(trailing);
});
it("is hidden while a generation is in flight", () => {
renderPanel(
createView({
isSendingChat: true,
chatMessages: [
user("user-1", "draw a cat"),
doneAssistant("assistant-1"),
user("user-2", "add a dog"),
],
}),
);
expect(
screen.queryByRole("button", { name: RERUN_LABEL }),
).not.toBeInTheDocument();
});
it("is hidden when the trailing turn has an assistant reply", () => {
renderPanel(
createView({
chatMessages: [
user("user-1", "draw a cat"),
doneAssistant("assistant-1"),
],
}),
);
expect(
screen.queryByRole("button", { name: RERUN_LABEL }),
).not.toBeInTheDocument();
});
});
-377
View File
@@ -1,377 +0,0 @@
import clsx from "clsx";
import { FilledButton } from "../components/FilledButton";
import { CloseIcon, PinIcon, historyIcon } from "../components/icons";
import { Modal } from "../components/Modal";
import { Tooltip } from "../components/Tooltip";
import { useI18n } from "../i18n";
import { TTAChatEmptyState } from "./TTAChatEmptyState";
import { TTAChatMessage } from "./TTAChatMessage";
import { TTAHistory } from "./TTAHistory";
import type { ReactNode, RefObject } from "react";
import type {
ChatMessage,
ChatConversation,
TTAChatScrollOptions,
TTADialogRenderWelcomeScreen,
TTADialogRenderWarning,
TTARateLimits,
} from "./types";
const AI_SUPPORT_GITHUB_ISSUES_URL =
"https://github.com/excalidraw/excalidraw/issues";
const AI_SUPPORT_DISCORD_URL = "https://discord.gg/UexuTaE";
export type TTAPreviewModalState = {
image: string;
titleKey: "ai.chat.generatedResult" | "ai.chat.attachedImage";
};
export type TTADialogPanelView = {
hasConversation: boolean;
isSendingChat: boolean;
isHistoryVisible: boolean;
isPinned: boolean;
shouldShowSupportBanner: boolean;
composerInputValue: string;
composerImages: readonly unknown[];
previewModal: TTAPreviewModalState | null;
chatMessages: ChatMessage[];
chatHistory: ChatConversation[];
latestHistoryChat: ChatConversation | null;
latestRetryableAssistantMessageId: string | null;
rateLimits: TTARateLimits | null;
/** A ConfirmDialog is open on top of the panel (suspends overlay Escape). */
isConfirmDialogOpen: boolean;
};
export type TTADialogPanelActions = {
onStartNewChat: () => void;
onToggleHistory: () => void;
onTogglePinned: () => void;
onClose: () => void;
onClosePreviewModal: () => void;
onOpenPreviewModal: (
image: string,
titleKey?: TTAPreviewModalState["titleKey"],
) => void;
onInsertResult: (message: ChatMessage) => void;
onRetry: (messageId: string) => void;
/** Re-runs a trailing prompt-only user turn as a fresh send (§5.8). */
onRerunMessage: (message: ChatMessage) => void;
onRequestDelete: (messageId: string) => void;
scrollChatToBottom: (options?: TTAChatScrollOptions) => void;
onDismissSupportBanner: () => void;
onSelectHistoryChat: (chat: ChatConversation) => void;
onDeleteHistoryChat: (chatId: string) => void;
onRenameHistoryChat: (chatId: string, newTitle: string) => void;
onHideHistory: () => void;
};
export type TTADialogPanelProps = {
view: TTADialogPanelView;
actions: TTADialogPanelActions;
chatHistoryRef: RefObject<HTMLDivElement | null>;
composer: ReactNode;
renderWelcomeScreen?: TTADialogRenderWelcomeScreen;
renderWarning?: TTADialogRenderWarning;
};
type TTADialogPanelHeaderProps = {
hasConversation: boolean;
isSendingChat: boolean;
isHistoryVisible: boolean;
isPinned: boolean;
onStartNewChat: () => void;
onToggleHistory: () => void;
onTogglePinned: () => void;
onClose: () => void;
};
const TTADialogPanelHeader = ({
hasConversation,
isSendingChat,
isHistoryVisible,
isPinned,
onStartNewChat,
onToggleHistory,
onTogglePinned,
onClose,
}: TTADialogPanelHeaderProps) => {
const { t } = useI18n();
return (
<div className="tta-panel__header">
{hasConversation && (
<FilledButton
className=""
onClick={onStartNewChat}
disabled={isSendingChat}
size="small"
>
{t("chat.newChat")}
</FilledButton>
)}
<div className="tta-panel__header-actions">
<Tooltip label={t("ai.chat.history")}>
<button
type="button"
className="tta-panel__icon-button"
onClick={onToggleHistory}
aria-pressed={isHistoryVisible}
aria-label={t("ai.chat.history")}
>
{historyIcon}
</button>
</Tooltip>
<Tooltip label={t("ai.chat.keepOpen")}>
<button
type="button"
className={clsx("tta-panel__icon-button ", {
"tta-panel__icon-button--active": isPinned,
})}
onClick={onTogglePinned}
aria-label={t("ai.chat.keepOpen")}
aria-pressed={isPinned}
>
{PinIcon}
</button>
</Tooltip>
<Tooltip label={t("ai.chat.close")}>
<button
type="button"
className="tta-panel__icon-button"
onClick={onClose}
aria-label={t("ai.chat.close")}
>
{CloseIcon}
</button>
</Tooltip>
</div>
</div>
);
};
const TTADialogPreviewModal = ({
previewModal,
onClosePreviewModal,
}: {
previewModal: TTAPreviewModalState | null;
onClosePreviewModal: () => void;
}) => {
const { t } = useI18n();
if (!previewModal) {
return null;
}
return (
<Modal
labelledBy="tta-preview-modal-title"
maxWidth={720}
onCloseRequest={onClosePreviewModal}
>
<div className="tta-preview-modal">
<div className="tta-preview-modal__header">
<h2 id="tta-preview-modal-title" className="tta-preview-modal__title">
{t(previewModal.titleKey)}
</h2>
<button
type="button"
className="tta-preview-modal__close"
onClick={onClosePreviewModal}
aria-label={t("ai.chat.closePreview")}
>
{CloseIcon}
</button>
</div>
<div className="tta-preview-modal__body">
<img src={previewModal.image} alt={t("ai.chat.enlargedPreview")} />
</div>
</div>
</Modal>
);
};
const TTADialogPanelBody = ({
view,
actions,
chatHistoryRef,
renderWelcomeScreen,
renderWarning,
}: {
view: Pick<
TTADialogPanelView,
| "composerInputValue"
| "composerImages"
| "chatMessages"
| "isSendingChat"
| "latestHistoryChat"
| "latestRetryableAssistantMessageId"
| "rateLimits"
>;
actions: Pick<
TTADialogPanelActions,
| "onSelectHistoryChat"
| "onInsertResult"
| "onRetry"
| "onRerunMessage"
| "onRequestDelete"
| "onOpenPreviewModal"
| "scrollChatToBottom"
>;
chatHistoryRef: RefObject<HTMLDivElement | null>;
renderWelcomeScreen?: TTADialogRenderWelcomeScreen;
renderWarning?: TTADialogRenderWarning;
}) => (
<div className="tta-panel__body">
<div className="tta-chat-container">
{!view.chatMessages.length &&
!view.composerInputValue &&
!view.composerImages.length && (
<TTAChatEmptyState
latestHistoryChat={view.latestHistoryChat}
onSelectHistoryChat={actions.onSelectHistoryChat}
rateLimits={view.rateLimits}
renderWelcomeScreen={renderWelcomeScreen}
/>
)}
{!!view.chatMessages.length && (
<div className="tta-chat-list" ref={chatHistoryRef}>
{view.chatMessages.map((message, index) => (
<TTAChatMessage
key={message.id}
message={message}
onInsert={() => actions.onInsertResult(message)}
onRetry={() => actions.onRetry(message.id)}
showRetry={
message.role === "assistant" &&
message.id === view.latestRetryableAssistantMessageId
}
onRerun={() => actions.onRerunMessage(message)}
showRerun={
// §5.8 re-run affordance: a trailing user message with no
// assistant/system reply (reload or switch-away
// mid-generation) can be run again as a fresh turn — hidden
// whenever a generation is in flight (single-flight, so also
// while another chat's generation streams in the background).
message.role === "user" &&
index === view.chatMessages.length - 1 &&
!view.isSendingChat
}
rateLimits={view.rateLimits}
onDelete={() => actions.onRequestDelete(message.id)}
showDelete={message.role === "assistant"}
onPreview={(image) =>
actions.onOpenPreviewModal(
image,
message.role === "user"
? "ai.chat.attachedImage"
: "ai.chat.generatedResult",
)
}
scrollChatToBottom={actions.scrollChatToBottom}
renderWarning={renderWarning}
/>
))}
</div>
)}
</div>
</div>
);
const TTADialogSupportBanner = ({
onDismissSupportBanner,
}: {
onDismissSupportBanner: () => void;
}) => {
const { t } = useI18n();
return (
<div className="tta-panel__support-banner">
<span className="tta-panel__support-banner-text">
{t("ai.chat.errors.supportBanner")}
</span>
<div className="tta-panel__support-banner-links">
<a href={AI_SUPPORT_GITHUB_ISSUES_URL} target="_blank" rel="noreferrer">
{t("ai.chat.errors.githubIssue")}
</a>
<span aria-hidden="true">·</span>
<a href={AI_SUPPORT_DISCORD_URL} target="_blank" rel="noreferrer">
{t("labels.discordChat")}
</a>
</div>
<button
type="button"
className="tta-panel__support-banner-dismiss"
onClick={onDismissSupportBanner}
aria-label={t("ai.chat.close")}
>
{CloseIcon}
</button>
</div>
);
};
export const TTADialogPanel = ({
view,
actions,
chatHistoryRef,
composer,
renderWelcomeScreen,
renderWarning,
}: TTADialogPanelProps) => (
<div
className="tta-panel__surface"
id="tta-floating-panel"
role="dialog"
aria-modal="false"
>
<div className={clsx("tta-chat-panel", "tta-chat-panel--floating")}>
<TTADialogPanelHeader
hasConversation={view.hasConversation}
isSendingChat={view.isSendingChat}
isHistoryVisible={view.isHistoryVisible}
isPinned={view.isPinned}
onStartNewChat={actions.onStartNewChat}
onToggleHistory={actions.onToggleHistory}
onTogglePinned={actions.onTogglePinned}
onClose={actions.onClose}
/>
<TTADialogPreviewModal
previewModal={view.previewModal}
onClosePreviewModal={actions.onClosePreviewModal}
/>
<TTADialogPanelBody
view={view}
actions={actions}
chatHistoryRef={chatHistoryRef}
renderWelcomeScreen={renderWelcomeScreen}
renderWarning={renderWarning}
/>
{view.shouldShowSupportBanner && (
<TTADialogSupportBanner
onDismissSupportBanner={actions.onDismissSupportBanner}
/>
)}
<div className="tta-panel__footer">{composer}</div>
{view.isHistoryVisible && (
<TTAHistory
history={view.chatHistory}
onSelectChat={actions.onSelectHistoryChat}
onDeleteChat={actions.onDeleteHistoryChat}
onRenameChat={actions.onRenameHistoryChat}
onClose={actions.onHideHistory}
suspendEscape={view.isConfirmDialogOpen}
/>
)}
</div>
</div>
);
@@ -1,134 +0,0 @@
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
import { useAtomValue } from "../editor-jotai";
import { MagicIconThin } from "../components/icons";
import { isSidebarDockedAtom } from "../components/Sidebar/Sidebar";
import { useTunnels } from "../context/tunnels";
import type { ReactNode } from "react";
type TTADialogTriggerProps = {
isOpen: boolean;
isPinned: boolean;
hasOpenSidebar: boolean;
onToggle: () => void;
children: ReactNode;
};
export const TTADialogTrigger = ({
isOpen,
isPinned,
hasOpenSidebar,
onToggle,
children,
}: TTADialogTriggerProps) => {
const { TTADialogTriggerTunnel } = useTunnels();
const isSidebarDocked = useAtomValue(isSidebarDockedAtom);
const launcherRef = useRef<HTMLDivElement>(null);
const [sidebarShiftPx, setSidebarShiftPx] = useState(0);
const [triggerShiftPx, setTriggerShiftPx] = useState(0);
useEffect(() => {
const needsPanelShift = isOpen && isPinned && hasOpenSidebar;
const needsTriggerShift = hasOpenSidebar && !isSidebarDocked;
if (!needsPanelShift && !needsTriggerShift) {
setSidebarShiftPx(0);
setTriggerShiftPx(0);
return;
}
const sidebarEl = document.querySelector<HTMLElement>(".sidebar");
if (!sidebarEl) {
setSidebarShiftPx(0);
setTriggerShiftPx(0);
return;
}
const updateShift = () => {
const sidebarRect = sidebarEl.getBoundingClientRect();
const sidebarWidth = sidebarRect.width;
const remPx =
parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
if (needsPanelShift) {
// `.tta-floating-launcher` uses `right: 1rem` by default, so subtract it
// then add 1rem gap so the panel doesn't sit flush against the sidebar.
const launcherRight =
parseFloat(
getComputedStyle(launcherRef.current ?? sidebarEl).right,
) || 0;
setSidebarShiftPx(Math.max(0, sidebarWidth - launcherRight + remPx));
} else {
setSidebarShiftPx(0);
}
if (needsTriggerShift) {
// When sidebar is undocked (floating), position the trigger button
// with the same gap from the sidebar as the dialog panel.
setTriggerShiftPx(sidebarWidth + remPx);
} else {
setTriggerShiftPx(0);
}
};
updateShift();
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(updateShift);
resizeObserver?.observe(sidebarEl);
window.addEventListener("resize", updateShift);
return () => {
resizeObserver?.disconnect();
window.removeEventListener("resize", updateShift);
};
}, [hasOpenSidebar, isOpen, isPinned, isSidebarDocked]);
const launcherStyle = {
transform: sidebarShiftPx ? `translateX(-${sidebarShiftPx}px)` : undefined,
} as const;
const triggerStyle = triggerShiftPx
? ({
position: "fixed" as const,
bottom: "1rem",
right: `${triggerShiftPx}px`,
zIndex: 100,
} as const)
: undefined;
return (
<>
<TTADialogTriggerTunnel.In>
<div className="tta-floating-inline" style={triggerStyle}>
<button
className={clsx("tta-floating-button", {
"tta-floating-button--active": isOpen,
})}
type="button"
aria-expanded={isOpen}
aria-controls="tta-floating-panel"
onClick={onToggle}
>
<span className="tta-floating-button__icon">{MagicIconThin}</span>
<span>Generate</span>
</button>
</div>
</TTADialogTriggerTunnel.In>
{isOpen && (
<div
ref={launcherRef}
className="tta-floating-launcher"
style={launcherStyle}
>
{children}
</div>
)}
</>
);
};
-386
View File
@@ -1,386 +0,0 @@
import React, {
useState,
useEffect,
useMemo,
useCallback,
useRef,
} from "react";
import { useI18n } from "../i18n";
import {
CloseIcon,
searchIcon,
checkIcon,
FreedrawIcon,
TrashIcon,
} from "../components/icons";
import "./TTADialog.scss";
import { TTA_CHAT_EMPTY_PLACEHOLDER_SRC } from "./assets";
import {
compareConversationsByUpdatedAt,
getConversationPreviewMessage,
} from "./chatHelpers";
import { useAIAssistantPreview } from "./useAIAssistantPreview";
import type { AssistantMessage, ChatConversation } from "./types";
export interface TTAHistoryProps {
history: ChatConversation[];
onSelectChat: (chat: ChatConversation) => void;
onDeleteChat: (chatId: string) => void;
onRenameChat: (chatId: string, newTitle: string) => void;
onClose: () => void;
/**
* Suspends the overlay's window-capture Escape handling while a modal sits
* on top of it (the chat-delete ConfirmDialog) — that capture listener runs
* before any handler of the modal itself, so without this the overlay would
* close behind the modal on Escape.
*/
suspendEscape?: boolean;
}
const TTAHistoryThumbnailPlaceholder = () => (
<div className="tta-history__thumb">
<img src={TTA_CHAT_EMPTY_PLACEHOLDER_SRC} alt="" />
</div>
);
const TTAHistoryThumbnail = ({
chatId,
isVisible,
message,
}: {
chatId: string;
isVisible: boolean;
message: AssistantMessage;
}) => {
const { previewSvg } = useAIAssistantPreview(message, {
enabled: isVisible,
});
return (
<div className="tta-history__thumb" data-chat-id={chatId}>
<img src={previewSvg || TTA_CHAT_EMPTY_PLACEHOLDER_SRC} alt="" />
</div>
);
};
export const TTAHistory: React.FC<TTAHistoryProps> = ({
history,
onSelectChat,
onDeleteChat,
onRenameChat,
onClose,
suspendEscape = false,
}) => {
const { t } = useI18n();
const searchInputRef = useRef<HTMLInputElement>(null);
const historyListRef = useRef<HTMLDivElement>(null);
const [visiblePreviewChatIds, setVisiblePreviewChatIds] = useState<
Set<string>
>(() => new Set());
const visiblePreviewChatIdsRef = useRef<Set<string>>(new Set());
const [historySearch, setHistorySearch] = useState("");
const [editingChatId, setEditingChatId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState("");
useEffect(() => {
searchInputRef.current?.focus();
}, []);
// Handle Escape key to close history or cancel editing
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") {
return;
}
if (suspendEscape) {
// a modal on top of the overlay owns Escape right now
return;
}
if (editingChatId) {
setEditingChatId(null);
event.stopPropagation();
return;
}
if (historySearch) {
setHistorySearch("");
event.preventDefault();
return;
}
onClose();
event.preventDefault();
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [editingChatId, historySearch, onClose, suspendEscape]);
const handleStartRename = (chat: ChatConversation, e: React.MouseEvent) => {
e.stopPropagation();
setEditingChatId(chat.id);
setEditingTitle(chat.title);
};
const handleConfirmRename = (chatId: string, e?: React.MouseEvent) => {
e?.stopPropagation();
if (editingTitle.trim()) {
onRenameChat(chatId, editingTitle.trim());
}
setEditingChatId(null);
setEditingTitle("");
};
const handleCancelRename = (e: React.MouseEvent) => {
e.stopPropagation();
setEditingChatId(null);
setEditingTitle("");
};
const normalizedSearch = historySearch.trim().toLowerCase();
const filteredHistory = useMemo(
() =>
history
.filter((item) => {
if (!normalizedSearch) {
return true;
}
return (
item.title.toLowerCase().includes(normalizedSearch) ||
item.messages.some(
(message) =>
message.role === "user" &&
message.content.toLowerCase().includes(normalizedSearch),
)
);
})
.sort(compareConversationsByUpdatedAt),
[history, normalizedSearch],
);
const markPreviewChatIdsVisible = useCallback((chatIds: string[]) => {
if (!chatIds.length) {
return;
}
setVisiblePreviewChatIds((prev) => {
const next = new Set(prev);
let didChange = false;
for (const chatId of chatIds) {
if (next.has(chatId)) {
continue;
}
next.add(chatId);
didChange = true;
}
if (!didChange) {
return prev;
}
visiblePreviewChatIdsRef.current = next;
return next;
});
}, []);
useEffect(() => {
const historyList = historyListRef.current;
if (!historyList) {
return;
}
const thumbnailElements = Array.from(
historyList.querySelectorAll<HTMLElement>(
".tta-history__thumb[data-chat-id]",
),
);
if (!thumbnailElements.length) {
return;
}
if (typeof IntersectionObserver === "undefined") {
markPreviewChatIdsVisible(
thumbnailElements
.map((element) => element.dataset.chatId)
.filter((chatId): chatId is string => Boolean(chatId)),
);
return;
}
const observer = new IntersectionObserver(
(entries) => {
const intersectingChatIds: string[] = [];
for (const entry of entries) {
if (!entry.isIntersecting) {
continue;
}
const target = entry.target as HTMLElement;
const chatId = target.dataset.chatId;
if (!chatId) {
continue;
}
intersectingChatIds.push(chatId);
observer.unobserve(target);
}
markPreviewChatIdsVisible(intersectingChatIds);
},
{
root: historyList,
rootMargin: "160px 0px",
},
);
for (const element of thumbnailElements) {
const chatId = element.dataset.chatId;
if (!chatId || visiblePreviewChatIdsRef.current.has(chatId)) {
continue;
}
observer.observe(element);
}
return () => observer.disconnect();
}, [filteredHistory, markPreviewChatIdsVisible]);
return (
<div className="tta-history">
<div className="tta-history__header">
<div className="tta-history__title">
{t("ai.chat.historyPanel.title")}
</div>
<button
type="button"
className="tta-history__back-button"
onClick={onClose}
aria-label={t("ai.chat.historyPanel.back")}
>
{t("ai.chat.historyPanel.back")}
</button>
</div>
<div className="tta-history__controls">
<div className="tta-history__search">
<span className="tta-history__search-icon" aria-hidden="true">
{searchIcon}
</span>
<input
ref={searchInputRef}
placeholder={t("ai.chat.historyPanel.searchPlaceholder")}
value={historySearch}
onChange={(event) => setHistorySearch(event.target.value)}
/>
</div>
</div>
{filteredHistory.length === 0 ? (
<div className="tta-history__empty">
{t("ai.chat.historyPanel.noChats")}
</div>
) : (
<div className="tta-history__list" ref={historyListRef}>
{filteredHistory.map((item) => {
const isEditing = editingChatId === item.id;
const previewMessage = getConversationPreviewMessage(item.messages);
return (
<div
key={item.id}
className="tta-history__item"
onClick={() => !isEditing && onSelectChat(item)}
>
{previewMessage ? (
<TTAHistoryThumbnail
chatId={item.id}
isVisible={visiblePreviewChatIds.has(item.id)}
message={previewMessage}
/>
) : (
<TTAHistoryThumbnailPlaceholder />
)}
<div className="tta-history__details">
<div className="tta-history__header-row">
{isEditing ? (
<div className="tta-history__item-title-edit">
<input
type="text"
value={editingTitle}
// titles derive at 80 chars (getConversationTitle)
maxLength={80}
onChange={(e) => setEditingTitle(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.stopPropagation();
handleConfirmRename(item.id);
} else if (e.key === "Escape") {
e.stopPropagation();
setEditingChatId(null);
}
}}
autoFocus
className="tta-history__rename-input"
/>
<div className="tta-history__edit-actions">
<button
type="button"
className="tta-panel__icon-button tta-panel__icon-button--confirm"
onClick={(e) => handleConfirmRename(item.id, e)}
title={t("ai.chat.historyPanel.confirm")}
>
{checkIcon}
</button>
<button
type="button"
className="tta-panel__icon-button tta-panel__icon-button--cancel"
onClick={handleCancelRename}
title={t("ai.chat.historyPanel.cancel")}
>
{CloseIcon}
</button>
</div>
</div>
) : (
<>
<div className="tta-history__item-title">
{item.title}
</div>
<div className="tta-history__actions">
<button
type="button"
className="tta-panel__icon-button"
onClick={(e) => handleStartRename(item, e)}
title={t("ai.chat.historyPanel.rename")}
>
{FreedrawIcon}
</button>
<button
type="button"
className="tta-panel__icon-button tta-history__action-button--delete"
onClick={(e) => {
e.stopPropagation();
onDeleteChat(item.id);
}}
title={t("ai.chat.historyPanel.delete")}
>
{TrashIcon}
</button>
</div>
</>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
};
@@ -1,11 +0,0 @@
.tta-warning-message {
width: min(100%, 21rem);
margin: 0.5rem auto 0.75rem;
padding: 0.625rem 1rem;
border: 1px solid var(--color-warning-dark);
border-radius: 8px;
background: var(--color-warning);
color: var(--color-warning-color);
font-size: 0.8125rem;
line-height: 1.35;
}
@@ -1,15 +0,0 @@
import "./TTAWarningMessage.scss";
import type { ReactNode } from "react";
export const TTAWarningMessage = ({ children }: { children: ReactNode }) => {
if (children === null || children === undefined || children === false) {
return null;
}
return (
<div className="tta-warning-message" role="alert" aria-live="polite">
{children}
</div>
);
};
-5
View File
@@ -1,5 +0,0 @@
// Inlined as a data URI so npm consumers don't need to host the asset at a
// well-known path (it used to be referenced as root-absolute
// `/tta-chat-empty.svg`, which only worked on excalidraw.com).
// Source: `public/tta-chat-empty.svg` (repo root).
export const TTA_CHAT_EMPTY_PLACEHOLDER_SRC = `data:image/svg+xml;base64,PHN2ZwogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICB3aWR0aD0iMzIwIgogIGhlaWdodD0iMTgwIgogIHZpZXdCb3g9IjAgMCAzMjAgMTgwIgogIGZpbGw9Im5vbmUiCj4KICA8cmVjdCB3aWR0aD0iMzIwIiBoZWlnaHQ9IjE4MCIgcng9IjE4IiBmaWxsPSIjRjhGQUZDIiAvPgogIDxyZWN0IHg9IjIwIiB5PSIyMCIgd2lkdGg9IjI4MCIgaGVpZ2h0PSIxNDAiIHJ4PSIxNCIgZmlsbD0iI0ZGRkZGRiIgLz4KICA8cmVjdCB4PSI0NCIgeT0iNDQiIHdpZHRoPSI5NCIgaGVpZ2h0PSI1OCIgcng9IjEwIiBmaWxsPSIjRTJFOEYwIiAvPgogIDxyZWN0IHg9IjE1OCIgeT0iNDgiIHdpZHRoPSIxMTgiIGhlaWdodD0iMTIiIHJ4PSI2IiBmaWxsPSIjQ0JENUUxIiAvPgogIDxyZWN0IHg9IjE1OCIgeT0iNjgiIHdpZHRoPSI5MiIgaGVpZ2h0PSIxMCIgcng9IjUiIGZpbGw9IiNFMkU4RjAiIC8+CiAgPHJlY3QgeD0iNDQiIHk9IjEyMCIgd2lkdGg9IjIzMiIgaGVpZ2h0PSIxNCIgcng9IjciIGZpbGw9IiNFMkU4RjAiIC8+CiAgPHBhdGgKICAgIGQ9Ik0yMzAgMTAxYzAtOC44MzcgNy4xNjMtMTYgMTYtMTZzMTYgNy4xNjMgMTYgMTYtNy4xNjMgMTYtMTYgMTYtMTYtNy4xNjMtMTYtMTZaIgogICAgZmlsbD0iI0VFRjJGRiIKICAvPgogIDxwYXRoCiAgICBkPSJNMjQ2IDkxLjVsMi43ODYgNS42NDggNi4yMTQuOTAzLTQuNSA0LjM4NyAxLjA2MiA2LjE4OEwyNDYgMTA1Ljc1bC01LjU2MiAyLjg3NiAxLjA2Mi02LjE4OC00LjUtNC4zODcgNi4yMTQtLjkwM0wyNDYgOTEuNVoiCiAgICBmaWxsPSIjNjM2NkYxIgogIC8+CiAgPGNpcmNsZSBjeD0iOTQiIGN5PSI3MyIgcj0iMTgiIGZpbGw9IiNEQkVBRkUiIC8+CiAgPHBhdGgKICAgIGQ9Ik04MyA3M2w3IDcgMTUtMTYiCiAgICBzdHJva2U9IiMyNTYzRUIiCiAgICBzdHJva2UtbGluZWNhcD0icm91bmQiCiAgICBzdHJva2UtbGluZWpvaW49InJvdW5kIgogICAgc3Ryb2tlLXdpZHRoPSI1IgogIC8+Cjwvc3ZnPgo=`;
-45
View File
@@ -1,45 +0,0 @@
import type { AI_ERROR_CODE } from "./types";
export type AIChatRuntimeError = Error & {
code?: AI_ERROR_CODE | number;
handled?: boolean;
cause?: unknown;
};
export const withAIChatErrorMeta = (
error: Error,
options?: {
code?: AI_ERROR_CODE | number;
handled?: boolean;
cause?: unknown;
},
): AIChatRuntimeError => {
const runtimeError = error as AIChatRuntimeError;
if (options && "code" in options && typeof options.code === "number") {
runtimeError.code = options.code;
}
if (options && "handled" in options && typeof options.handled === "boolean") {
runtimeError.handled = options.handled;
}
if (options && "cause" in options) {
runtimeError.cause = options.cause;
}
return runtimeError;
};
export const getAIChatErrorCode = (
error: unknown,
): AI_ERROR_CODE | number | undefined => {
if (!error || typeof error !== "object") {
return undefined;
}
const maybeCode = (error as { code?: unknown }).code;
return typeof maybeCode === "number" ? maybeCode : undefined;
};
export const isAIChatErrorHandled = (error: unknown) => {
if (!error || typeof error !== "object") {
return false;
}
return (error as { handled?: unknown }).handled === true;
};
-136
View File
@@ -1,136 +0,0 @@
import { getCommonBounds } from "@excalidraw/element";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import type { AssistantMessage, ChatConversation, ChatMessage } from "./types";
export const compareConversationsByUpdatedAt = (
a: Pick<ChatConversation, "updatedAt">,
b: Pick<ChatConversation, "updatedAt">,
) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0);
/**
* Marks generations that were still streaming (e.g. when a chat is re-opened
* after an interrupted session) as stopped/interrupted.
*/
export const stopIncompleteAssistantMessages = (
messages: ChatMessage[],
): ChatMessage[] =>
messages.map((message) =>
message.role === "assistant" && message.status.kind === "streaming"
? {
...message,
status: {
kind: "stopped",
elapsedMs: Math.max(0, Date.now() - message.status.startedAt),
reason: "interrupted",
},
}
: message,
);
/**
* Local generation id of the latest assistant generation with drawable
* content — the canvas tag (`customData[AI_GENERATED_ELEMENTS_KEY]`) of the
* conversation's current on-canvas result. Only assistants with skeletons
* produce tags: a generation that never rendered has nothing on the canvas to
* replace.
*/
export const getLatestAssistantGenerationId = (
messages: ChatMessage[],
): string | null => {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
if (message.role === "assistant" && message.skeletons?.length) {
return message.id;
}
}
return null;
};
export const getLatestRetryableAssistantMessage = (
messages: ChatMessage[],
): AssistantMessage | null => {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
if (message.role === "assistant" && message.status.kind !== "streaming") {
return message;
}
}
return null;
};
/**
* Canvas tags (= local generation ids) of every assistant generation with
* drawable content in the conversation.
*/
export const getAssistantGenerationTags = (
messages: ChatMessage[],
): string[] => {
const generationTags: string[] = [];
for (const message of messages) {
if (message.role === "assistant" && message.skeletons?.length) {
generationTags.push(message.id);
}
}
return generationTags;
};
export const getTurnStartIndexForAssistantDelete = (
messages: ChatMessage[],
messageIndex: number,
) => {
let turnStartIndex = messageIndex;
for (let index = messageIndex - 1; index >= 0; index--) {
if (messages[index].role === "user") {
turnStartIndex = index;
break;
}
}
return turnStartIndex;
};
export const getConversationTitle = (
messages: ChatMessage[],
defaultTitle = "Untitled chat",
) => {
const firstUserMessage = messages.find(
(message): message is Extract<ChatMessage, { role: "user" }> =>
message.role === "user",
);
const title = firstUserMessage?.content.trim() || defaultTitle;
return title.slice(0, 80);
};
export const getConversationPreviewMessage = (
messages: ChatMessage[],
): AssistantMessage | null => {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
if (
message.role === "assistant" &&
message.status.kind !== "error" &&
message.skeletons?.length
) {
return message;
}
}
return null;
};
export const getElementsCenter = (
elements: readonly NonDeletedExcalidrawElement[],
): { x: number; y: number } | null => {
if (!elements.length) {
return null;
}
const bounds = getCommonBounds(elements);
if (bounds.some((value) => Number.isNaN(value) || !Number.isFinite(value))) {
return null;
}
const [minX, minY, maxX, maxY] = bounds;
return {
x: (minX + maxX) / 2,
y: (minY + maxY) / 2,
};
};
-435
View File
@@ -1,435 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TTAStreamFetch } from "./client";
import { AI_CLIENT_ERRORS } from "./utils";
function createMockStream(chunks: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
let index = 0;
return new ReadableStream({
pull(controller) {
if (index >= chunks.length) {
controller.close();
return;
}
controller.enqueue(encoder.encode(chunks[index]));
index++;
},
});
}
const createChunk = (data: unknown) => `data: ${JSON.stringify(data)}\n\n`;
const createTransportFetch =
() =>
async ({
method,
headers,
payload,
signal,
}: {
method: "POST";
headers: Record<string, string>;
payload: unknown;
signal?: AbortSignal;
}) =>
global.fetch("https://api.example.com/tta/stream", {
method,
headers,
body: JSON.stringify(payload),
signal,
});
describe("TTAStreamFetch", () => {
let originalFetch: typeof global.fetch;
beforeEach(() => {
originalFetch = global.fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it("streams partial payloads and returns the final payload", async () => {
const onStarted = vi.fn();
const onMessage = vi.fn();
const onChunk = vi.fn();
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream([
createChunk({
type: "started",
chatId: "chat-1",
turnId: "turn-1",
messageId: "message-1",
lifecycleStatus: "pending",
}),
createChunk({
type: "message",
message: "Something went wrong. Retrying...",
}),
createChunk({
type: "partial",
skeletons: [],
}),
createChunk({
type: "done",
lifecycleStatus: "completed",
finishReason: "stop",
skeletons: [],
chatId: "chat-1",
turnId: "turn-1",
messageId: "message-1",
updatedAt: 1710000000000,
}),
]),
});
const result = await TTAStreamFetch({
payload: { prompt: "hello" },
onStarted,
onMessage,
onChunk,
fetch: createTransportFetch(),
});
expect(onStarted).toHaveBeenCalledWith({
chatId: "chat-1",
turnId: "turn-1",
messageId: "message-1",
lifecycleStatus: "pending",
});
expect(onMessage).toHaveBeenCalledWith({
message: "Something went wrong. Retrying...",
});
expect(onChunk).toHaveBeenCalledWith({
skeletons: [],
isComplete: false,
});
expect(result.error).toBeNull();
expect(result.finalPayload).toEqual({
skeletons: [],
isComplete: true,
chatId: "chat-1",
turnId: "turn-1",
messageId: "message-1",
finishReason: "stop",
lifecycleStatus: "completed",
updatedAt: 1710000000000,
});
});
it("forwards the server's isComplete flag on partial chunks", async () => {
const onChunk = vi.fn();
const skeleton = { type: "rectangle", x: 0, y: 0, width: 10, height: 10 };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream([
createChunk({ type: "partial", skeletons: [skeleton] }),
createChunk({
type: "partial",
skeletons: [skeleton],
isComplete: true,
}),
createChunk({
type: "done",
lifecycleStatus: "completed",
finishReason: "stop",
skeletons: [skeleton],
}),
]),
});
await TTAStreamFetch({
payload: { prompt: "hello" },
onChunk,
fetch: createTransportFetch(),
});
expect(onChunk).toHaveBeenNthCalledWith(1, {
skeletons: [skeleton],
isComplete: false,
});
expect(onChunk).toHaveBeenNthCalledWith(2, {
skeletons: [skeleton],
isComplete: true,
});
});
it("surfaces the started chat id before a later stream error", async () => {
const onStarted = vi.fn();
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream([
createChunk({
type: "started",
chatId: "chat-error",
turnId: "turn-error",
messageId: "message-error",
lifecycleStatus: "pending",
}),
createChunk({
type: "error",
lifecycleStatus: "failed",
error: {
code: 422,
message: "Generation failed",
},
}),
]),
});
const result = await TTAStreamFetch({
payload: { prompt: "hello" },
onStarted,
fetch: createTransportFetch(),
});
expect(onStarted).toHaveBeenCalledWith({
chatId: "chat-error",
turnId: "turn-error",
messageId: "message-error",
lifecycleStatus: "pending",
});
expect(result.error).toEqual({
code: 422,
lifecycleStatus: "failed",
message: "Generation failed",
});
});
it("returns rate limit metadata on 429 responses", async () => {
const headers = new Headers();
headers.set("X-Ratelimit-Limit", "100");
headers.set("X-Ratelimit-Remaining", "0");
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 429,
headers,
clone() {
return this;
},
json: async () => ({ error: "Rate limit exceeded" }),
text: async () => "Rate limit exceeded",
});
const result = await TTAStreamFetch({
payload: { prompt: "hello" },
fetch: createTransportFetch(),
});
expect(result.error).toEqual({
code: 429,
message: "Rate limit exceeded",
});
expect(result.rateLimit).toBe(100);
expect(result.rateLimitRemaining).toBe(0);
});
it("returns rate limit metadata on successful responses", async () => {
const headers = new Headers();
headers.set("X-Ratelimit-Limit", "100");
headers.set("X-Ratelimit-Remaining", "0");
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers,
body: createMockStream([
createChunk({
type: "done",
lifecycleStatus: "completed",
finishReason: "stop",
skeletons: [],
chatId: "chat-1",
turnId: "turn-1",
messageId: "message-1",
}),
]),
});
const result = await TTAStreamFetch({
payload: { prompt: "hello" },
fetch: createTransportFetch(),
});
expect(result.error).toBeNull();
expect(result.rateLimit).toBe(100);
expect(result.rateLimitRemaining).toBe(0);
});
it("reports an interrupted stream when EOF arrives without a terminal chunk", async () => {
const onChunk = vi.fn();
const skeleton = { type: "rectangle", x: 0, y: 0, width: 10, height: 10 };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream([
createChunk({
type: "started",
chatId: "chat-1",
turnId: "turn-1",
messageId: "message-1",
lifecycleStatus: "pending",
}),
createChunk({ type: "partial", skeletons: [skeleton] }),
// stream cut here: no `done`/`error` chunk, no `[DONE]` sentinel
]),
});
const result = await TTAStreamFetch({
payload: { prompt: "hello" },
onChunk,
fetch: createTransportFetch(),
});
expect(onChunk).toHaveBeenCalledWith({
skeletons: [skeleton],
isComplete: false,
});
expect(result.finalPayload).toBeFalsy();
expect(result.error).toMatchObject({
code: AI_CLIENT_ERRORS.STREAM_INTERRUPTED,
lifecycleStatus: "failed",
});
expect(result.error?.message).toMatch(/connection interrupted/i);
});
it("reports a server-ended stream when [DONE] arrives without a result", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream([
createChunk({
type: "started",
chatId: "chat-1",
turnId: "turn-1",
messageId: "message-1",
lifecycleStatus: "pending",
}),
"data: [DONE]\n\n",
]),
});
const result = await TTAStreamFetch({
payload: { prompt: "hello" },
fetch: createTransportFetch(),
});
expect(result.finalPayload).toBeFalsy();
expect(result.error).toMatchObject({
code: AI_CLIENT_ERRORS.STREAM_INTERRUPTED,
lifecycleStatus: "failed",
});
expect(result.error?.message).toMatch(/ended the stream/i);
});
it("drains the stream to EOF after the done chunk so the fetch completes cleanly", async () => {
const encoder = new TextEncoder();
const chunks = [
createChunk({
type: "done",
lifecycleStatus: "completed",
finishReason: "stop",
skeletons: [],
}),
"data: [DONE]\n\n",
];
let index = 0;
let fullyRead = false;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (index >= chunks.length) {
fullyRead = true;
controller.close();
return;
}
controller.enqueue(encoder.encode(chunks[index]));
index++;
},
});
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body,
});
const result = await TTAStreamFetch({
payload: { prompt: "hello" },
fetch: createTransportFetch(),
});
// the result resolves at the `done` frame, before the trailing sentinel…
expect(result.error).toBeNull();
// …and the detached drain then reads the body to EOF (no ERR_ABORTED)
await new Promise((resolve) => setTimeout(resolve, 300));
expect(fullyRead).toBe(true);
});
it("returns an abort error when the request is cancelled", async () => {
const abortController = new AbortController();
global.fetch = vi.fn().mockImplementation(() => {
abortController.abort();
const error = new Error("The operation was aborted");
error.name = "AbortError";
return Promise.reject(error);
});
const result = await TTAStreamFetch({
payload: { prompt: "hello" },
signal: abortController.signal,
fetch: createTransportFetch(),
});
expect(result.error).toEqual({
code: 499,
lifecycleStatus: "aborted",
message: "Request aborted",
});
});
it("passes chat ids through without trimming", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream([
createChunk({
type: "done",
finishReason: "stop",
skeletons: [],
}),
]),
});
await TTAStreamFetch({
payload: {
prompt: "hello",
chatId: " chat-with-spaces ",
},
fetch: createTransportFetch(),
});
expect(global.fetch).toHaveBeenCalledWith(
"https://api.example.com/tta/stream",
expect.objectContaining({
body: JSON.stringify({
prompt: "hello",
chatId: " chat-with-spaces ",
}),
}),
);
});
});
-402
View File
@@ -1,402 +0,0 @@
import { extractRateLimitHeaders, iterateSSEJSONChunks } from "../data/sse";
import { AI_CLIENT_ERRORS } from "./utils";
import type {
AIAssistantLifecycleStatus,
AIGenerateRequestPayload,
StreamChunk,
AIStreamFinalPayload,
AIStreamMessagePayload,
AIStreamPartialPayload,
AIStreamStartedPayload,
AI_ERROR_CODE,
} from "./types";
type AIClientError = Error & {
code?: AI_ERROR_CODE | number;
};
const getErrorCode = (error: unknown): AI_ERROR_CODE | number | undefined => {
if (!error || typeof error !== "object") {
return undefined;
}
const maybeCode = (error as { code?: unknown }).code;
return typeof maybeCode === "number" ? maybeCode : undefined;
};
const withErrorCode = (
error: Error,
code?: AI_ERROR_CODE | number,
): AIClientError => {
const typedError = error as AIClientError;
if (typeof code === "number") {
typedError.code = code;
}
return typedError;
};
const parseErrorResponseMessage = async (
response: Response,
fallback: string,
): Promise<string> => {
const jsonPayload = await response
.clone()
.json()
.catch(() => null as { error?: string } | null);
if (jsonPayload?.error) {
return jsonPayload.error;
}
const textPayload = await response.text().catch(() => "");
return textPayload || fallback;
};
const createHttpStatusError = async (
response: Response,
fallback: string,
): Promise<AIClientError> => {
const message = await parseErrorResponseMessage(response, fallback);
return withErrorCode(new Error(message), response.status);
};
export type AIChatTruncateRequest = {
chatId: string;
keepThroughTurnId?: string | null;
};
export type AIChatTruncateResponse = {
ok: boolean;
chatId: string;
updatedAt?: number;
};
export interface TTAStreamFetchRequest {
method: "POST";
headers: {
"Content-Type": "application/json";
Accept: "text/event-stream";
};
payload: AIGenerateRequestPayload;
signal?: AbortSignal;
}
export type TTAStreamResponseFetch = (
request: TTAStreamFetchRequest,
) => Promise<Response>;
export interface TTATruncateFetchRequest {
method: "POST";
headers: {
"Content-Type": "application/json";
};
payload: AIChatTruncateRequest;
}
export type TTATruncateResponseFetch = (
request: TTATruncateFetchRequest,
) => Promise<Response>;
export const truncateChat = async (
payload: AIChatTruncateRequest,
options: {
fetch: TTATruncateResponseFetch;
},
): Promise<AIChatTruncateResponse> => {
const response = await options.fetch({
method: "POST",
headers: {
"Content-Type": "application/json",
},
payload,
});
if (!response.ok) {
throw await createHttpStatusError(
response,
`AI server request failed with status ${response.status}`,
);
}
const data = (await response.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!data || typeof data.chatId !== "string") {
throw withErrorCode(
new Error("AI server returned an invalid truncate response"),
500,
);
}
return {
ok: Boolean(data.ok),
chatId: data.chatId,
...(typeof data.updatedAt === "number"
? { updatedAt: data.updatedAt }
: {}),
};
};
export interface TTAStreamFetchOptions {
payload: AIGenerateRequestPayload;
onStarted?: (payload: AIStreamStartedPayload) => void;
onMessage?: (payload: AIStreamMessagePayload) => void;
onChunk?: (payload: AIStreamPartialPayload) => void;
signal?: AbortSignal;
onStreamCreated?: () => void;
fetch: TTAStreamResponseFetch;
}
export type TTAStreamError = {
code?: AI_ERROR_CODE | number;
message: string;
lifecycleStatus?: Extract<AIAssistantLifecycleStatus, "failed" | "aborted">;
};
export type TTAStreamFetchResult = {
rateLimit?: number | null;
rateLimitRemaining?: number | null;
} & (
| {
finalPayload: AIStreamFinalPayload;
error: null;
}
| {
error: TTAStreamError;
finalPayload?: null;
}
);
export interface TTATransportAdapter {
stream(
options: Omit<TTAStreamFetchOptions, "fetch">,
): Promise<TTAStreamFetchResult>;
truncate(payload: AIChatTruncateRequest): Promise<AIChatTruncateResponse>;
}
export interface TTADefaultTransportAdapterConfig {
stream: TTAStreamResponseFetch;
truncate: TTATruncateResponseFetch;
}
/**
* After a terminal `done`/`error` chunk the response body still holds a few
* unread frames (the `[DONE]` sentinel, then EOF). Read them so the stream
* completes instead of being abandoned mid-body — an abandoned body forces
* the browser to tear down the connection, while a drained one can return to
* the keep-alive pool. Detached and bounded: the result is resolved before
* this runs, and a server that doesn't close promptly gets cancelled after
* the deadline (the old behavior).
*
* NOTE this does NOT silence the `net::ERR_ABORTED` annotation DevTools
* attaches to these requests — verified empirically that Chrome logs it for
* cross-origin SSE fetches even when the body is read fully to EOF.
*/
const drainStreamToEof = (body: ReadableStream<Uint8Array>) => {
void (async () => {
try {
// the for-await teardown releases the SSE parser's reader lock
// asynchronously — wait briefly for it
for (let i = 0; i < 10 && body.locked; i++) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
if (body.locked) {
return;
}
const reader = body.getReader();
const deadline = setTimeout(() => {
reader.cancel().catch(() => {});
}, 2_000);
while (!(await reader.read()).done) {
// discard trailing frames
}
clearTimeout(deadline);
} catch {
// teardown races are fine — nothing user-visible depends on the drain
}
})();
};
const toStreamError = (
message: string,
code?: AI_ERROR_CODE | number,
lifecycleStatus?: Extract<AIAssistantLifecycleStatus, "failed" | "aborted">,
): TTAStreamError => ({
message,
code,
...(lifecycleStatus ? { lifecycleStatus } : {}),
});
export const TTAStreamFetch = async (
options: TTAStreamFetchOptions,
): Promise<TTAStreamFetchResult> => {
const {
payload,
onStarted,
onMessage,
onChunk,
onStreamCreated,
signal,
fetch: streamFetch,
} = options;
try {
const response = await streamFetch({
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
payload,
signal,
});
const rateLimitInfo = extractRateLimitHeaders(response.headers);
if (!response.ok) {
return {
...rateLimitInfo,
error: toStreamError(
await parseErrorResponseMessage(
response,
`AI server streaming request failed with status ${response.status}`,
),
response.status,
),
};
}
const stream = response.body;
if (!stream) {
return {
...rateLimitInfo,
error: toStreamError("Couldn't get reader from response body", 500),
};
}
onStreamCreated?.();
let startedPayload: AIStreamStartedPayload | null = null;
let receivedDoneSentinel = false;
for await (const event of iterateSSEJSONChunks<StreamChunk>(stream, {
signal,
onInvalidJSON: (rawPayload) => {
console.warn("AI Client: Failed to parse JSON payload", rawPayload);
},
onDoneSentinel: () => {
receivedDoneSentinel = true;
},
})) {
switch (event.type) {
case "started":
startedPayload = {
chatId: event.chatId,
turnId: event.turnId,
messageId: event.messageId,
...(event.lifecycleStatus
? { lifecycleStatus: event.lifecycleStatus }
: {}),
...(typeof event.updatedAt === "number"
? { updatedAt: event.updatedAt }
: {}),
};
onStarted?.(startedPayload);
break;
case "message":
onMessage?.({ message: event.message });
break;
case "partial":
onChunk?.({
skeletons: event.skeletons ?? [],
isComplete: event.isComplete ?? false,
});
break;
case "done":
drainStreamToEof(stream);
return {
...rateLimitInfo,
finalPayload: {
skeletons: event.skeletons ?? [],
isComplete: true,
chatId: event.chatId ?? startedPayload?.chatId,
turnId: event.turnId ?? startedPayload?.turnId,
messageId: event.messageId ?? startedPayload?.messageId,
finishReason: event.finishReason,
...(event.lifecycleStatus
? { lifecycleStatus: event.lifecycleStatus }
: {}),
...(typeof event.updatedAt === "number"
? { updatedAt: event.updatedAt }
: {}),
},
error: null,
};
case "error":
drainStreamToEof(stream);
return {
...rateLimitInfo,
error: toStreamError(
event.error.message ?? "AI streaming error",
event.error.code,
event.lifecycleStatus ?? "failed",
),
};
}
}
if (signal?.aborted) {
return {
...rateLimitInfo,
error: toStreamError("Request aborted", 499, "aborted"),
};
}
// Every deliberately-ended generation terminates with a `done` or `error`
// chunk (the success path additionally appends a `[DONE]` sentinel — see
// StreamingResponse.end() server-side). Reaching EOF without one means
// the stream was cut (proxy idle timeout, server restart, network blip).
// Never fabricate an empty success here: it would overwrite the streamed
// partial skeletons and wipe the canvas preview (C2 in tta.md).
return {
...rateLimitInfo,
error: toStreamError(
receivedDoneSentinel
? "The AI server ended the stream without a result"
: "Connection interrupted before the response completed",
AI_CLIENT_ERRORS.STREAM_INTERRUPTED,
"failed",
),
};
} catch (error) {
if ((error as { name?: string }).name === "AbortError" || signal?.aborted) {
return {
error: toStreamError("Request aborted", 499, "aborted"),
};
}
return {
error: toStreamError(
error instanceof Error ? error.message : "AI streaming failed",
getErrorCode(error),
),
};
}
};
export class TTADefaultTransportAdapter implements TTATransportAdapter {
constructor(private readonly config: TTADefaultTransportAdapterConfig) {}
stream: TTATransportAdapter["stream"] = async (options) => {
return TTAStreamFetch({
...options,
fetch: this.config.stream,
});
};
truncate: TTATransportAdapter["truncate"] = async (payload) => {
return truncateChat(payload, {
fetch: this.config.truncate,
});
};
}
-51
View File
@@ -1,51 +0,0 @@
import { createStore, del, entries, set } from "idb-keyval";
import { compareConversationsByUpdatedAt } from "./chatHelpers";
import type { ChatConversation, TTAPersistenceAdapter } from "./types";
const ALL_CHATS_DB = "excalidraw-tta-all-chats";
const ALL_CHATS_STORE = "excalidraw-tta-all-chats-store";
const chatsStore = createStore(ALL_CHATS_DB, ALL_CHATS_STORE);
const isChatConversation = (value: unknown): value is ChatConversation =>
typeof value === "object" &&
value !== null &&
typeof (value as ChatConversation).id === "string" &&
Array.isArray((value as ChatConversation).messages);
/**
* Per-chat persistence: one IndexedDB entry per chat, keyed by the chat id, so
* saving the active chat never rewrites (or clobbers) the others.
*/
export const TTAIndexedDBAdapter: TTAPersistenceAdapter = {
async loadChats() {
try {
const storedChats = await entries<string, unknown>(chatsStore);
return storedChats
.map(([, chat]) => chat)
.filter(isChatConversation)
.sort(compareConversationsByUpdatedAt);
} catch (error) {
console.warn("Failed to load TTA chats from IndexedDB:", error);
return [];
}
},
async saveChat(chat) {
try {
await set(chat.id, chat, chatsStore);
} catch (error) {
console.warn("Failed to save TTA chat to IndexedDB:", error);
throw error;
}
},
async deleteChat(id) {
try {
await del(id, chatsStore);
} catch (error) {
console.warn("Failed to delete TTA chat from IndexedDB:", error);
throw error;
}
},
};
@@ -1,818 +0,0 @@
import { describe, expect, it } from "vitest";
import {
getBoundTextMaxWidth,
getCommonBounds,
isTextElement,
measureText,
type ExcalidrawElementSkeleton,
} from "@excalidraw/element";
import { getFontString } from "@excalidraw/common";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import {
insertAISkeletons,
INTERMEDIATE_PREVIEW_ELEMENT_KEY,
} from "./insertAISkeletons";
import type { AppClassProperties, NormalizedZoomValue } from "../types";
const createTestApp = (
initialElements: ExcalidrawElement[] = [],
appStateOverrides: Partial<AppClassProperties["state"]> = {},
) => {
let elements = [...initialElements];
let lastSync: {
elements: ExcalidrawElement[];
appState?: AppClassProperties["state"];
} | null = null;
const app = {
state: {
width: 1000,
height: 1000,
scrollX: 0,
scrollY: 0,
offsetLeft: 0,
offsetTop: 0,
zoom: { value: 1 },
selectedElementIds: {},
...appStateOverrides,
} as AppClassProperties["state"],
scene: {
getNonDeletedElements: () =>
elements.filter(
(element): element is NonDeletedExcalidrawElement =>
!element.isDeleted,
),
getElementsIncludingDeleted: () => elements,
} as unknown as AppClassProperties["scene"],
syncActionResult: ({
elements: nextElements,
appState,
}: {
elements: ExcalidrawElement[];
appState?: AppClassProperties["state"];
}) => {
elements = nextElements;
lastSync = { elements: nextElements, appState };
},
} as unknown as AppClassProperties;
return {
app,
getElements: () => elements,
getLastSync: () => lastSync,
};
};
const getById = <T extends { id: string }>(
elements: readonly T[],
id: string,
) => elements.find((element) => element.id === id);
const getInsertedBoundsCenter = (
inserted: readonly NonDeletedExcalidrawElement[],
) => {
const [minX, minY, maxX, maxY] = getCommonBounds(inserted);
return { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
};
describe("insertAISkeletons", () => {
it("preserves existing deleted elements when inserting", () => {
const setup = createTestApp();
insertAISkeletons(
setup.app,
[
{
type: "rectangle",
id: "deleted-rect",
x: 0,
y: 0,
width: 10,
height: 10,
},
],
{ targetCenter: { x: 0, y: 0 } },
);
const deletedElement = {
...setup.getElements()[0],
isDeleted: true,
} as ExcalidrawElement;
const { app, getLastSync } = createTestApp([deletedElement]);
insertAISkeletons(
app,
[
{
type: "ellipse",
id: "new-ellipse",
x: 20,
y: 20,
width: 30,
height: 30,
},
],
{ targetCenter: { x: 0, y: 0 } },
);
expect(getLastSync()!.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "deleted-rect", isDeleted: true }),
expect.objectContaining({ id: "new-ellipse", isDeleted: false }),
]),
);
});
it("marks replaced generation elements deleted when inserting", () => {
const setup = createTestApp();
insertAISkeletons(
setup.app,
[
{
type: "rectangle",
id: "old-rect",
x: 0,
y: 0,
width: 10,
height: 10,
},
],
{ generationId: "old-generation", targetCenter: { x: 0, y: 0 } },
);
const oldElement = setup.getElements()[0];
const { app, getLastSync } = createTestApp([oldElement]);
insertAISkeletons(
app,
[
{
type: "ellipse",
id: "new-ellipse",
x: 20,
y: 20,
width: 30,
height: 30,
},
],
{
generationId: "new-generation",
targetCenter: { x: 0, y: 0 },
deleteGenerationTags: ["old-generation"],
},
);
const synced = getLastSync()!.elements;
const deletedOldElement = getById(synced, "old-rect")!;
const newElement = getById(synced, "new-ellipse")!;
expect(deletedOldElement).toMatchObject({
id: "old-rect",
isDeleted: true,
});
expect(deletedOldElement.version).toBeGreaterThan(oldElement.version);
expect(newElement).toMatchObject({
id: "new-ellipse",
isDeleted: false,
customData: expect.objectContaining({
aiSidebarGenerationId: "new-generation",
}),
});
});
it("marks intermediate preview elements", () => {
const { app, getLastSync } = createTestApp();
insertAISkeletons(
app,
[
{
type: "rectangle",
id: "preview-rect",
x: 0,
y: 0,
width: 10,
height: 10,
},
],
{
generationId: "preview-generation",
targetCenter: { x: 0, y: 0 },
intermediatePreviewElement: true,
},
);
expect(getLastSync()!.elements[0]).toMatchObject({
id: "preview-rect",
customData: expect.objectContaining({
aiSidebarGenerationId: "preview-generation",
[INTERMEDIATE_PREVIEW_ELEMENT_KEY]: true,
}),
});
});
it("replaces existing elements with matching inserted ids", () => {
const setup = createTestApp();
insertAISkeletons(
setup.app,
[
{
type: "rectangle",
id: "stable-id",
x: 0,
y: 0,
width: 10,
height: 10,
},
],
{ targetCenter: { x: 0, y: 0 } },
);
const deletedStableElement = {
...setup.getElements()[0],
isDeleted: true,
} as ExcalidrawElement;
const { app, getLastSync } = createTestApp([deletedStableElement]);
insertAISkeletons(
app,
[
{
type: "ellipse",
id: "stable-id",
x: 20,
y: 20,
width: 30,
height: 30,
},
],
{ targetCenter: { x: 0, y: 0 } },
);
const synced = getLastSync()!.elements;
const matchingElements = synced.filter(
(element) => element.id === "stable-id",
);
expect(matchingElements).toHaveLength(1);
expect(matchingElements[0]).toMatchObject({
id: "stable-id",
type: "ellipse",
isDeleted: false,
});
expect(matchingElements[0].version).toBeGreaterThan(
deletedStableElement.version,
);
});
it("inserts normal elements and preserves relative offsets", () => {
const { app } = createTestApp();
const skeletons: ExcalidrawElementSkeleton[] = [
{
type: "rectangle",
id: "rect-1",
x: 10,
y: 20,
width: 100,
height: 50,
},
{
type: "ellipse",
id: "ell-1",
x: 200,
y: 80,
width: 60,
height: 40,
},
{
type: "text",
id: "text-1",
x: 50,
y: 30,
text: "Hello world",
fontSize: 20,
fontFamily: 1,
},
];
const inserted = insertAISkeletons(app, skeletons, {
targetCenter: { x: 0, y: 0 },
selectInsertedElements: true,
});
const rect = getById(inserted, "rect-1")!;
const ellipse = getById(inserted, "ell-1")!;
const text = getById(inserted, "text-1")!;
const rectSkeleton = skeletons[0];
const ellipseSkeleton = skeletons[1];
const textSkeleton = skeletons[2];
expect(ellipse.x - rect.x).toBeCloseTo(
ellipseSkeleton.x! - rectSkeleton.x!,
);
expect(ellipse.y - rect.y).toBeCloseTo(
ellipseSkeleton.y! - rectSkeleton.y!,
);
expect(text.x - rect.x).toBeCloseTo(textSkeleton.x! - rectSkeleton.x!);
expect(text.y - rect.y).toBeCloseTo(textSkeleton.y! - rectSkeleton.y!);
});
it("repairs bound text elements on insert", () => {
const { app, getLastSync } = createTestApp();
const skeletons: ExcalidrawElementSkeleton[] = [
{
type: "rectangle",
id: "rect-1",
x: 100,
y: 100,
width: 200,
height: 80,
boundElements: [{ type: "text", id: "text-1" }],
},
{
type: "text",
id: "text-1",
x: 120,
y: 120,
text: "Bound label",
fontSize: 18,
fontFamily: 1,
containerId: "missing-container",
},
];
insertAISkeletons(app, skeletons, {
targetCenter: { x: 0, y: 0 },
});
const synced = getLastSync()!.elements;
const rect = getById(synced, "rect-1")!;
const text = getById(synced, "text-1")!;
if (!isTextElement(text)) {
throw new Error("Expected text element");
}
expect(text.type).toBe("text");
expect(text.containerId).toBe(rect.id);
expect(rect.boundElements?.some((el) => el.id === text.id)).toBe(true);
});
it("handles arrows with bindings and labels", () => {
const { app } = createTestApp();
const skeletons: ExcalidrawElementSkeleton[] = [
{
type: "rectangle",
id: "rect-a",
x: 20,
y: 40,
width: 120,
height: 60,
},
{
type: "rectangle",
id: "rect-b",
x: 260,
y: 40,
width: 120,
height: 60,
},
{
type: "arrow",
id: "arrow-1",
x: 140,
y: 70,
width: 120,
height: 0,
start: {
id: "rect-a",
},
end: {
id: "rect-b",
},
label: {
text: "Flow",
},
},
];
const inserted = insertAISkeletons(app, skeletons, {
targetCenter: { x: 0, y: 0 },
});
const rectA = getById(inserted, "rect-a")!;
const rectB = getById(inserted, "rect-b")!;
const arrow = getById(inserted, "arrow-1") as Extract<
NonDeletedExcalidrawElement,
{ type: "arrow" }
>;
const label = inserted.find(
(element) => element.type === "text" && element.containerId === arrow.id,
);
expect(arrow).toBeDefined();
expect(arrow.type).toBe("arrow");
expect(arrow.startBinding?.elementId).toBe(rectA.id);
expect(arrow.endBinding?.elementId).toBe(rectB.id);
expect(label).toBeDefined();
if (label) {
expect(
arrow.boundElements?.some(
(boundElement) => boundElement.id === label.id,
),
).toBe(true);
}
});
it("keeps frame membership for large children when regenerating ids", () => {
const { app, getElements } = createTestApp();
const skeletons: ExcalidrawElementSkeleton[] = [
{
type: "rectangle",
id: "child-1",
x: 1000,
y: 1200,
width: 2000,
height: 1000,
boundElements: [{ type: "text", id: "child-text" }],
},
{
type: "text",
id: "child-text",
x: 1100,
y: 1250,
text: "Big child",
fontSize: 20,
fontFamily: 1,
containerId: "child-1",
},
{
type: "frame",
id: "frame-1",
name: "Frame with big children",
width: 800,
height: 450,
children: ["child-1"],
},
];
expect(() =>
insertAISkeletons(app, skeletons, {
targetCenter: { x: 0, y: 0 },
regenerateIds: true,
}),
).not.toThrow();
const elements = getElements();
const frame = elements.find((element) => element.type === "frame")!;
const frameChildren = elements.filter(
(element) => element.frameId === frame.id,
);
const frameChild = frameChildren.find((element) => element.type !== "text");
const boundText = frameChildren.find((element) => element.type === "text");
expect(frameChild).toBeDefined();
expect(boundText).toBeDefined();
expect(frame.width).toBe(800);
expect(frame.height).toBe(450);
});
it("preserves layout spacing across multiple frames", () => {
const { app } = createTestApp();
const skeletons: ExcalidrawElementSkeleton[] = [
{
type: "frame",
id: "frame-left",
name: "Left",
x: 0,
y: 0,
width: 200,
height: 150,
children: ["left-child"],
},
{
type: "rectangle",
id: "left-child",
x: 20,
y: 30,
width: 80,
height: 60,
},
{
type: "frame",
id: "frame-right",
name: "Right",
x: 500,
y: 0,
width: 200,
height: 150,
children: ["right-child"],
},
{
type: "rectangle",
id: "right-child",
x: 520,
y: 30,
width: 80,
height: 60,
},
];
const inserted = insertAISkeletons(app, skeletons, {
targetCenter: { x: 0, y: 0 },
});
const leftFrame = inserted.find(
(element) => element.type === "frame" && element.name === "Left",
) as Extract<NonDeletedExcalidrawElement, { type: "frame" }>;
const rightFrame = inserted.find(
(element) => element.type === "frame" && element.name === "Right",
) as Extract<NonDeletedExcalidrawElement, { type: "frame" }>;
expect(leftFrame).toBeDefined();
expect(rightFrame).toBeDefined();
const left = leftFrame!;
const right = rightFrame!;
const originalDistance =
skeletons[2].x! +
skeletons[2].width! / 2 -
(skeletons[0].x! + skeletons[0].width! / 2);
const insertedDistance =
right.x + right.width / 2 - (left.x + left.width / 2);
expect(insertedDistance).toBeCloseTo(originalDistance);
});
it("handles arrows that bind to frames", () => {
const { app } = createTestApp();
const skeletons: ExcalidrawElementSkeleton[] = [
{
type: "frame",
id: "frame-1",
name: "Target frame",
x: 300,
y: 0,
width: 200,
height: 200,
children: ["frame-child"],
},
{
type: "rectangle",
id: "frame-child",
x: 320,
y: 20,
width: 80,
height: 60,
},
{
type: "rectangle",
id: "source",
x: 0,
y: 40,
width: 80,
height: 60,
},
{
type: "arrow",
id: "arrow-to-frame",
x: 80,
y: 70,
width: 220,
height: 0,
elbowed: true,
start: { id: "source" },
end: { id: "frame-1" },
} as ExcalidrawElementSkeleton,
];
const inserted = insertAISkeletons(app, skeletons, {
regenerateIds: true,
selectInsertedElements: true,
});
const insertedFrame = inserted.find(
(element) => element.type === "frame",
) as Extract<NonDeletedExcalidrawElement, { type: "frame" }>;
const insertedArrow = inserted.find(
(element) => element.type === "arrow",
) as Extract<NonDeletedExcalidrawElement, { type: "arrow" }>;
expect(insertedFrame).toBeDefined();
expect(insertedArrow).toBeDefined();
expect(insertedArrow.endBinding?.elementId).toBe(insertedFrame.id);
});
it("verifies near-threshold bound text wraps and restores single-line text", () => {
const { app, getLastSync } = createTestApp();
const originalText = "Hello world";
const fontFamily = 1;
const fontSize = 16;
const lineHeight = 1.25 as ExcalidrawTextElement["lineHeight"];
const singleLineWidth = measureText(
originalText,
getFontString({ fontFamily, fontSize }),
lineHeight,
).width;
let ellipseSize: number | null = null;
for (let candidate = 100; candidate <= 220; candidate += 1) {
const maxWidth = getBoundTextMaxWidth(
{
type: "ellipse",
width: candidate,
height: candidate,
} as any,
{
fontSize,
} as any,
);
const overflow = singleLineWidth - maxWidth;
if (overflow > 0.05 && overflow <= 1.4) {
ellipseSize = candidate;
break;
}
}
expect(ellipseSize).not.toBeNull();
const skeletons: ExcalidrawElementSkeleton[] = [
{
type: "ellipse",
id: "bubble",
x: 100,
y: 100,
width: ellipseSize!,
height: ellipseSize!,
boundElements: [{ type: "text", id: "label" }],
},
{
type: "text",
id: "label",
x: 120,
y: 120,
text: "Hello\nworld",
originalText,
fontSize,
fontFamily,
lineHeight,
textAlign: "center",
verticalAlign: "middle",
autoResize: true,
containerId: "bubble",
},
];
insertAISkeletons(app, skeletons, {
targetCenter: { x: 0, y: 0 },
});
const synced = getLastSync()!.elements;
const bubble = getById(synced, "bubble")!;
const label = getById(synced, "label");
expect(label).toBeDefined();
if (!label || !isTextElement(label)) {
throw new Error("Expected text element");
}
expect(label.text).toBe(originalText);
expect(bubble.width).toBeGreaterThanOrEqual(ellipseSize!);
});
it("does not force cross-frame arrows into a child frame", () => {
const { app } = createTestApp();
const skeletons: ExcalidrawElementSkeleton[] = [
{
type: "frame",
id: "frame-a",
name: "A",
x: 0,
y: 0,
width: 300,
height: 200,
children: ["child-a"],
},
{
type: "rectangle",
id: "child-a",
x: 20,
y: 20,
width: 80,
height: 60,
},
{
type: "frame",
id: "frame-b",
name: "B",
x: 500,
y: 0,
width: 300,
height: 200,
children: [],
},
{
type: "arrow",
id: "cross-frame",
x: 60,
y: 50,
width: 440,
height: 0,
elbowed: true,
start: { id: "child-a" },
end: { id: "frame-b" },
} as ExcalidrawElementSkeleton,
];
const inserted = insertAISkeletons(app, skeletons, {
regenerateIds: true,
selectInsertedElements: true,
});
const arrow = inserted.find(
(element) => element.type === "arrow",
) as Extract<NonDeletedExcalidrawElement, { type: "arrow" }>;
expect(arrow).toBeDefined();
expect(arrow.startBinding?.elementId).toBeDefined();
expect(arrow.endBinding?.elementId).toBeDefined();
expect(arrow.frameId).toBeNull();
});
it("centers inserted elements in the visible viewport when the canvas has a page offset", () => {
const { app } = createTestApp([], {
width: 1000,
height: 800,
offsetLeft: 480,
offsetTop: 120,
});
const inserted = insertAISkeletons(app, [
{ type: "rectangle", id: "rect-1", x: 0, y: 0, width: 100, height: 50 },
]);
// Scene-space viewport center is (width / 2) / zoom - scroll; the canvas
// page offset cancels out of the client→scene transform and must NOT
// shift the result (C4 in tta.md: the old formula landed this at
// (20, 280) — i.e. offset px to the left/top of the visible center).
const center = getInsertedBoundsCenter(inserted);
expect(center.x).toBeCloseTo(500);
expect(center.y).toBeCloseTo(400);
});
it("centers inserted elements with offset, zoom and scroll combined", () => {
const { app } = createTestApp([], {
width: 1000,
height: 800,
offsetLeft: 480,
offsetTop: 120,
scrollX: 100,
scrollY: -50,
zoom: { value: 2 as NormalizedZoomValue },
});
const inserted = insertAISkeletons(app, [
{ type: "rectangle", id: "rect-1", x: 0, y: 0, width: 100, height: 50 },
]);
// x: (1000 / 2) / 2 - 100 = 150 ; y: (800 / 2) / 2 - (-50) = 250
// (old formula: (-90, 190))
const center = getInsertedBoundsCenter(inserted);
expect(center.x).toBeCloseTo(150);
expect(center.y).toBeCloseTo(250);
});
it("keeps offset-0 centering unchanged at zoom/scroll (excalidraw.com regression)", () => {
const { app } = createTestApp([], {
width: 1000,
height: 800,
scrollX: 100,
scrollY: -50,
zoom: { value: 2 as NormalizedZoomValue },
});
const inserted = insertAISkeletons(app, [
{ type: "rectangle", id: "rect-1", x: 0, y: 0, width: 100, height: 50 },
]);
// Identical before and after the fix — pins the offset-0 behavior.
const center = getInsertedBoundsCenter(inserted);
expect(center.x).toBeCloseTo(150);
expect(center.y).toBeCloseTo(250);
});
});
@@ -1,306 +0,0 @@
import {
getCommonBounds,
isBoundToContainer,
getContainerElement,
redrawTextBoundingBox,
makeNextSelectedElementIds,
Scene,
CaptureUpdateAction,
normalizeElbowArrows,
newElementWith,
} from "@excalidraw/element";
import { convertToExcalidrawElements } from "@excalidraw/element";
import { viewportCoordsToSceneCoords } from "@excalidraw/common";
import type { ExcalidrawElementSkeleton } from "@excalidraw/element";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import type { CaptureUpdateActionType } from "@excalidraw/element";
import { restoreElements } from "../data/restore";
import type { AppClassProperties } from "../types";
const getViewportCenter = (appState: AppClassProperties["state"]) => {
// Viewport center in client (page) coordinates: the canvas occupies
// `width`×`height` px starting at `offsetLeft`/`offsetTop`. Routing through
// the canonical client→scene transform (rather than hand-rolling it) keeps
// this in lockstep with how the rest of the editor centers content — see
// `addElementsFromPasteOrLibrary`'s `position: "center"` handling. Note the
// offsets cancel out of the math; a previous inline version dropped them on
// the wrong side and inserted off-center in embedded hosts (C4 in tta.md).
const { x, y } = viewportCoordsToSceneCoords(
{
clientX: appState.offsetLeft + appState.width / 2,
clientY: appState.offsetTop + appState.height / 2,
},
appState,
);
return { sceneX: x, sceneY: y };
};
export const AI_GENERATED_ELEMENTS_KEY = "aiSidebarGenerationId";
export const INTERMEDIATE_PREVIEW_ELEMENT_KEY = "intermediatePreviewElement";
/**
* Whether the element is a live streaming-preview element — a real scene
* element inserted with `CaptureUpdateAction.NEVER` while a generation is
* still streaming. Such elements must never be persisted (hosts should filter
* them from saves — see excalidraw-app's `getPersistableElements`).
*/
export const isIntermediatePreviewElement = (
element: ExcalidrawElement,
): boolean => element.customData?.[INTERMEDIATE_PREVIEW_ELEMENT_KEY] === true;
/**
* Tombstones every non-deleted element tagged with one of the given
* generation tags. Pure — returns the next elements array and whether
* anything changed; the caller decides how to apply it to the scene.
*/
export const getElementsWithDeletedGenerationTags = (
elements: readonly ExcalidrawElement[],
generationTags: ReadonlySet<string>,
): { elements: ExcalidrawElement[]; didChange: boolean } => {
let didChange = false;
const nextElements = elements.map((element) => {
if (element.isDeleted) {
return element;
}
const elementGenerationTag =
element.customData?.[AI_GENERATED_ELEMENTS_KEY];
if (
typeof elementGenerationTag === "string" &&
generationTags.has(elementGenerationTag)
) {
didChange = true;
return newElementWith(element, { isDeleted: true });
}
return element;
});
return { elements: nextElements, didChange };
};
interface InsertSkeletonsOptions {
targetCenter?: { x: number; y: number };
generationId?: string;
regenerateIds?: boolean;
selectInsertedElements?: boolean;
captureUpdate?: CaptureUpdateActionType;
deleteGenerationTags?: readonly string[];
intermediatePreviewElement?: boolean;
}
export const fixBoundTextElements = (
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
// `restoreElements()` already repairs the `text.containerId -> container.boundElements`
// direction. This pass only makes the inverse container-declared binding
// authoritative when the payload already references the text from the container.
for (const element of elements) {
if (!element.boundElements?.length) {
continue;
}
const boundTextId = element.boundElements.find(
(boundElement) => boundElement.type === "text",
)?.id;
if (!boundTextId) {
continue;
}
const boundTextElement = elementsMap.get(boundTextId);
if (!boundTextElement || boundTextElement.isDeleted) {
continue;
}
if (
boundTextElement.type === "text" &&
boundTextElement.containerId !== element.id
) {
scene.mutateElement(boundTextElement, {
containerId: element.id,
});
}
}
for (const element of elements) {
if (!isBoundToContainer(element)) {
continue;
}
const container = getContainerElement(element, elementsMap);
if (!container) {
continue;
}
redrawTextBoundingBox(element, container, scene);
}
};
const convertAISkeletonsToElements = (
skeletons: ReadonlyArray<ExcalidrawElementSkeleton>,
options?: { regenerateIds?: boolean },
): readonly NonDeletedExcalidrawElement[] => {
if (skeletons.length === 0) {
return [];
}
const convertedElements = convertToExcalidrawElements([...skeletons], {
regenerateIds: options?.regenerateIds ?? false,
}) as readonly NonDeletedExcalidrawElement[];
const normalizedElements = restoreElements(convertedElements, null, {
repairBindings: true,
}) as readonly NonDeletedExcalidrawElement[];
return normalizeElbowArrows(normalizedElements);
};
export const convertAISkeletonsToSceneElements = (
skeletons: ReadonlyArray<ExcalidrawElementSkeleton>,
app: AppClassProperties,
options?: InsertSkeletonsOptions,
): readonly NonDeletedExcalidrawElement[] => {
if (skeletons.length === 0) {
return [];
}
const elbowNormalizedElements = convertAISkeletonsToElements(skeletons, {
regenerateIds: options?.regenerateIds ?? false,
});
const [minX, minY, maxX, maxY] = getCommonBounds(elbowNormalizedElements);
const elementsCenterXRaw = (minX + maxX) / 2;
const elementsCenterYRaw = (minY + maxY) / 2;
const { sceneX: viewportCenterSceneX, sceneY: viewportCenterSceneY } =
getViewportCenter(app.state);
const destination = options?.targetCenter ?? {
x: viewportCenterSceneX,
y: viewportCenterSceneY,
};
const elementsCenterX = Number.isFinite(elementsCenterXRaw)
? elementsCenterXRaw
: destination.x;
const elementsCenterY = Number.isFinite(elementsCenterYRaw)
? elementsCenterYRaw
: destination.y;
const dx = destination.x - elementsCenterX;
const dy = destination.y - elementsCenterY;
const generationId = options?.generationId;
const isIntermediatePreview = options?.intermediatePreviewElement === true;
return elbowNormalizedElements.map((element) => {
const nextCustomData =
generationId || isIntermediatePreview
? {
...(element.customData ?? {}),
...(generationId
? { [AI_GENERATED_ELEMENTS_KEY]: generationId }
: {}),
...(isIntermediatePreview
? { [INTERMEDIATE_PREVIEW_ELEMENT_KEY]: true }
: {}),
}
: element.customData;
return {
...element,
x: element.x + dx,
y: element.y + dy,
customData: nextCustomData,
};
}) as readonly NonDeletedExcalidrawElement[];
};
export const insertAISkeletons = (
app: AppClassProperties,
skeletons: ReadonlyArray<ExcalidrawElementSkeleton>,
options?: InsertSkeletonsOptions,
) => {
const rawAdjustedElements = convertAISkeletonsToSceneElements(
skeletons,
app,
options,
);
if (!rawAdjustedElements.length) {
return [];
}
const deleteGenerationTagSet = options?.deleteGenerationTags?.length
? new Set(options.deleteGenerationTags)
: null;
const existingElementsBase = deleteGenerationTagSet
? app.scene.getElementsIncludingDeleted().map((element) => {
if (element.isDeleted) {
return element;
}
const elementGenerationTag =
element.customData?.[AI_GENERATED_ELEMENTS_KEY];
if (
typeof elementGenerationTag === "string" &&
deleteGenerationTagSet.has(elementGenerationTag)
) {
return newElementWith(element, { isDeleted: true });
}
return element;
})
: app.scene.getElementsIncludingDeleted();
const existingElementsById = new Map(
existingElementsBase.map((element) => [element.id, element]),
);
const adjustedElements = rawAdjustedElements.map((element) => {
const existingElement = existingElementsById.get(element.id);
if (existingElement && element.version <= existingElement.version) {
return newElementWith(
element,
{ version: existingElement.version + 1 },
true,
);
}
return element;
});
const adjustedElementIds = new Set(
adjustedElements.map((element) => element.id),
);
const existingElements = existingElementsBase.filter(
(element) => !adjustedElementIds.has(element.id),
);
const nextElements = [...existingElements, ...adjustedElements];
// We don't want to mutate the live scene before updateScene is applied, but we do
// want any bound-text corrections to be included in the captured update.
const tempScene = new Scene(nextElements, { skipValidation: true });
fixBoundTextElements(adjustedElements, tempScene);
const appState = options?.selectInsertedElements
? {
selectedElementIds: makeNextSelectedElementIds(
adjustedElements
.filter((element) => !isBoundToContainer(element))
.reduce((acc: Record<string, true>, element) => {
acc[element.id] = true;
return acc;
}, {}),
app.state,
),
}
: undefined;
app.syncActionResult({
elements: nextElements,
appState,
captureUpdate: options?.captureUpdate ?? CaptureUpdateAction.NEVER,
});
return adjustedElements;
};
-75
View File
@@ -1,75 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { throttle } from "./throttle";
describe("throttle", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("invokes immediately on the leading edge", () => {
const fn = vi.fn();
const throttled = throttle(fn, 300);
throttled("a");
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith("a");
});
it("parks calls inside the window and invokes the latest on the trailing edge", () => {
const fn = vi.fn();
const throttled = throttle(fn, 300);
throttled("a");
throttled("b");
throttled("c");
expect(fn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(299);
expect(fn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenLastCalledWith("c");
// no extra trailing invocation afterwards
vi.advanceTimersByTime(1000);
expect(fn).toHaveBeenCalledTimes(2);
});
it("flush invokes a parked call immediately", () => {
const fn = vi.fn();
const throttled = throttle(fn, 300);
throttled("a");
throttled("b");
throttled.flush();
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenLastCalledWith("b");
// flush with nothing parked is a no-op
throttled.flush();
expect(fn).toHaveBeenCalledTimes(2);
});
it("cancel drops the parked call and resets the window", () => {
const fn = vi.fn();
const throttled = throttle(fn, 300);
throttled("a");
throttled("b");
throttled.cancel();
vi.advanceTimersByTime(1000);
expect(fn).toHaveBeenCalledTimes(1);
// window was reset — the next call is a leading-edge immediate invoke
throttled("c");
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenLastCalledWith("c");
});
});
-73
View File
@@ -1,73 +0,0 @@
/**
* Shared leading + trailing edge throttle (tta_rewrite_final.md §2.4).
*
* - A call outside the window invokes immediately (leading edge).
* - A call inside the window parks the *latest* args and schedules a trailing
* invocation for when the window elapses — a parked update never has to wait
* for the next call to render (M1: no more frozen canvas on provider
* stalls).
* - `flush()` invokes any parked call immediately.
* - `cancel()` drops any parked call and fully resets the throttle, so the
* next call is a leading-edge immediate invoke.
*/
export type ThrottledFunction<Args extends unknown[]> = {
(...args: Args): void;
flush: () => void;
cancel: () => void;
};
export const throttle = <Args extends unknown[]>(
fn: (...args: Args) => void,
wait: number,
): ThrottledFunction<Args> => {
let lastInvokeTime = -Infinity;
let pendingArgs: Args | null = null;
let trailingTimeout: ReturnType<typeof setTimeout> | null = null;
const clearTrailingTimeout = () => {
if (trailingTimeout !== null) {
clearTimeout(trailingTimeout);
trailingTimeout = null;
}
};
const invoke = (args: Args) => {
pendingArgs = null;
lastInvokeTime = Date.now();
fn(...args);
};
const throttled = (...args: Args) => {
const remaining = wait - (Date.now() - lastInvokeTime);
if (remaining <= 0) {
clearTrailingTimeout();
invoke(args);
return;
}
pendingArgs = args;
if (trailingTimeout === null) {
trailingTimeout = setTimeout(() => {
trailingTimeout = null;
if (pendingArgs) {
invoke(pendingArgs);
}
}, remaining);
}
};
throttled.flush = () => {
clearTrailingTimeout();
if (pendingArgs) {
invoke(pendingArgs);
}
};
throttled.cancel = () => {
clearTrailingTimeout();
pendingArgs = null;
lastInvokeTime = -Infinity;
};
return throttled;
};
-239
View File
@@ -1,239 +0,0 @@
import type { ExcalidrawElementSkeleton } from "@excalidraw/element";
import type { ReactNode } from "react";
import type { AIRateLimitWarningDescriptor } from "../aiWarnings";
export type AIServerMessageRole = "user" | "assistant";
export type AIAssistantLifecycleStatus =
| "pending"
| "completed"
| "failed"
| "aborted";
export type AIStreamProgressPhase =
| "starting"
| "waiting"
| "thinking"
| "generating"
| "finalizing";
export interface AIServerConversationMessage {
role: AIServerMessageRole;
content: string;
images?: string[];
}
export interface AIGenerationRetryContext {
reason: "user_not_happy" | "generation_error";
avoidSimilarity?: boolean;
retryAssistantMessageId?: string;
}
export interface AIGenerateRequestPayload extends Record<string, unknown> {
prompt: string;
images?: string[];
chatId?: string | null;
retry?: AIGenerationRetryContext;
}
export interface ExcalidrawServerPayloadBase {
skeletons: ExcalidrawElementSkeleton[];
}
export interface AIStreamPartialPayload extends ExcalidrawServerPayloadBase {
isComplete: boolean;
}
export interface AIStreamStartedPayload {
chatId: string;
turnId: string;
messageId: string;
lifecycleStatus?: Extract<AIAssistantLifecycleStatus, "pending">;
updatedAt?: number | null;
}
export interface AIStreamMessagePayload {
message: string;
}
export interface AIStreamFinalPayload extends AIStreamPartialPayload {
chatId?: string | null;
turnId?: string | null;
messageId?: string | null;
lifecycleStatus?: Extract<AIAssistantLifecycleStatus, "completed">;
updatedAt?: number | null;
/**
* Provider finish reason, forwarded from the `done` frame. `"length"` /
* `"content_filter"` mean the generation was truncated/blocked even though
* it parsed — not a clean success.
*/
finishReason?: "stop" | "length" | "content_filter" | "tool_calls" | null;
}
export const AI_ERRORS = {
RATE_LIMIT: {
code: 429,
message: "Rate limit exceeded. Please wait before trying again.",
},
SERVER_ERROR: {
code: 500,
message: "An internal server error occurred. Please try again later.",
},
REQUEST_ERROR: {
code: 400,
message: "The request could not be processed due to invalid data.",
},
GENERATION_ERROR: {
code: 422,
message: "Failed to generate valid content.",
},
} as const;
export type AI_ERROR_CODE = typeof AI_ERRORS[keyof typeof AI_ERRORS]["code"];
export type StreamChunk =
| {
type: "started";
chatId: string;
turnId: string;
messageId: string;
lifecycleStatus?: Extract<AIAssistantLifecycleStatus, "pending">;
updatedAt?: number;
}
| {
type: "message";
message: string;
}
| {
type: "partial";
skeletons: ExcalidrawElementSkeleton[];
isComplete?: boolean;
}
| {
type: "done";
lifecycleStatus?: Extract<AIAssistantLifecycleStatus, "completed">;
finishReason: "stop" | "length" | "content_filter" | "tool_calls" | null;
skeletons: ExcalidrawElementSkeleton[];
chatId?: string;
turnId?: string;
messageId?: string;
updatedAt?: number;
}
| {
type: "error";
lifecycleStatus?: Extract<AIAssistantLifecycleStatus, "failed">;
error: {
code?: number;
message: string;
};
};
export type UserMessage = {
role: "user";
id: string; // stable, persisted (no re-minting on hydrate)
content: string;
images?: string[];
createdAt: number;
};
/**
* The assistant generation lifecycle as a discriminated union — exactly one
* variant at a time, patched with single `status =` assignments. Everything
* else (spinner, terminal labels, retry-eligibility, elapsed display) is
* derived at render from `kind`.
*/
export type AssistantStatus =
| {
kind: "streaming";
phase: AIStreamProgressPhase;
startedAt: number;
/** Free server text (`message` frames) overriding the phase label. */
statusText?: string;
}
| {
kind: "done";
elapsedMs: number;
outcome: "generated" | "empty";
/** Provider finishReason surfaced: truncated/blocked, partials kept. */
warning?: "length" | "content_filter";
}
| { kind: "stopped"; elapsedMs: number; reason: "user" | "interrupted" }
| {
kind: "error";
elapsedMs?: number;
error: { code?: number; message: string };
};
export type AssistantMessage = {
role: "assistant";
/**
* Local generation id — stable across error-retries and persistence.
*/
id: string;
createdAt: number;
/** Reconciled once from `started`; metadata only (retry/truncate ids). */
server?: { turnId: string; messageId: string };
/**
* Server id of the turn's last *successful* attempt — the only id the
* server's retry lookup accepts (`current_message_id`). Survives failed
* retries; absent when the turn never completed successfully.
*/
lastCompletedMessageId?: string;
skeletons?: ReadonlyArray<ExcalidrawElementSkeleton>;
status: AssistantStatus;
};
/**
* Session-scoped rate-limit warning bubble. Not persisted; the numbers live in
* the rate-limits atom, not on the message.
*/
export type SystemWarningMessage = {
role: "system";
id: string;
createdAt: number;
variant: AIRateLimitWarningDescriptor["variant"];
};
export type ChatMessage = UserMessage | AssistantMessage | SystemWarningMessage;
export type ChatConversation = {
id: string;
title: string;
messages: ChatMessage[];
updatedAt: number;
};
export interface TTARateLimits {
rateLimit: number;
rateLimitRemaining: number;
}
export type TTAChatScrollOptions = {
keepElementTopVisible?: HTMLElement | null;
behavior?: ScrollBehavior;
};
export interface TTAPersistenceAdapter {
loadChats(): Promise<ChatConversation[]>;
saveChat(chat: ChatConversation): Promise<void>;
deleteChat(id: string): Promise<void>;
}
/**
* Renders the empty-state intro area. Return `undefined` to use the built-in
* guidance text.
*/
export type TTADialogRenderWelcomeScreen = (props: {
rateLimits: TTARateLimits | null;
}) => ReactNode | undefined;
/**
* Return `undefined` to use the built-in warning/error rendering.
* Receives a shared warning descriptor as the first argument.
* Returning a node replaces the full message row.
*/
export type TTADialogRenderWarning = (
warning: AIRateLimitWarningDescriptor,
chatMessage: SystemWarningMessage,
) => ReactNode | undefined;
@@ -1,281 +0,0 @@
import React from "react";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ExcalidrawElementSkeleton } from "@excalidraw/element";
import {
evictAssistantPreviews,
useAIAssistantPreview,
} from "./useAIAssistantPreview";
import type { AssistantMessage } from "./types";
const { exportToSvgMock, mockApp } = vi.hoisted(() => ({
exportToSvgMock: vi.fn(),
// stable identity — `app` is an effect dependency in the hook (the real
// useApp returns the context-stable App instance)
mockApp: { files: {} },
}));
vi.mock("@excalidraw/utils/export", () => ({
exportToSvg: exportToSvgMock,
}));
vi.mock("../components/App", () => ({
useApp: () => mockApp,
}));
vi.mock("../hooks/useAppStateValue", () => ({
useAppStateValue: () => "light",
}));
vi.mock("./insertAISkeletons", () => ({
convertAISkeletonsToSceneElements: (
skeletons: ReadonlyArray<{ id?: string }>,
) =>
skeletons.map((skeleton, index) => ({
id: skeleton.id ?? `element-${index}`,
type: "rectangle",
isDeleted: false,
})),
fixBoundTextElements: () => {},
}));
const skeleton = (id: string): ExcalidrawElementSkeleton =>
({
type: "rectangle",
id,
x: 0,
y: 0,
width: 10,
height: 10,
} as ExcalidrawElementSkeleton);
const completedMessage = (
id: string,
skeletons: ReadonlyArray<ExcalidrawElementSkeleton> = [
skeleton(`${id}-skeleton`),
],
): AssistantMessage => ({
role: "assistant",
id,
createdAt: 1,
skeletons,
status: { kind: "done", elapsedMs: 10, outcome: "generated" },
});
const streamingMessage = (
id: string,
skeletons: ReadonlyArray<ExcalidrawElementSkeleton>,
): AssistantMessage => ({
role: "assistant",
id,
createdAt: 1,
skeletons,
status: { kind: "streaming", phase: "generating", startedAt: 1 },
});
type PreviewHookValue = ReturnType<typeof useAIAssistantPreview>;
const Harness = ({
message,
enabled,
onHook,
}: {
message: AssistantMessage;
enabled?: boolean;
onHook: (value: PreviewHookValue) => void;
}) => {
onHook(
useAIAssistantPreview(
message,
enabled === undefined ? undefined : { enabled },
),
);
return null;
};
const renderPreview = (message: AssistantMessage, enabled?: boolean) => {
const hookRef: { current: PreviewHookValue | null } = { current: null };
const utils = render(
<Harness
message={message}
enabled={enabled}
onHook={(value) => {
hookRef.current = value;
}}
/>,
);
return { hookRef, ...utils };
};
/** Mounts a completed message so its preview lands in the module cache. */
const mountUntilDone = async (message: AssistantMessage) => {
const { hookRef, unmount } = renderPreview(message);
await waitFor(() => {
expect(hookRef.current!.status).toBe("done");
});
unmount();
};
describe("useAIAssistantPreview", () => {
beforeEach(() => {
exportToSvgMock.mockClear();
exportToSvgMock.mockImplementation(async () =>
document.createElementNS("http://www.w3.org/2000/svg", "svg"),
);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("serves a completed message from the cache without re-rendering", async () => {
const message = completedMessage("cache-hit");
await mountUntilDone(message);
expect(exportToSvgMock).toHaveBeenCalledTimes(1);
// same render key + same skeletons reference → synchronously done from
// the initializer, no new export
const { hookRef, unmount } = renderPreview(message);
expect(hookRef.current!.status).toBe("done");
expect(hookRef.current!.previewSvg).toMatch(/^data:image\/svg\+xml,/);
await act(async () => {});
expect(exportToSvgMock).toHaveBeenCalledTimes(1);
unmount();
});
it("caps the cache at 32 entries, evicting the least-recently-used one", async () => {
const messages = Array.from({ length: 32 }, (_, index) =>
completedMessage(`lru-${index}`),
);
for (const message of messages) {
await mountUntilDone(message);
}
expect(exportToSvgMock).toHaveBeenCalledTimes(32);
// touch lru-0 (cache hit) so lru-1 becomes the least-recently-used entry
const touched = renderPreview(messages[0]);
expect(touched.hookRef.current!.status).toBe("done");
await act(async () => {});
touched.unmount();
expect(exportToSvgMock).toHaveBeenCalledTimes(32);
// 33rd entry exceeds the bound → evicts lru-1, not the freshly-used lru-0
await mountUntilDone(completedMessage("lru-32"));
expect(exportToSvgMock).toHaveBeenCalledTimes(33);
const kept = renderPreview(messages[0]);
expect(kept.hookRef.current!.status).toBe("done");
await act(async () => {});
kept.unmount();
expect(exportToSvgMock).toHaveBeenCalledTimes(33);
const evicted = renderPreview(messages[1]);
expect(evicted.hookRef.current!.status).toBe("loading");
await waitFor(() => {
expect(evicted.hookRef.current!.status).toBe("done");
});
evicted.unmount();
expect(exportToSvgMock).toHaveBeenCalledTimes(34);
});
it("evictAssistantPreviews drops the given messages' cached previews", async () => {
const kept = completedMessage("evict-kept");
const dropped = completedMessage("evict-dropped");
await mountUntilDone(kept);
await mountUntilDone(dropped);
exportToSvgMock.mockClear();
evictAssistantPreviews(["evict-dropped", "evict-not-cached"]);
const keptRemount = renderPreview(kept);
expect(keptRemount.hookRef.current!.status).toBe("done");
await act(async () => {});
keptRemount.unmount();
expect(exportToSvgMock).not.toHaveBeenCalled();
const droppedRemount = renderPreview(dropped);
expect(droppedRemount.hookRef.current!.status).toBe("loading");
await waitFor(() => {
expect(droppedRemount.hookRef.current!.status).toBe("done");
});
droppedRemount.unmount();
expect(exportToSvgMock).toHaveBeenCalledTimes(1);
});
it("enabled: false does no render work and reports idle / cached / unavailable", async () => {
const message = completedMessage("disabled");
// uncached → idle, no export
const idle = renderPreview(message, false);
expect(idle.hookRef.current!.status).toBe("idle");
await act(async () => {});
expect(idle.hookRef.current!.status).toBe("idle");
expect(exportToSvgMock).not.toHaveBeenCalled();
idle.unmount();
// cached → served as done, still no render work
await mountUntilDone(message);
exportToSvgMock.mockClear();
const cached = renderPreview(message, false);
expect(cached.hookRef.current!.status).toBe("done");
expect(cached.hookRef.current!.previewSvg).toBeTruthy();
await act(async () => {});
expect(exportToSvgMock).not.toHaveBeenCalled();
cached.unmount();
// no skeletons → unavailable
const empty = renderPreview(
{ ...completedMessage("disabled-empty"), skeletons: [] },
false,
);
expect(empty.hookRef.current!.status).toBe("unavailable");
empty.unmount();
});
it("throttles streaming renders with a trailing flush", async () => {
vi.useFakeTimers();
const firstChunk = [skeleton("chunk-1")];
const secondChunk = [skeleton("chunk-1"), skeleton("chunk-2")];
const hookRef: { current: PreviewHookValue | null } = { current: null };
const onHook = (value: PreviewHookValue) => {
hookRef.current = value;
};
const { rerender, unmount } = render(
<Harness
message={streamingMessage("streaming", firstChunk)}
onHook={onHook}
/>,
);
// leading edge: the first chunk renders immediately
await act(async () => {});
expect(exportToSvgMock).toHaveBeenCalledTimes(1);
expect(hookRef.current!.status).toBe("done");
// a chunk inside the window parks (previous preview stays visible)
rerender(
<Harness
message={streamingMessage("streaming", secondChunk)}
onHook={onHook}
/>,
);
await act(async () => {});
expect(exportToSvgMock).toHaveBeenCalledTimes(1);
expect(hookRef.current!.status).toBe("loading");
expect(hookRef.current!.previewSvg).toBeTruthy();
// trailing edge flushes the parked chunk
await act(async () => {
vi.advanceTimersByTime(300);
});
await act(async () => {});
expect(exportToSvgMock).toHaveBeenCalledTimes(2);
expect(hookRef.current!.status).toBe("done");
unmount();
});
});
@@ -1,304 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Scene } from "@excalidraw/element";
import { exportToSvg } from "@excalidraw/utils/export";
import type { ExcalidrawElementSkeleton } from "@excalidraw/element";
import { useApp } from "../components/App";
import { useAppStateValue } from "../hooks/useAppStateValue";
import {
convertAISkeletonsToSceneElements,
fixBoundTextElements,
} from "./insertAISkeletons";
import { throttle } from "./throttle";
import type { AssistantMessage } from "./types";
import type { AppClassProperties, AppState } from "../types";
const ASSISTANT_PREVIEW_RENDER_THROTTLE_DELAY = 300;
/** LRU bound for the module-level preview cache (M7). */
const ASSISTANT_PREVIEW_CACHE_MAX_ENTRIES = 32;
export type AIAssistantPreviewStatus =
| "idle"
| "loading"
| "done"
| "unavailable";
type AIAssistantPreviewState = {
previewSvg: string | null;
status: AIAssistantPreviewStatus;
};
type AssistantPreviewCacheEntry = {
renderKey: string;
skeletons: ReadonlyArray<ExcalidrawElementSkeleton>;
previewSvg: string;
};
type AssistantPreviewRenderRequest = {
app: AppClassProperties;
theme: AppState["theme"];
messageId: string;
renderKey: string;
skeletons: ReadonlyArray<ExcalidrawElementSkeleton>;
};
type UseAIAssistantPreviewOptions = {
enabled?: boolean;
};
/**
* Preview cache keyed by the (persistence-stable) local message id. The Map's
* insertion order doubles as recency order — hits and writes re-insert their
* entry, so the first key is always the least-recently-used one.
*/
const assistantPreviewCache = new Map<string, AssistantPreviewCacheEntry>();
const getCachedPreview = (
messageId: string,
renderKey: string,
skeletons: ReadonlyArray<ExcalidrawElementSkeleton>,
) => {
const cached = assistantPreviewCache.get(messageId);
// streaming entries self-invalidate on new chunks: the skeletons identity
// changes, so only the exact rendered partial ever hits
if (cached?.renderKey === renderKey && cached.skeletons === skeletons) {
assistantPreviewCache.delete(messageId);
assistantPreviewCache.set(messageId, cached);
return cached.previewSvg;
}
return null;
};
const setCachedPreview = (
messageId: string,
entry: AssistantPreviewCacheEntry,
) => {
assistantPreviewCache.delete(messageId);
assistantPreviewCache.set(messageId, entry);
if (assistantPreviewCache.size > ASSISTANT_PREVIEW_CACHE_MAX_ENTRIES) {
const leastRecentlyUsed = assistantPreviewCache.keys().next().value;
if (leastRecentlyUsed !== undefined) {
assistantPreviewCache.delete(leastRecentlyUsed);
}
}
};
/** Drops cached previews for the given messages — called on chat delete (M7). */
export const evictAssistantPreviews = (messageIds: Iterable<string>) => {
for (const messageId of messageIds) {
assistantPreviewCache.delete(messageId);
}
};
const isDarkTheme = (theme: AppState["theme"]) => theme === "dark";
/** State updater that keeps the previous object identity when values match. */
const toPreviewState =
(
previewSvg: string | null,
status: AIAssistantPreviewStatus,
): ((prev: AIAssistantPreviewState) => AIAssistantPreviewState) =>
(prev) =>
prev.previewSvg === previewSvg && prev.status === status
? prev
: { previewSvg, status };
const getRenderKey = (message: AssistantMessage, theme: AppState["theme"]) =>
[
message.id,
theme,
message.status.kind === "streaming" ? "streaming" : "complete",
].join(":");
export const renderAIAssistantPreviewDataUrl = async ({
app,
skeletons,
theme,
}: {
app: AppClassProperties;
skeletons: ReadonlyArray<ExcalidrawElementSkeleton>;
theme: AppState["theme"];
}) => {
if (!skeletons.length) {
return null;
}
try {
const elements = convertAISkeletonsToSceneElements(skeletons, app, {
targetCenter: { x: 0, y: 0 },
});
if (!elements.length) {
console.warn("AI Sidebar: no elements generated from skeletons");
return null;
}
const scene = new Scene(elements, { skipValidation: true });
fixBoundTextElements(elements, scene);
if (typeof window === "undefined" || typeof XMLSerializer === "undefined") {
return null;
}
const svgElement = await exportToSvg({
elements,
appState: {
exportBackground: false,
exportWithDarkMode: isDarkTheme(theme),
},
files: app.files,
renderEmbeddables: false,
skipInliningFonts: true,
});
const serialized = new XMLSerializer().serializeToString(svgElement);
return `data:image/svg+xml,${encodeURIComponent(serialized)}`;
} catch (error) {
console.warn("[AI Chat] Failed to render preview", error);
return null;
}
};
export const useAIAssistantPreview = (
message: AssistantMessage,
options: UseAIAssistantPreviewOptions = {},
): AIAssistantPreviewState => {
const app = useApp();
const theme = useAppStateValue("theme");
const enabled = options.enabled ?? true;
const skeletons = message.skeletons;
const isStreaming = message.status.kind === "streaming";
const renderKey = getRenderKey(message, theme);
const [previewState, setPreviewState] = useState<AIAssistantPreviewState>(
() => {
// NOTE errored messages may still carry partial skeletons streamed
// before the failure (C2 in tta.md) — render them so the salvaged
// partial result stays previewable alongside the error.
if (!skeletons?.length) {
return { previewSvg: null, status: "unavailable" };
}
const cachedPreview = getCachedPreview(message.id, renderKey, skeletons);
if (cachedPreview) {
return { previewSvg: cachedPreview, status: "done" };
}
return { previewSvg: null, status: enabled ? "loading" : "idle" };
},
);
/**
* Epoch token: the render key the hook currently wants on screen (null when
* idle/unavailable). An async render result only lands — in state *and* in
* the cache — while it still matches; `renderSeq` additionally drops it once
* a newer render has *started*, so a slow stale export never clobbers a
* newer one.
*/
const activeRenderKeyRef = useRef<string | null>(null);
const renderSeqRef = useRef(0);
const throttledRender = useMemo(
() =>
throttle((request: AssistantPreviewRenderRequest) => {
if (activeRenderKeyRef.current !== request.renderKey) {
// parked trailing render whose render key was superseded
return;
}
const seq = ++renderSeqRef.current;
void renderAIAssistantPreviewDataUrl(request).then((previewSvg) => {
if (
renderSeqRef.current !== seq ||
activeRenderKeyRef.current !== request.renderKey
) {
return;
}
if (previewSvg) {
setCachedPreview(request.messageId, {
renderKey: request.renderKey,
skeletons: request.skeletons,
previewSvg,
});
setPreviewState(toPreviewState(previewSvg, "done"));
} else {
setPreviewState(toPreviewState(null, "unavailable"));
}
});
}, ASSISTANT_PREVIEW_RENDER_THROTTLE_DELAY),
[],
);
// NOTE deliberately no effect cleanup: canceling the throttle between runs
// would drop the parked trailing render on every streaming chunk. Stale
// parked/in-flight renders self-drop via the epoch checks above instead
// (at worst one parked render completes after unmount and warms the cache).
useEffect(() => {
if (!skeletons?.length) {
activeRenderKeyRef.current = null;
renderSeqRef.current += 1;
throttledRender.cancel();
setPreviewState(toPreviewState(null, "unavailable"));
return;
}
const cachedPreview = getCachedPreview(message.id, renderKey, skeletons);
if (!enabled) {
// offscreen history rows (IntersectionObserver lazy rendering): no
// render work — fall back to the cached preview or stay idle
activeRenderKeyRef.current = null;
renderSeqRef.current += 1;
throttledRender.cancel();
setPreviewState(
cachedPreview
? toPreviewState(cachedPreview, "done")
: toPreviewState(null, "idle"),
);
return;
}
activeRenderKeyRef.current = renderKey;
if (cachedPreview) {
renderSeqRef.current += 1;
throttledRender.cancel();
setPreviewState(toPreviewState(cachedPreview, "done"));
return;
}
// keep the previous preview visible while the replacement renders
setPreviewState((prev) =>
prev.status === "loading"
? prev
: { previewSvg: prev.previewSvg, status: "loading" },
);
const request: AssistantPreviewRenderRequest = {
app,
theme,
messageId: message.id,
renderKey,
skeletons,
};
if (isStreaming) {
throttledRender(request);
} else {
// terminal renders are immediate: cancel() resets the throttle window,
// so the call below invokes on its leading edge
throttledRender.cancel();
throttledRender(request);
}
}, [
app,
enabled,
isStreaming,
message.id,
renderKey,
skeletons,
theme,
throttledRender,
]);
return previewState;
};
@@ -1,340 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAIStreamingLifecycle } from "./useAIStreamingLifecycle";
import { useCanvasDraft } from "./useCanvasDraft";
import type { TTAStreamFetchResult } from "./client";
import type { AssistantMessage, ChatMessage } from "./types";
import type { AppClassProperties } from "../types";
const streamingStatusOf = (message: AssistantMessage) => {
if (message.status.kind !== "streaming") {
throw new Error(
`expected a streaming status, got "${message.status.kind}"`,
);
}
return message.status;
};
const deferred = () => {
let resolve!: () => void;
const promise = new Promise<void>((_resolve) => {
resolve = _resolve;
});
return { promise, resolve };
};
const createMockApp = () => {
let elements: unknown[] = [];
const updateScene = vi.fn(({ elements: nextElements }) => {
elements = nextElements;
});
const syncActionResult = vi.fn(({ elements: nextElements }) => {
elements = nextElements;
});
return {
api: {
updateScene,
},
state: {
width: 1000,
height: 1000,
scrollX: 0,
scrollY: 0,
offsetLeft: 0,
offsetTop: 0,
zoom: { value: 1 },
selectedElementIds: {},
},
scene: {
getNonDeletedElements: () =>
elements.filter(
(element) => !(element as { isDeleted?: boolean }).isDeleted,
),
getElementsIncludingDeleted: () => elements,
},
syncActionResult,
} as unknown as AppClassProperties;
};
const TestHarness = ({
streamResult,
streamFetch: streamFetchProp,
onRateLimitInfo,
onMessagesChange,
}: {
streamResult?: TTAStreamFetchResult;
streamFetch?: ReturnType<typeof vi.fn>;
onRateLimitInfo: (rateLimitInfo: {
rateLimit?: number | null;
rateLimitRemaining?: number | null;
}) => void;
/** Receives the raw (non-serialized) message array on every change. */
onMessagesChange?: (messages: ChatMessage[]) => void;
}) => {
const [app] = useState(createMockApp);
const [streamFetch] = useState(
() => streamFetchProp ?? vi.fn().mockResolvedValue(streamResult),
);
const [applyServerChatMetadata] = useState(() => vi.fn());
const didGenerateRef = useRef(false);
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([
{
id: "user-1",
role: "user",
content: "hello",
createdAt: 1,
},
{
id: "assistant-1",
role: "assistant",
createdAt: 2,
status: { kind: "streaming", phase: "starting", startedAt: 2 },
},
]);
const canvasDraft = useCanvasDraft({ app });
const lifecycle = useAIStreamingLifecycle({
chatMessages,
setChatMessages,
applyServerChatMetadata,
canvasDraft,
streamFetch,
onRateLimitInfo,
});
const { generateResponse } = lifecycle;
useEffect(() => {
onMessagesChange?.(chatMessages);
}, [chatMessages, onMessagesChange]);
useEffect(() => {
if (didGenerateRef.current) {
return;
}
didGenerateRef.current = true;
void generateResponse("assistant-1", { prompt: "hello" });
}, [generateResponse]);
return (
<pre data-testid="messages">{JSON.stringify(chatMessages, null, 2)}</pre>
);
};
describe("useAIStreamingLifecycle", () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it("adds a rate-limit warning when a successful response exhausts the quota", async () => {
const onRateLimitInfo = vi.fn();
render(
<TestHarness
onRateLimitInfo={onRateLimitInfo}
streamResult={{
rateLimit: 100,
rateLimitRemaining: 0,
finalPayload: {
skeletons: [],
isComplete: true,
chatId: "chat-1",
turnId: "turn-1",
messageId: "message-1",
lifecycleStatus: "completed",
},
error: null,
}}
/>,
);
await waitFor(() => {
expect(
JSON.parse(screen.getByTestId("messages").textContent!),
).toHaveLength(3);
});
const messages = JSON.parse(screen.getByTestId("messages").textContent!);
expect(onRateLimitInfo).toHaveBeenCalledWith({
rateLimit: 100,
rateLimitRemaining: 0,
});
expect(messages).toHaveLength(3);
expect(messages[1]).toMatchObject({
id: "assistant-1",
role: "assistant",
server: { turnId: "turn-1", messageId: "message-1" },
lastCompletedMessageId: "message-1",
status: {
kind: "done",
outcome: "empty",
elapsedMs: expect.any(Number),
},
});
// session-scoped system warning bubble — the rate-limit numbers live in
// the rate-limits atom, not on the message
expect(messages[2]).toMatchObject({
role: "system",
variant: "messageLimitExceeded",
});
});
it("keeps the streaming label until the status genuinely changes (keep-alive chunks are no-ops)", async () => {
const skeleton = { type: "rectangle", x: 0, y: 0, width: 10, height: 10 };
const statusText = "Something went wrong. Retrying...";
const keepAliveGate = deferred();
const contentGate = deferred();
const doneGate = deferred();
const streamFetch = vi.fn().mockImplementation(async (options) => {
options.onStarted?.({
chatId: "chat-1",
turnId: "turn-1",
messageId: "message-1",
});
await keepAliveGate.promise;
// empty keep-alive frames around a server `message` frame — the server
// emits these continuously, and they must not replace the status
// object (no-op) nor wipe the label
options.onChunk?.({ skeletons: [], isComplete: false });
options.onChunk?.({ skeletons: [], isComplete: false });
options.onMessage?.({ message: statusText });
options.onChunk?.({ skeletons: [], isComplete: false });
await contentGate.promise;
// real content — the label downgrade is legitimate progress
options.onChunk?.({ skeletons: [skeleton], isComplete: false });
await doneGate.promise;
return {
finalPayload: {
skeletons: [skeleton],
isComplete: true,
chatId: "chat-1",
turnId: "turn-1",
messageId: "message-1",
},
error: null,
};
});
const snapshots: ChatMessage[][] = [];
render(
<TestHarness
onRateLimitInfo={vi.fn()}
streamFetch={streamFetch}
onMessagesChange={(messages) => snapshots.push(messages)}
/>,
);
await waitFor(() => {
const messages = JSON.parse(screen.getByTestId("messages").textContent!);
expect(messages[1].status).toMatchObject({
kind: "streaming",
phase: "generating",
});
});
const startedAt = streamingStatusOf(
snapshots.at(-1)![1] as AssistantMessage,
).startedAt;
keepAliveGate.resolve();
// the server statusText set between keep-alive chunks survives them
await waitFor(() => {
const messages = JSON.parse(screen.getByTestId("messages").textContent!);
expect(messages[1].status).toMatchObject({
kind: "streaming",
phase: "finalizing",
statusText,
});
});
// the unlabeled keep-alives before the `message` frame were complete
// no-ops: every "generating, no label" render shares one message object
const unlabeledGenerating = snapshots
.map((messages) => messages[1] as AssistantMessage)
.filter(
(message) =>
message.status.kind === "streaming" &&
message.status.phase === "generating" &&
!message.status.statusText,
);
expect(unlabeledGenerating.length).toBeGreaterThan(0);
expect(new Set(unlabeledGenerating).size).toBe(1);
// startedAt never resets mid-stream — the elapsed ticker depends on it
expect(
streamingStatusOf(snapshots.at(-1)![1] as AssistantMessage).startedAt,
).toBe(startedAt);
contentGate.resolve();
// a renderable chunk is a real change: back to the unlabeled generating
// phase, with the partial skeletons on the message
await waitFor(() => {
const messages = JSON.parse(screen.getByTestId("messages").textContent!);
expect(messages[1].skeletons).toEqual([skeleton]);
expect(messages[1].status).toMatchObject({
kind: "streaming",
phase: "generating",
});
expect(messages[1].status.statusText).toBeUndefined();
});
doneGate.resolve();
await waitFor(() => {
const messages = JSON.parse(screen.getByTestId("messages").textContent!);
expect(messages[1].status).toMatchObject({
kind: "done",
outcome: "generated",
});
});
});
it("keeps streamed partial skeletons on the message when the stream is interrupted", async () => {
const onRateLimitInfo = vi.fn();
const skeleton = { type: "rectangle", x: 0, y: 0, width: 10, height: 10 };
const streamFetch = vi.fn().mockImplementation(async (options) => {
// Deliberately no onStarted: the canvas draft is keyed by the LOCAL
// generation id, so the chunk renders even before `started` arrives.
options.onChunk?.({ skeletons: [skeleton], isComplete: false });
return {
error: {
code: 1002,
message: "Connection interrupted before the response completed",
lifecycleStatus: "failed",
},
};
});
render(
<TestHarness
onRateLimitInfo={onRateLimitInfo}
streamFetch={streamFetch}
/>,
);
await waitFor(() => {
const messages = JSON.parse(screen.getByTestId("messages").textContent!);
expect(messages[1].status.kind).toBe("error");
});
const messages = JSON.parse(screen.getByTestId("messages").textContent!);
expect(messages).toHaveLength(2);
expect(messages[1]).toMatchObject({
id: "assistant-1",
role: "assistant",
skeletons: [skeleton],
status: {
kind: "error",
elapsedMs: expect.any(Number),
error: { code: 1002 },
},
});
});
});
@@ -1,438 +0,0 @@
import { useCallback, useEffect, useRef } from "react";
import { randomId } from "@excalidraw/common";
import {
AI_ERRORS,
type AIGenerateRequestPayload,
type AIStreamProgressPhase,
type AssistantMessage,
type AssistantStatus,
type ChatMessage,
} from "./types";
import { getAIChatErrorCode, withAIChatErrorMeta } from "./chatErrors";
import type { Dispatch, SetStateAction } from "react";
import type { CanvasDraft } from "./useCanvasDraft";
import type { TTATransportAdapter } from "./client";
const isAbortError = (error: unknown) =>
error instanceof Error && error.name === "AbortError";
const isHandledStreamErrorCode = (errorCode?: number) =>
errorCode === AI_ERRORS.SERVER_ERROR.code ||
errorCode === AI_ERRORS.RATE_LIMIT.code ||
errorCode === AI_ERRORS.REQUEST_ERROR.code ||
errorCode === AI_ERRORS.GENERATION_ERROR.code;
const assistantMessageHasError = (
messages: ChatMessage[],
assistantId: string,
) => {
const message = messages.find((candidate) => candidate.id === assistantId);
return message?.role === "assistant" && message.status.kind === "error";
};
type UseAIStreamingLifecycleOptions = {
chatMessages: ChatMessage[];
setChatMessages: Dispatch<SetStateAction<ChatMessage[]>>;
applyServerChatMetadata: (metadata: {
chatId?: string | null;
updatedAt?: number | null;
}) => void;
canvasDraft: CanvasDraft;
streamFetch: TTATransportAdapter["stream"];
onRateLimitInfo?: (rateLimitInfo: {
rateLimit?: number | null;
rateLimitRemaining?: number | null;
}) => void;
};
const isExhaustedRateLimit = (rateLimitRemaining?: number | null) =>
typeof rateLimitRemaining === "number" &&
Number.isFinite(rateLimitRemaining) &&
rateLimitRemaining === 0;
const STREAM_IDLE_STATUS_DELAY = 5000;
const getElapsedMs = (startedAt: number) => Math.max(0, Date.now() - startedAt);
/**
* A streaming status the user can currently read: free server text
* (`message` frames) or an idle-timer phase with a built-in label.
* `starting`/`waiting`/`generating` render spinner-only.
*/
const isLabeledStreamingStatus = (
status: Extract<AssistantStatus, { kind: "streaming" }>,
) =>
Boolean(status.statusText) ||
status.phase === "thinking" ||
status.phase === "finalizing";
export const useAIStreamingLifecycle = ({
chatMessages,
setChatMessages,
applyServerChatMetadata,
canvasDraft,
streamFetch,
onRateLimitInfo,
}: UseAIStreamingLifecycleOptions) => {
const activeStreamAbortControllerRef = useRef<AbortController | null>(null);
const stopRequestedRef = useRef(false);
const chatMessagesRef = useRef(chatMessages);
chatMessagesRef.current = chatMessages;
const patchAssistantMessage = useCallback(
(assistantId: string, patch: Partial<AssistantMessage>) => {
setChatMessages((prev) =>
prev.map((message) =>
message.id === assistantId && message.role === "assistant"
? { ...message, ...patch }
: message,
),
);
},
[setChatMessages],
);
const appendRateLimitWarningMessage = useCallback(() => {
setChatMessages((prev) => {
const lastMessage = prev.at(-1);
// dedupe on repeat — the rate-limit numbers live in the atom, so a
// second exhausted response has nothing new to say
if (
lastMessage?.role === "system" &&
lastMessage.variant === "messageLimitExceeded"
) {
return prev;
}
return [
...prev,
{
id: `system-rate-limit-${randomId()}`,
role: "system",
createdAt: Date.now(),
variant: "messageLimitExceeded",
},
];
});
}, [setChatMessages]);
const generateResponse = useCallback(
async (assistantId: string, payload: AIGenerateRequestPayload) => {
let activeTurnId: string | null = null;
let activeMessageId: string | null = null;
let idleStatusTimeout: ReturnType<typeof setTimeout> | null = null;
let hasReceivedRenderableChunk = false;
const generationStartedAt = Date.now();
/**
* Streaming-status patch. The union model replaces the whole `status`
* object per stream event, so this guards the replacement (the pre-union
* field patches were just as blind, but the server's empty-partial
* keep-alives made the visible label flash in and out — the regression
* this closes):
* - a no-op patch (same phase + statusText) keeps the previous status
* object instead of minting an identical one;
* - `keepLabel` (keep-alive chunks with no new content) never downgrades
* a labeled state (server statusText / idle thinking-finalizing) to an
* unlabeled phase — the label persists until the status genuinely
* changes (next label, real content, or a terminal state);
* - `startedAt` carries over from the current streaming status so the
* elapsed ticker never resets mid-stream.
*/
const patchStreamingStatus = (
phase: AIStreamProgressPhase,
options?: {
statusText?: string;
keepLabel?: boolean;
patch?: Partial<Omit<AssistantMessage, "status">>;
},
) => {
setChatMessages((prev) =>
prev.map((message) => {
if (message.id !== assistantId || message.role !== "assistant") {
return message;
}
const current =
message.status.kind === "streaming" ? message.status : null;
const nextStatusText = options?.statusText || undefined;
const isNoOpStatus =
current !== null &&
current.phase === phase &&
(current.statusText || undefined) === nextStatusText;
const keepCurrentStatus =
isNoOpStatus ||
(options?.keepLabel === true &&
current !== null &&
isLabeledStreamingStatus(current));
if (keepCurrentStatus) {
return options?.patch
? { ...message, ...options.patch }
: message;
}
return {
...message,
...options?.patch,
status: {
kind: "streaming",
phase,
startedAt: current?.startedAt ?? generationStartedAt,
...(nextStatusText ? { statusText: nextStatusText } : {}),
},
};
}),
);
};
const clearIdleStatusTimeout = () => {
if (idleStatusTimeout !== null) {
clearTimeout(idleStatusTimeout);
idleStatusTimeout = null;
}
};
const scheduleIdleStatus = () => {
clearIdleStatusTimeout();
idleStatusTimeout = setTimeout(() => {
if (stopRequestedRef.current) {
return;
}
patchStreamingStatus(
hasReceivedRenderableChunk ? "finalizing" : "thinking",
);
}, STREAM_IDLE_STATUS_DELAY);
};
const abortController = new AbortController();
try {
activeStreamAbortControllerRef.current = abortController;
// A canceled predecessor skips its ownership-checked cleanup (see the
// `finally` below), so start from a clean throttle state here.
canvasDraft.cancelPendingRenders();
if (stopRequestedRef.current) {
abortController.abort();
}
// Full replace on purpose: each attempt's stream starts the elapsed
// ticker from its own `generationStartedAt` (the documented startedAt
// reset on retry — types.ts), and wipes any seeded "Retrying..." /
// "Regenerating..." statusText after its MIN_RETRYING_VISIBLE_MS
// window. Every later patch preserves this baseline.
patchAssistantMessage(assistantId, {
status: {
kind: "streaming",
phase: "starting",
startedAt: generationStartedAt,
},
});
const { finalPayload, error, rateLimit, rateLimitRemaining } =
await streamFetch({
payload,
signal: abortController.signal,
onStreamCreated: () => {
patchStreamingStatus("waiting");
scheduleIdleStatus();
},
onStarted: (startedPayload) => {
activeTurnId = startedPayload.turnId;
activeMessageId = startedPayload.messageId;
applyServerChatMetadata(startedPayload);
patchStreamingStatus("generating", {
patch: {
server: {
turnId: startedPayload.turnId,
messageId: startedPayload.messageId,
},
},
});
scheduleIdleStatus();
},
onMessage: (messagePayload) => {
patchStreamingStatus("finalizing", {
statusText: messagePayload.message,
});
scheduleIdleStatus();
},
onChunk: (partialPayload) => {
if (stopRequestedRef.current) {
return;
}
scheduleIdleStatus();
if (!partialPayload.skeletons.length) {
// Keep-alive frame — no content progressed, so it must not
// wipe a visible label (the server emits these continuously
// while the model thinks or its internal retry backs off).
patchStreamingStatus("generating", { keepLabel: true });
return;
}
hasReceivedRenderableChunk = true;
patchStreamingStatus("generating", {
patch: { skeletons: partialPayload.skeletons },
});
// The draft is keyed by the local generation id (known
// synchronously), so chunks render regardless of whether
// `started` arrived yet. Always render partials as non-final:
// the server marks its last partial `isComplete: true`, but the
// authoritative final render (IMMEDIATELY capture + selection)
// happens once on `done` — honoring it here would commit the
// draft twice.
canvasDraft.applyChunk(
{ skeletons: partialPayload.skeletons, isComplete: false },
assistantId,
);
},
});
onRateLimitInfo?.({ rateLimit, rateLimitRemaining });
if (stopRequestedRef.current || abortController.signal.aborted) {
return;
}
if (error) {
patchAssistantMessage(assistantId, {
status: {
kind: "error",
elapsedMs: getElapsedMs(generationStartedAt),
error: { code: error.code, message: error.message },
},
});
// On-error canvas policy (tta_rewrite_final.md §2.2): treat a failed
// stream like user Stop — commit whatever draft rendered so the chat
// bubble, thumbnail, and canvas agree. If nothing rendered this is a
// no-op and the previous generation stays visible (its queued
// replacement tag survives, so the next successful generation still
// replaces it).
canvasDraft.commitDraft();
return;
}
if (!finalPayload) {
return;
}
const finalTurnId = finalPayload.turnId ?? activeTurnId;
const finalMessageId = finalPayload.messageId ?? activeMessageId;
activeTurnId = finalTurnId;
activeMessageId = finalMessageId;
applyServerChatMetadata({
chatId: finalPayload.chatId,
updatedAt: finalPayload.updatedAt,
});
// A `done` carrying finishReason "length"/"content_filter" parsed, but
// the generation was truncated/blocked — surfaced as a warning on an
// otherwise successful status (M10); partials are kept.
const warning =
finalPayload.finishReason === "length" ||
finalPayload.finishReason === "content_filter"
? finalPayload.finishReason
: undefined;
patchAssistantMessage(assistantId, {
status: {
kind: "done",
elapsedMs: getElapsedMs(generationStartedAt),
outcome: finalPayload.skeletons.length ? "generated" : "empty",
...(warning ? { warning } : {}),
},
skeletons: finalPayload.skeletons,
...(finalTurnId && finalMessageId
? { server: { turnId: finalTurnId, messageId: finalMessageId } }
: {}),
// the successful attempt becomes the turn's retry target (N1)
lastCompletedMessageId: finalMessageId ?? undefined,
});
canvasDraft.applyFinal(finalPayload, assistantId);
if (isExhaustedRateLimit(rateLimitRemaining)) {
appendRateLimitWarningMessage();
}
} catch (error: unknown) {
canvasDraft.cancelPendingRenders();
if (stopRequestedRef.current || isAbortError(error)) {
return;
}
const errorMessage =
error instanceof Error ? error.message : String(error);
const errorCode = getAIChatErrorCode(error);
if (isHandledStreamErrorCode(errorCode)) {
return;
}
if (assistantMessageHasError(chatMessagesRef.current, assistantId)) {
return;
}
// Same on-error canvas policy as the transport-error branch above:
// commit the rendered draft instead of wiping it. Even when the final
// render threw mid-insert (INVALID_RESULT) after tombstoning the
// preview frame, the commit resurrects those elements as committed.
canvasDraft.commitDraft();
patchAssistantMessage(assistantId, {
status: {
kind: "error",
elapsedMs: getElapsedMs(generationStartedAt),
error: { code: errorCode, message: errorMessage },
},
});
throw withAIChatErrorMeta(new Error(errorMessage), {
handled: true,
});
} finally {
clearIdleStatusTimeout();
// Ownership-checked cleanup: when this stream was canceled and a
// successor already took over (retry's cancel-and-replace), the ref
// holds the successor's controller (or null) — tearing down the
// shared throttle/stop state here would clobber the live stream.
if (activeStreamAbortControllerRef.current === abortController) {
canvasDraft.cancelPendingRenders();
activeStreamAbortControllerRef.current = null;
stopRequestedRef.current = false;
}
}
},
[
applyServerChatMetadata,
appendRateLimitWarningMessage,
canvasDraft,
onRateLimitInfo,
patchAssistantMessage,
setChatMessages,
streamFetch,
],
);
const cancelActiveStream = useCallback(() => {
if (activeStreamAbortControllerRef.current) {
activeStreamAbortControllerRef.current.abort();
activeStreamAbortControllerRef.current = null;
}
}, []);
const setStopRequested = useCallback((value: boolean) => {
stopRequestedRef.current = value;
}, []);
useEffect(() => {
return () => {
cancelActiveStream();
};
}, [cancelActiveStream]);
return {
cancelActiveStream,
setStopRequested,
generateResponse,
};
};
@@ -1,488 +0,0 @@
import React from "react";
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElementSkeleton } from "@excalidraw/element";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import { useCanvasDraft, type CanvasDraft } from "./useCanvasDraft";
import {
AI_GENERATED_ELEMENTS_KEY,
INTERMEDIATE_PREVIEW_ELEMENT_KEY,
} from "./insertAISkeletons";
import type { AppClassProperties } from "../types";
const THROTTLE_DELAY = 300;
const createMockApp = (initialElements: ExcalidrawElement[] = []) => {
let elements = [...initialElements];
const updateScene = vi.fn(
({
elements: nextElements,
}: {
elements: ExcalidrawElement[];
captureUpdate?: unknown;
}) => {
elements = nextElements;
},
);
const syncActionResult = vi.fn(
({
elements: nextElements,
}: {
elements: ExcalidrawElement[];
captureUpdate?: unknown;
}) => {
elements = nextElements;
},
);
const app = {
api: {
updateScene,
},
state: {
width: 1000,
height: 1000,
scrollX: 0,
scrollY: 0,
offsetLeft: 0,
offsetTop: 0,
zoom: { value: 1 },
selectedElementIds: {},
} as AppClassProperties["state"],
scene: {
getNonDeletedElements: () =>
elements.filter(
(element): element is NonDeletedExcalidrawElement =>
!element.isDeleted,
),
getElementsIncludingDeleted: () => elements,
} as unknown as AppClassProperties["scene"],
syncActionResult,
} as unknown as AppClassProperties;
return {
app,
getElements: () => elements,
syncActionResult,
updateScene,
};
};
const createCommittedGenerationElement = (
id: string,
generationTag: string,
): ExcalidrawElement =>
({
id,
type: "rectangle",
x: 0,
y: 0,
width: 10,
height: 10,
isDeleted: false,
version: 1,
versionNonce: 1,
updated: 1,
customData: { [AI_GENERATED_ELEMENTS_KEY]: generationTag },
} as unknown as ExcalidrawElement);
const renderCanvasDraft = (app: AppClassProperties) => {
const result = { current: null as unknown as CanvasDraft };
const Harness = () => {
result.current = useCanvasDraft({ app });
return null;
};
render(<Harness />);
return result;
};
const rectSkeleton = (id: string): ExcalidrawElementSkeleton => ({
type: "rectangle",
id,
x: 0,
y: 0,
width: 10,
height: 10,
});
const ellipseSkeleton = (id: string): ExcalidrawElementSkeleton => ({
type: "ellipse",
id,
x: 20,
y: 20,
width: 30,
height: 30,
});
describe("useCanvasDraft", () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.useRealTimers();
});
it("tombstones replaced preview elements and bumps same-id replacements", () => {
vi.useFakeTimers();
const setup = createMockApp();
const draft = renderCanvasDraft(setup.app);
draft.current.applyChunk(
{ skeletons: [rectSkeleton("preview-id")], isComplete: false },
"gen-1",
);
draft.current.applyChunk(
{ skeletons: [ellipseSkeleton("preview-id")], isComplete: false },
"gen-1",
);
vi.advanceTimersByTime(THROTTLE_DELAY);
expect(setup.syncActionResult).toHaveBeenCalledTimes(2);
const firstSyncedElements = setup.syncActionResult.mock.calls[0][0]
.elements as ExcalidrawElement[];
const firstPreviewElement = firstSyncedElements.find(
(element) => element.id === "preview-id",
)!;
expect(
firstPreviewElement.customData?.[INTERMEDIATE_PREVIEW_ELEMENT_KEY],
).toBe(true);
expect(firstPreviewElement.customData?.[AI_GENERATED_ELEMENTS_KEY]).toBe(
"gen-1",
);
expect(setup.updateScene).toHaveBeenCalledTimes(1);
const removedPreviewElements = setup.updateScene.mock.calls[0][0]
.elements as ExcalidrawElement[];
const removedPreviewElement = removedPreviewElements.find(
(element) => element.id === "preview-id",
)!;
expect(removedPreviewElement.isDeleted).toBe(true);
expect(
removedPreviewElement.customData?.[INTERMEDIATE_PREVIEW_ELEMENT_KEY],
).toBe(true);
expect(removedPreviewElement.version).toBeGreaterThan(
firstPreviewElement.version,
);
const secondSyncedElements = setup.syncActionResult.mock.calls[1][0]
.elements as ExcalidrawElement[];
const secondPreviewElement = secondSyncedElements.find(
(element) => element.id === "preview-id",
)!;
expect(secondPreviewElement).toMatchObject({
id: "preview-id",
type: "ellipse",
isDeleted: false,
customData: expect.objectContaining({
[INTERMEDIATE_PREVIEW_ELEMENT_KEY]: true,
}),
});
expect(secondPreviewElement.version).toBeGreaterThan(
removedPreviewElement.version,
);
// single instance per element id
expect(
setup.getElements().filter((element) => element.id === "preview-id"),
).toHaveLength(1);
});
it("renders a chunk parked inside the throttle window via the trailing edge (M1)", () => {
vi.useFakeTimers();
const setup = createMockApp();
const draft = renderCanvasDraft(setup.app);
draft.current.applyChunk(
{ skeletons: [rectSkeleton("preview-id")], isComplete: false },
"gen-1",
);
// leading edge rendered immediately
expect(setup.syncActionResult).toHaveBeenCalledTimes(1);
draft.current.applyChunk(
{ skeletons: [ellipseSkeleton("preview-id")], isComplete: false },
"gen-1",
);
// parked inside the window — no render yet
expect(setup.syncActionResult).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(THROTTLE_DELAY - 1);
expect(setup.syncActionResult).toHaveBeenCalledTimes(1);
// trailing edge fires WITHOUT a subsequent chunk
vi.advanceTimersByTime(1);
expect(setup.syncActionResult).toHaveBeenCalledTimes(2);
const trailingElements = setup.syncActionResult.mock.calls[1][0]
.elements as ExcalidrawElement[];
expect(
trailingElements.find((element) => element.id === "preview-id"),
).toMatchObject({ type: "ellipse", isDeleted: false });
});
it("commits the visible preview as regular elements", () => {
const setup = createMockApp();
const draft = renderCanvasDraft(setup.app);
draft.current.applyChunk(
{ skeletons: [rectSkeleton("preview-id")], isComplete: false },
"gen-1",
);
draft.current.commitDraft();
expect(setup.syncActionResult).toHaveBeenCalledTimes(2);
const insertedPreviewElement = setup.syncActionResult.mock.calls[0][0]
.elements[0] as ExcalidrawElement;
expect(
insertedPreviewElement.customData?.[INTERMEDIATE_PREVIEW_ELEMENT_KEY],
).toBe(true);
// pinned ordering: the updateScene-NEVER tombstone happens BEFORE the
// syncActionResult-IMMEDIATELY commit
expect(setup.updateScene).toHaveBeenCalledTimes(1);
expect(setup.updateScene.mock.calls[0][0].captureUpdate).toBe(
CaptureUpdateAction.NEVER,
);
const tombstonedPreviewElement = setup.updateScene.mock.calls[0][0]
.elements[0] as ExcalidrawElement;
expect(tombstonedPreviewElement).toMatchObject({
id: "preview-id",
isDeleted: true,
});
expect(
tombstonedPreviewElement.customData?.[INTERMEDIATE_PREVIEW_ELEMENT_KEY],
).toBe(true);
const commitCall = setup.syncActionResult.mock.calls[1][0];
expect(commitCall.captureUpdate).toBe(CaptureUpdateAction.IMMEDIATELY);
expect(setup.updateScene.mock.invocationCallOrder[0]).toBeLessThan(
setup.syncActionResult.mock.invocationCallOrder[1],
);
const committedPreviewElement = commitCall.elements[0] as ExcalidrawElement;
expect(committedPreviewElement).toMatchObject({
id: "preview-id",
isDeleted: false,
});
// intermediate flag stripped on commit
expect(
committedPreviewElement.customData?.[INTERMEDIATE_PREVIEW_ELEMENT_KEY],
).toBeUndefined();
expect(
committedPreviewElement.customData?.[AI_GENERATED_ELEMENTS_KEY],
).toBe("gen-1");
expect(committedPreviewElement.version).toBeGreaterThan(
tombstonedPreviewElement.version,
);
expect(setup.getElements()).toEqual([committedPreviewElement]);
});
it("replaces the preview with the same-id final result", () => {
const setup = createMockApp();
const draft = renderCanvasDraft(setup.app);
draft.current.applyChunk(
{ skeletons: [rectSkeleton("stable-preview-id")], isComplete: false },
"gen-1",
);
draft.current.applyFinal(
{ skeletons: [ellipseSkeleton("stable-preview-id")], isComplete: true },
"gen-1",
);
expect(setup.updateScene).toHaveBeenCalledTimes(1);
const tombstonedPreviewElement = setup.updateScene.mock.calls[0][0]
.elements[0] as ExcalidrawElement;
expect(tombstonedPreviewElement).toMatchObject({
id: "stable-preview-id",
isDeleted: true,
});
expect(setup.syncActionResult).toHaveBeenCalledTimes(2);
const finalSync = setup.syncActionResult.mock.calls[1][0];
expect(finalSync.captureUpdate).toBe(CaptureUpdateAction.IMMEDIATELY);
const finalElements = finalSync.elements as ExcalidrawElement[];
expect(finalElements).toHaveLength(1);
const finalElement = finalElements[0];
expect(finalElement).toMatchObject({
id: "stable-preview-id",
type: "ellipse",
isDeleted: false,
});
expect(
finalElement.customData?.[INTERMEDIATE_PREVIEW_ELEMENT_KEY],
).toBeUndefined();
expect(finalElement.version).toBeGreaterThan(
tombstonedPreviewElement.version,
);
expect(setup.getElements()).toEqual([finalElement]);
});
it("flushes a parked chunk before rendering the final result", () => {
vi.useFakeTimers();
const setup = createMockApp();
const draft = renderCanvasDraft(setup.app);
draft.current.applyChunk(
{ skeletons: [rectSkeleton("preview-id")], isComplete: false },
"gen-1",
);
draft.current.applyChunk(
{ skeletons: [rectSkeleton("late-preview-id")], isComplete: false },
"gen-1",
);
draft.current.applyFinal(
{ skeletons: [ellipseSkeleton("final-id")], isComplete: true },
"gen-1",
);
// leading chunk + flushed parked chunk + final
expect(setup.syncActionResult).toHaveBeenCalledTimes(3);
const finalElements = setup.syncActionResult.mock.calls[2][0]
.elements as ExcalidrawElement[];
expect(
finalElements.filter((element) => !element.isDeleted).map((el) => el.id),
).toEqual(["final-id"]);
// nothing parked anymore — no trailing render fires later
vi.advanceTimersByTime(THROTTLE_DELAY * 2);
expect(setup.syncActionResult).toHaveBeenCalledTimes(3);
});
it("keeps a queued previous generation visible until the next one renders, then replaces it", () => {
const setup = createMockApp([
createCommittedGenerationElement("prev-el", "gen-1"),
]);
const draft = renderCanvasDraft(setup.app);
draft.current.queueReplacement("gen-1");
// queueing alone must not touch the canvas
expect(setup.getElements()[0].isDeleted).toBe(false);
draft.current.applyChunk(
{ skeletons: [rectSkeleton("new-el")], isComplete: false },
"gen-2",
);
// the previous generation was removed as a captured (IMMEDIATELY) update
const replacementCall = setup.syncActionResult.mock.calls.find((call) =>
(call[0].elements as ExcalidrawElement[]).some(
(element) => element.id === "prev-el" && element.isDeleted,
),
);
expect(replacementCall?.[0].captureUpdate).toBe(
CaptureUpdateAction.IMMEDIATELY,
);
const elements = setup.getElements();
expect(
elements.find((element) => element.id === "prev-el")?.isDeleted,
).toBe(true);
expect(elements.find((element) => element.id === "new-el")?.isDeleted).toBe(
false,
);
});
it("keeps the queue across a stop/error that never rendered", () => {
const setup = createMockApp([
createCommittedGenerationElement("prev-el", "gen-1"),
]);
const draft = renderCanvasDraft(setup.app);
draft.current.queueReplacement("gen-1");
// stop/error before the new generation rendered anything: the commit is a
// no-op and the queued tag must survive
draft.current.commitDraft();
expect(setup.getElements()[0].isDeleted).toBe(false);
draft.current.applyChunk(
{ skeletons: [rectSkeleton("new-el")], isComplete: false },
"gen-2",
);
expect(
setup.getElements().find((element) => element.id === "prev-el")
?.isDeleted,
).toBe(true);
});
it("replaces a failed attempt's committed partial on error-retry with the same generation id", () => {
// an error-retry reuses the local generation id, so the queued tag equals
// the retrying generation's own id — its old elements must still be
// replaced once the new attempt renders
const setup = createMockApp([
createCommittedGenerationElement("old-el", "gen-1"),
]);
const draft = renderCanvasDraft(setup.app);
draft.current.queueReplacement("gen-1");
draft.current.applyChunk(
{ skeletons: [rectSkeleton("new-el")], isComplete: false },
"gen-1",
);
const elements = setup.getElements();
expect(elements.find((element) => element.id === "old-el")?.isDeleted).toBe(
true,
);
expect(elements.find((element) => element.id === "new-el")?.isDeleted).toBe(
false,
);
});
it("drops the queue on reset", () => {
const setup = createMockApp([
createCommittedGenerationElement("prev-el", "gen-1"),
]);
const draft = renderCanvasDraft(setup.app);
draft.current.queueReplacement("gen-1");
draft.current.reset();
draft.current.applyChunk(
{ skeletons: [rectSkeleton("new-el")], isComplete: false },
"gen-2",
);
// the queued tag was dropped — the previous generation stays
expect(
setup.getElements().find((element) => element.id === "prev-el")
?.isDeleted,
).toBe(false);
});
it("clearDraft removes the active draft from the canvas without committing", () => {
const setup = createMockApp();
const draft = renderCanvasDraft(setup.app);
draft.current.applyChunk(
{ skeletons: [rectSkeleton("preview-id")], isComplete: false },
"gen-1",
);
draft.current.clearDraft();
const previewElement = setup
.getElements()
.find((element) => element.id === "preview-id");
expect(previewElement?.isDeleted).toBe(true);
// teardown is uncaptured — no IMMEDIATELY update was issued
expect(
setup.syncActionResult.mock.calls.some(
(call) => call[0].captureUpdate === CaptureUpdateAction.IMMEDIATELY,
),
).toBe(false);
expect(
setup.updateScene.mock.calls.every(
(call) => call[0].captureUpdate === CaptureUpdateAction.NEVER,
),
).toBe(true);
});
});

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