mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-21 04:04:37 +00:00
* 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