Commit Graph
4086 Commits
Author SHA1 Message Date
David Luzar a918648012 feat(packages/excalidraw): add renderOverrides to allow overriding of element rendering (#12100)
* feat(packages/excalidraw): add renderOverrides to allow overriding rendering of elements

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

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

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

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

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

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

Fable 5.1, medium reasoning

* refactor(renderer): drop two override edge cases

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

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

Fable 5.1, medium reasoning

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

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

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

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

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

Claude Fable 5.1, high effort

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

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

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

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

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

Claude Fable 5.1, high effort

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

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

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

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

Claude Fable 5.1, high effort

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

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

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

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

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

Claude Fable 5.1, high effort

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

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

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

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

Claude Fable 5.1, high effort

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

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

    Claude Fable 5.1, high effort

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

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

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

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

Claude Fable 5.1, high effort

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

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

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

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

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

* clamp sticky note font size

* [dragInput] reject non-finite values

* chore: prettier formatting after rebase

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Plan: sticky_notes_review_final.md §9.

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

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

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

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

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

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

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

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

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

Plan: sticky_notes_review_final.md §12.

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

* make footer opacity=1

* chore: fix sticky note test import order

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(editor): slightly strengthen sticky note edges

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

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

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

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

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

* fix changelog

* fix snaps

* more snap

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two review findings.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

[ci skip]

* New translations en.json (Persian)

[ci skip]

* New translations en.json (Swedish)

[ci skip]

* New translations en.json (Slovenian)

[ci skip]

* New translations en.json (Slovenian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Thai)

[ci skip]

* Auto commit: Calculate translation coverage

---------

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

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

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

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

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

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

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

* add missed places

* tweak jsdoc

---------

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

* render status spinner while streaming

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

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (French)

[ci skip]

* New translations en.json (Spanish)

[ci skip]

* New translations en.json (Arabic)

[ci skip]

* New translations en.json (Bulgarian)

[ci skip]

* New translations en.json (Catalan)

[ci skip]

* New translations en.json (Czech)

[ci skip]

* New translations en.json (Danish)

[ci skip]

* New translations en.json (German)

[ci skip]

* New translations en.json (Greek)

[ci skip]

* New translations en.json (Basque)

[ci skip]

* New translations en.json (Finnish)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Hungarian)

[ci skip]

* New translations en.json (Italian)

[ci skip]

* New translations en.json (Japanese)

[ci skip]

* New translations en.json (Korean)

[ci skip]

* New translations en.json (Kurdish)

[ci skip]

* New translations en.json (Lithuanian)

[ci skip]

* New translations en.json (Dutch)

[ci skip]

* New translations en.json (Punjabi)

[ci skip]

* New translations en.json (Polish)

[ci skip]

* New translations en.json (Portuguese)

[ci skip]

* New translations en.json (Russian)

[ci skip]

* New translations en.json (Slovak)

[ci skip]

* New translations en.json (Slovenian)

[ci skip]

* New translations en.json (Swedish)

[ci skip]

* New translations en.json (Turkish)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]

* New translations en.json (Chinese Traditional)

[ci skip]

* New translations en.json (Vietnamese)

[ci skip]

* New translations en.json (Galician)

[ci skip]

* New translations en.json (Portuguese, Brazilian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Persian)

[ci skip]

* New translations en.json (Khmer)

[ci skip]

* New translations en.json (Tamil)

[ci skip]

* New translations en.json (Bengali)

[ci skip]

* New translations en.json (Marathi)

[ci skip]

* New translations en.json (Thai)

[ci skip]

* New translations en.json (Norwegian Nynorsk)

[ci skip]

* New translations en.json (Kazakh)

[ci skip]

* New translations en.json (Latvian)

[ci skip]

* New translations en.json (Azerbaijani)

[ci skip]

* New translations en.json (Hindi)

[ci skip]

* New translations en.json (Burmese)

[ci skip]

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

[ci skip]

* New translations en.json (Sinhala)

[ci skip]

* New translations en.json (Uzbek)

[ci skip]

* New translations en.json (Norwegian Bokmal)

[ci skip]

* New translations en.json (Occitan)

[ci skip]

* New translations en.json (German, Switzerland)

[ci skip]

* New translations en.json (Bengali, India)

[ci skip]

* New translations en.json (Kabyle)

[ci skip]

* New translations en.json (Karakalpak)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (French)

[ci skip]

* New translations en.json (Spanish)

[ci skip]

* New translations en.json (Arabic)

[ci skip]

* New translations en.json (Bulgarian)

[ci skip]

* New translations en.json (Catalan)

[ci skip]

* New translations en.json (Czech)

[ci skip]

* New translations en.json (Danish)

[ci skip]

* New translations en.json (German)

[ci skip]

* New translations en.json (Greek)

[ci skip]

* New translations en.json (Basque)

[ci skip]

* New translations en.json (Finnish)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Hungarian)

[ci skip]

* New translations en.json (Italian)

[ci skip]

* New translations en.json (Japanese)

[ci skip]

* New translations en.json (Korean)

[ci skip]

* New translations en.json (Kurdish)

[ci skip]

* New translations en.json (Lithuanian)

[ci skip]

* New translations en.json (Dutch)

[ci skip]

* New translations en.json (Punjabi)

[ci skip]

* New translations en.json (Polish)

[ci skip]

* New translations en.json (Portuguese)

[ci skip]

* New translations en.json (Russian)

[ci skip]

* New translations en.json (Slovak)

[ci skip]

* New translations en.json (Slovenian)

[ci skip]

* New translations en.json (Swedish)

[ci skip]

* New translations en.json (Turkish)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]

* New translations en.json (Chinese Traditional)

[ci skip]

* New translations en.json (Vietnamese)

[ci skip]

* New translations en.json (Galician)

[ci skip]

* New translations en.json (Portuguese, Brazilian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Persian)

[ci skip]

* New translations en.json (Khmer)

[ci skip]

* New translations en.json (Tamil)

[ci skip]

* New translations en.json (Bengali)

[ci skip]

* New translations en.json (Marathi)

[ci skip]

* New translations en.json (Thai)

[ci skip]

* New translations en.json (Norwegian Nynorsk)

[ci skip]

* New translations en.json (Kazakh)

[ci skip]

* New translations en.json (Latvian)

[ci skip]

* New translations en.json (Azerbaijani)

[ci skip]

* New translations en.json (Hindi)

[ci skip]

* New translations en.json (Burmese)

[ci skip]

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

[ci skip]

* New translations en.json (Sinhala)

[ci skip]

* New translations en.json (Uzbek)

[ci skip]

* New translations en.json (Norwegian Bokmal)

[ci skip]

* New translations en.json (Occitan)

[ci skip]

* New translations en.json (German, Switzerland)

[ci skip]

* New translations en.json (Bengali, India)

[ci skip]

* New translations en.json (Kabyle)

[ci skip]

* New translations en.json (Karakalpak)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (French)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (French)

[ci skip]

* New translations en.json (Spanish)

[ci skip]

* New translations en.json (Arabic)

[ci skip]

* New translations en.json (Bulgarian)

[ci skip]

* New translations en.json (Catalan)

[ci skip]

* New translations en.json (Czech)

[ci skip]

* New translations en.json (Danish)

[ci skip]

* New translations en.json (German)

[ci skip]

* New translations en.json (Greek)

[ci skip]

* New translations en.json (Basque)

[ci skip]

* New translations en.json (Finnish)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Hungarian)

[ci skip]

* New translations en.json (Italian)

[ci skip]

* New translations en.json (Japanese)

[ci skip]

* New translations en.json (Korean)

[ci skip]

* New translations en.json (Kurdish)

[ci skip]

* New translations en.json (Lithuanian)

[ci skip]

* New translations en.json (Dutch)

[ci skip]

* New translations en.json (Punjabi)

[ci skip]

* New translations en.json (Polish)

[ci skip]

* New translations en.json (Portuguese)

[ci skip]

* New translations en.json (Russian)

[ci skip]

* New translations en.json (Slovak)

[ci skip]

* New translations en.json (Slovenian)

[ci skip]

* New translations en.json (Swedish)

[ci skip]

* New translations en.json (Turkish)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]

* New translations en.json (Chinese Traditional)

[ci skip]

* New translations en.json (Vietnamese)

[ci skip]

* New translations en.json (Galician)

[ci skip]

* New translations en.json (Portuguese, Brazilian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Persian)

[ci skip]

* New translations en.json (Khmer)

[ci skip]

* New translations en.json (Tamil)

[ci skip]

* New translations en.json (Bengali)

[ci skip]

* New translations en.json (Marathi)

[ci skip]

* New translations en.json (Thai)

[ci skip]

* New translations en.json (Norwegian Nynorsk)

[ci skip]

* New translations en.json (Kazakh)

[ci skip]

* New translations en.json (Latvian)

[ci skip]

* New translations en.json (Azerbaijani)

[ci skip]

* New translations en.json (Hindi)

[ci skip]

* New translations en.json (Burmese)

[ci skip]

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

[ci skip]

* New translations en.json (Sinhala)

[ci skip]

* New translations en.json (Uzbek)

[ci skip]

* New translations en.json (Norwegian Bokmal)

[ci skip]

* New translations en.json (Occitan)

[ci skip]

* New translations en.json (German, Switzerland)

[ci skip]

* New translations en.json (Bengali, India)

[ci skip]

* New translations en.json (Kabyle)

[ci skip]

* New translations en.json (Karakalpak)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (Italian)

[ci skip]

* New translations en.json (Chinese Traditional)

[ci skip]

* New translations en.json (Japanese)

[ci skip]

* New translations en.json (Japanese)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (French)

[ci skip]

* New translations en.json (Spanish)

[ci skip]

* New translations en.json (Arabic)

[ci skip]

* New translations en.json (Bulgarian)

[ci skip]

* New translations en.json (Catalan)

[ci skip]

* New translations en.json (Czech)

[ci skip]

* New translations en.json (Danish)

[ci skip]

* New translations en.json (German)

[ci skip]

* New translations en.json (Greek)

[ci skip]

* New translations en.json (Basque)

[ci skip]

* New translations en.json (Finnish)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Hungarian)

[ci skip]

* New translations en.json (Italian)

[ci skip]

* New translations en.json (Japanese)

[ci skip]

* New translations en.json (Korean)

[ci skip]

* New translations en.json (Kurdish)

[ci skip]

* New translations en.json (Lithuanian)

[ci skip]

* New translations en.json (Dutch)

[ci skip]

* New translations en.json (Punjabi)

[ci skip]

* New translations en.json (Polish)

[ci skip]

* New translations en.json (Portuguese)

[ci skip]

* New translations en.json (Russian)

[ci skip]

* New translations en.json (Slovak)

[ci skip]

* New translations en.json (Slovenian)

[ci skip]

* New translations en.json (Swedish)

[ci skip]

* New translations en.json (Turkish)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]

* New translations en.json (Chinese Traditional)

[ci skip]

* New translations en.json (Vietnamese)

[ci skip]

* New translations en.json (Galician)

[ci skip]

* New translations en.json (Portuguese, Brazilian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Persian)

[ci skip]

* New translations en.json (Khmer)

[ci skip]

* New translations en.json (Tamil)

[ci skip]

* New translations en.json (Bengali)

[ci skip]

* New translations en.json (Marathi)

[ci skip]

* New translations en.json (Thai)

[ci skip]

* New translations en.json (Norwegian Nynorsk)

[ci skip]

* New translations en.json (Kazakh)

[ci skip]

* New translations en.json (Latvian)

[ci skip]

* New translations en.json (Azerbaijani)

[ci skip]

* New translations en.json (Hindi)

[ci skip]

* New translations en.json (Burmese)

[ci skip]

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

[ci skip]

* New translations en.json (Sinhala)

[ci skip]

* New translations en.json (Uzbek)

[ci skip]

* New translations en.json (Norwegian Bokmal)

[ci skip]

* New translations en.json (Occitan)

[ci skip]

* New translations en.json (German, Switzerland)

[ci skip]

* New translations en.json (Bengali, India)

[ci skip]

* New translations en.json (Kabyle)

[ci skip]

* New translations en.json (Karakalpak)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* Auto commit: Calculate translation coverage

---------

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

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

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

* Segments detection

* Remove aspect ratio

* Refactor

* Fix tests

* Toggle shapeSnap

* Fix linting

* debug: live preview

* Rebase fixes

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

* Bind to elements (no hover background)

* Shape closed

* Fix rad calc + rename segments

* Comments

* Fix binding highlighting + lints

* Rename to convert to shape

* fix: Binding

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

* chore: Refactor spline simplification

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

* chore: Remove unused doBoundsIntersect

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

* fix: Restore type signature

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

* feat: Propose new button shapes

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

* chore: Remove unused types

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

* chore: Icon shapes

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

* fix: Element cloning

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

* fix: Element cloning

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

* fix: Remove unused

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

* feat: Shape settings in recognition mode

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

* chore: Remove debug

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

* feat: Actions

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

* fix: Lint

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

* fix: Arrowheads for freedraw

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

* feat: Draw shape tool instead of freedraw

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

* fix: Test

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

* fix: Lint

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

* fix: Keyboard shortcut visibility

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

* feat: Unique cursor for draw shape

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

* fix: Remove selection

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

* fix: Shape selection

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

* feat: Improve shape recognition

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

* fix: Further refine shape recognition

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

* feat: Preview

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

* feat: Add hint

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

* fix: Refined shape recognition

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

* fix: Less ellipse more rectanguloid

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

* fix: Refined arrow recognition

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

* fix: Refinerecognition

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

* chore: Lower linear threshold

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

* feat: Rectangle head arrow

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

* feat: More arrowhead sizes

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

* fix: Arrow remains an arrow

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

* feat: Draw line between elements converts to arrow

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

* remove pendingDrawShapeElement

* fix: Arrow start point binding

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

* fix: Different arrow endpoint heuristic

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

* fix: Point type

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

* fix: Various small fixes

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

* fix: Avoid recompute

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

* fix: Type issues

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

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

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

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

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

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

* feat: Add test

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

* feat: Breakout from App.tsx

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

* fix: Shortcut is now Shift+X

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

* fix: No selecting arrows for shape recog

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

* fix: Double history

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

* feat: PCA + feature extraction recognition

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

* reduce and normalize min-size gates

* never select newElement when shape recog tool active

* simplify lifecycle & remove double finalize

* make arrows bindings orbit

* funnel though actionFinalize & fix two unrelated cleanup cases

* use crosshair cursor

* show strokeWidth

* switch icons for drawShape & extraTools

* dedupe getBoundsFromPoints

* remove unused helpers

* jsdoc cleanup

* dynamic trail size

* improve rectangle recognition

* disallow linears to deviate too much from a straight line

* put under freedraw tool on tablet/mobile

* tweak label

* rename `drawShape` -> `autoshape`

---------

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

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

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

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

* generalize

---------

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