Files
excalidraw/packages
David Luzar f3d99c42e9 fix(editor): pan the canvas to follow the caret instead of shifting the UI (#12175)
* fix(editor): keep the text editor flush with the canvas when a click places the caret in a tall text

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

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

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

Claude Fable 5.1, high effort

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

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

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

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

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

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

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

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

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

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

Claude Fable 5.1, high effort

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

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

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

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

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

Claude Opus 5.5 (1M), xhigh effort

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

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

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

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

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

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

Claude Opus 5.5 (1M), xhigh effort

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

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

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

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

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

Claude Opus 5.5 (1M), xhigh effort

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

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

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

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

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

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

Claude Opus 5.5 (1M), xhigh effort

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

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

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

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

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

Claude Opus 5.5 (1M), xhigh effort

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

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

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

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

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

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

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

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

Claude Opus 5.5 (1M), xhigh effort
2026-09-26 23:00:28 +02:00
..