From 3d056dbc8a552cf4e66a65e60da93e8e950bde27 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:06:41 +0200 Subject: [PATCH] feat(editor): wrap text at 800 and keep it clear of the side panels (#12192) * feat(editor): wrap typed & pasted text at 800, clear of the side panels A free text growing as it's typed stopped only at the view's width (#12175), so on a large screen it ran to 2000+ px before wrapping, and a pasted text wrapped at half the view. Both now follow one rule, in `getMaxTextWidth`: - no wider than TEXT_MAX_WRAP_WIDTH (800, in @excalidraw/common) in scene units, at any zoom: the paste's former cap, now shared. A paste no longer wraps at half the view. - nor wider than the canvas between the side panels, less TEXT_VIEWPORT_PADDING at each side: the docked sidebar, as before; the full styles panel (desktop), which shows next to a text while it's edited, and once it's pasted and selected (pasting with nothing selected, its room is reserved: as last measured, else approximately); and the stats panel, only for a text beside it, i.e. with rows within that padding of it, as it covers only the top of the canvas' side. A text that starts wrapping is brought in between the same panels, as before. A paste is laid out as if the stats panel weren't there, and again, clear of it, only if it lands beside it. As the stats panel grows to show a selection's properties, with it open the paste is rendered selected first (not captured yet, via `flushSync`), so the panels are measured as they'll be; laid out again, it stays the same elements, and one undo step. A wrapped paste is then moved into view (as far as the view would have scrolled), instead of scrolling the view. The stats panel is marked as side viewport UI (`data-viewport-ui="side"`, named "stats"), so the viewport offsets' other users (zoom to fit, scroll to content, search, a host's `setViewport` with UI offsets) keep clear of it too while it's open. `AppViewport` gains `getSideInsets(names, { reserve })`, generalizing `getSidebarInsets`, and `getSideUIRect(name)`; `getOffsets` shares the reserve fallback through `getReservedSide`. Tests: the cap at 50% and 200%; a typed text clear of the styles and the stats panel, and of the stats panel only beside it; a paste wrapping as a typed one, with the styles panel's room reserved; a paste clear of the stats panel only where it lands beside it, and of the stats panel grown to show it, as one undo step; a wrapped paste moved into view, the view not scrolled. Claude Opus 5.5 (1M), xhigh effort * feat(editor): pan the canvas when the caret goes under the stats or styles panel The canvas follows the caret past the view's edge, and behind the docked sidebar, through the editor's box, which the browser scrolls to reveal a caret (#12175). The stats and the full styles panel don't span the box's height, so a caret typed in under them stayed out of sight. After each input, once the update (and any scroll it made) is in the app's state, the editor works out where the caret is: its line from `getWrappedTextLines`, its x from the width of the line before it, rotated with the text. In a line with RTL characters (`hasRTLChars`, new in @excalidraw/common) the x comes from the browser's bidirectional layout of the line instead, in its paragraph's direction (the editor's `dir="auto"` makes it `unicode-bidi: plaintext`: each paragraph goes its first strong character's way): with Hebrew then English, the caret after the English is mid-line, not at the line's edge. That's measured through a range in an off-screen mirror of the line, shared with the click-to-caret lookup. If the caret's under one of those panels, the canvas pans to bring it out on the canvas' side, with CARET_FOLLOW_PADDING to spare. Only then: a text beside or below a panel stays where it is. Tests: typing past the stats panel's left edge pans the canvas under it, but not below it; the caret in a bidirectional line, a paragraph after one going the other way, is found where the browser lays it out. Claude Opus 5.5 (1M), xhigh effort --- packages/common/src/constants.ts | 3 + packages/common/src/utils.ts | 7 + packages/excalidraw/components/App.tsx | 193 +++++++++++++----- .../excalidraw/components/App.viewport.ts | 129 ++++++++---- .../excalidraw/components/Stats/index.tsx | 6 +- packages/excalidraw/tests/clipboard.test.tsx | 140 ++++++++++++- packages/excalidraw/types.ts | 11 +- .../excalidraw/wysiwyg/textWysiwyg.test.tsx | 130 ++++++++++++ packages/excalidraw/wysiwyg/textWysiwyg.tsx | 173 +++++++++++++++- 9 files changed, 686 insertions(+), 106 deletions(-) diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 594fc3c791..1708ee9f86 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -25,6 +25,9 @@ export const TEXT_AUTOWRAP_THRESHOLD = 36; // px // room left at each side of the visible canvas when a text stops growing at // its width, or is brought into view once it wraps export const TEXT_VIEWPORT_PADDING = 20; // px +// the widest a typed or pasted text gets before it wraps (in scene units, so +// at any zoom), however wide the view +export const TEXT_MAX_WRAP_WIDTH = 800; // px export const DRAGGING_THRESHOLD = 10; // px export const MINIMUM_ARROW_SIZE = 20; // px export const LINE_CONFIRM_THRESHOLD = 8; // px diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index bd015a99a4..dcab0fcd56 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -373,6 +373,13 @@ const RE_RTL_CHECK = new RegExp(`^[^${RS_LTR_CHARS}]*[${RS_RTL_CHARS}]`); */ export const isRTL = (text: string) => RE_RTL_CHECK.test(text); +const RE_RTL_CHARS = new RegExp(`[${RS_RTL_CHARS}]`); +/** + * Checks whether the text has any RTL character, i.e. whether it may be laid + * out bidirectionally. + */ +export const hasRTLChars = (text: string) => RE_RTL_CHARS.test(text); + export const tupleToCoors = ( xyTuple: readonly [number, number], ): { x: number; y: number } => { diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 67dc831e20..94e161525c 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -110,6 +110,8 @@ import { oneOf, getStrokeWidthByKey, TEXT_VIEWPORT_PADDING, + TEXT_MAX_WRAP_WIDTH, + type Bounds, } from "@excalidraw/common"; import { @@ -197,7 +199,6 @@ import { hitElementBoundText, hitElementBoundingBoxOnly, hitElementItself, - getVisibleSceneBounds, cropElement, wrapText, isElementLink, @@ -516,6 +517,8 @@ import type { GenerateDiagramToCode, NullableGridSize, UIConfig, + Offsets, + ViewportUIName, } from "../types"; import type { RoughCanvas } from "roughjs/bin/canvas"; import type { Action, ActionResult } from "../actions/types"; @@ -5087,19 +5090,12 @@ class App extends React.Component { fontFamily: textElementProps.fontFamily, }); const lineHeight = getLineHeight(textElementProps.fontFamily); - const [x1, , x2] = getVisibleSceneBounds(this.state); - // long texts should not go beyond 800 pixels in width nor should it go - // below 200 px — nor, whatever those make of it, outgrow the view - const maxTextWidth = Math.min( - Math.max(Math.min((x2 - x1) * 0.5, 800), 200), - this.getMaxTextWidth(), - ); const LINE_GAP = 10; - let currentY = y; const lines = isPlainPaste ? [text] : text.split("\n"); - const textElements = lines.reduce( - (acc: ExcalidrawTextElement[], line, idx) => { + const createTextElements = (maxTextWidth: number) => { + let currentY = y; + return lines.reduce((acc: ExcalidrawTextElement[], line, idx) => { const originalText = normalizeText(line).trim(); if (originalText.length) { const topLayerFrame = this.getTopLayerFrameAtSceneCoords({ @@ -5145,33 +5141,100 @@ class App extends React.Component { } return acc; - }, - [], - ); + }, []); + }; + // long texts wrap as a typed one does; wrapped to fit the view, all of + // it is brought in view, too — by moving it there (as far as the view + // would have to scroll), not the view + const pasteWithin = (offsets: Required) => { + const textElements = createTextElements(this.getMaxTextWidth(offsets)); + const scroll = + textElements.length && textElements.some((el) => !el.autoResize) + ? scrollBoundsIntoView({ + bounds: getCommonBounds(textElements), + appState: this.state, + offsets, + }) + : null; + if (!scroll) { + return textElements; + } + const dx = scroll.scrollX - this.state.scrollX; + const dy = scroll.scrollY - this.state.scrollY; + return textElements.map((element) => { + const nextX = element.x + dx; + const nextY = element.y + dy; + // at the point it's pasted at: its center + const topLayerFrame = this.getTopLayerFrameAtSceneCoords({ + x: nextX + element.width / 2, + y: nextY + element.height / 2, + }); + return newElementWith(element, { + x: nextX, + y: nextY, + frameId: topLayerFrame ? topLayerFrame.id : null, + }); + }); + }; + // laid out as if the stats panel weren't there, to see where it lands: + // it counts only beside it + const offsets = this.getTextViewportOffsets(); + let textElements = pasteWithin(offsets); if (textElements.length === 0) { return; } - - this.insertNewElements(textElements); - this.store.scheduleCapture(); - this.setState({ - selectedElementIds: makeNextSelectedElementIds( + const getSelectedElementIds = () => + makeNextSelectedElementIds( Object.fromEntries(textElements.map((el) => [el.id, true])), this.state, - ), - }); - // wrapped to fit the view: make sure all of it is in view, too - if (textElements.some((element) => !element.autoResize)) { - const scroll = scrollBoundsIntoView({ - bounds: getCommonBounds(textElements), - appState: this.state, - offsets: this.getTextViewportOffsets(), + ); + + if (this.viewport.getSideUIRect("stats")) { + // selected, it's in the stats panel, which grows to show its + // properties: rendered so (not captured yet), the panels are measured + // as they'll be — the styles panel, too, shown then, not reserved + flushSync(() => { + this.insertNewElements(textElements); + this.setState({ selectedElementIds: getSelectedElementIds() }); }); - if (scroll) { - this.viewport.translate(scroll); + const besideOffsets = this.getTextViewportOffsets( + getCommonBounds(textElements), + ); + if ( + besideOffsets.left !== offsets.left || + besideOffsets.right !== offsets.right + ) { + // beside the stats panel, or the styles panel's not as reserved: + // laid out again, as the same elements (for anyone who's seen them + // rendered already) + const relaid = pasteWithin(besideOffsets); + const pastedIds = new Set(textElements.map((element) => element.id)); + textElements = textElements.map((element, index) => { + const { x, y, width, height, text, autoResize, frameId } = + relaid[index]; + return newElementWith(element, { + x, + y, + width, + height, + text, + autoResize, + frameId, + }); + }); + this.scene.replaceAllElements( + this.scene + .getElementsIncludingDeleted() + .filter((element) => !pastedIds.has(element.id)), + ); + this.insertNewElements(textElements); } + } else { + this.insertNewElements(textElements); } + this.store.scheduleCapture(); + this.setState({ selectedElementIds: getSelectedElementIds() }); if ( !isPlainPaste && @@ -6450,13 +6513,44 @@ class App extends React.Component { }); /** - * The part of the canvas a typed or pasted text is kept within, as offsets - * from its edges (screen px): all of it but the sidebar, less some room - * at each side. + * The side panels, besides the sidebar, that a typed or pasted text is + * kept clear of: the stats panel, and the full styles panel (desktop), + * which shows next to a text while it's edited, and once it's pasted and + * selected. */ - private getTextViewportOffsets = () => { - const { left, right } = this.viewport.getSidebarInsets(); + public getTextSidePanels = (): ViewportUIName[] => + this.stylesPanelMode === "full" && + this.isDefaultUIEnabled() && + !this.state.zenModeEnabled + ? ["stats", "stylesPanel"] + : ["stats"]; + + /** + * The part of the canvas a typed or pasted text is kept within, as offsets + * from its edges (screen px): all of it but the sidebar and the side + * panels (see getTextSidePanels), less some room at each side. The stats + * panel covers the top of a side only, and counts only for a text beside + * it: one (given by its scene bounds) with rows within that room of it. + */ + private getTextViewportOffsets = (textBounds?: Bounds): Required => { const padding = TEXT_VIEWPORT_PADDING; + const stats = textBounds && this.viewport.getSideUIRect("stats"); + const { scrollY, zoom } = this.state; + const isBesideStats = + !!stats && + !!textBounds && + (textBounds[1] + scrollY) * zoom.value < stats.bottom + padding && + (textBounds[3] + scrollY) * zoom.value > stats.top - padding; + const { left, right } = this.viewport.getSideInsets( + [ + "sidebar", + ...this.getTextSidePanels().filter( + (name) => name !== "stats" || isBesideStats, + ), + ], + // the styles panel isn't shown yet when pasting with nothing selected + { reserve: { stylesPanel: true } }, + ); return { top: padding, right: right + padding, @@ -6467,13 +6561,16 @@ class App extends React.Component { /** * The widest a text may grow to as it's typed or pasted, in scene units: - * the width of the part of the canvas it's kept within, so that a text - * never outgrows the view. + * TEXT_MAX_WRAP_WIDTH, or less, so that a text never outgrows the part of + * the canvas it's kept within. */ - private getMaxTextWidth = () => { - const { left, right } = this.getTextViewportOffsets(); - const width = this.state.width - left - right; - return width > 0 ? width / this.state.zoom.value : Infinity; + private getMaxTextWidth = (offsets: Required) => { + const { left, right } = offsets; + const viewWidth = this.state.width - left - right; + return Math.min( + viewWidth > 0 ? viewWidth / this.state.zoom.value : Infinity, + TEXT_MAX_WRAP_WIDTH, + ); }; private handleTextWysiwyg( @@ -6510,8 +6607,12 @@ class App extends React.Component { originalText: nextOriginalText, }) : null; - // a free text stops growing at the view's width and wraps from there - const maxWidth = this.getMaxTextWidth(); + // a free text stops growing at the view's width (between the side + // panels beside its rows), or TEXT_MAX_WRAP_WIDTH, and wraps from there + const textViewportOffsets = this.getTextViewportOffsets( + getElementBounds(latestTextElement, elementsMap), + ); + const maxWidth = this.getMaxTextWidth(textViewportOffsets); this.scene.replaceAllElements([ // Not sure why we include deleted elements as well hence using deleted elements map @@ -6551,16 +6652,16 @@ class App extends React.Component { isTextElement(updatedTextElement) && !updatedTextElement.autoResize ) { - // it just started wrapping at the view's width: bring all of it into - // view (right edge off the view's by the same room as the width - // left) — vertically only if it fits; the caret follows the rest + // it just started wrapping: bring all of it into view, between the + // side panels (with the same room at the edges as the width left) + // — vertically only if it fits; the caret follows the rest const scroll = scrollBoundsIntoView({ bounds: getElementBounds( updatedTextElement, this.scene.getNonDeletedElementsMap(), ), appState: this.state, - offsets: this.getTextViewportOffsets(), + offsets: textViewportOffsets, tooLarge: "leave", }); if (scroll) { diff --git a/packages/excalidraw/components/App.viewport.ts b/packages/excalidraw/components/App.viewport.ts index c242f8cd58..5437d82f93 100644 --- a/packages/excalidraw/components/App.viewport.ts +++ b/packages/excalidraw/components/App.viewport.ts @@ -482,27 +482,100 @@ export class AppViewport { }; /** - * How far the sidebar reaches into the canvas, in screen px, from the - * right or (RTL) the left: the part of the canvas it covers. Zero when - * it's closed — or on phones, where it's an overlay that doesn't count as - * viewport UI. + * The side a named surface docks to while it's hidden, and how far it + * reaches into the canvas from it: as last measured, else approximately. */ - getSidebarInsets = () => { + private getReservedSide = ( + name: keyof NonNullable, + ) => { + const isRTL = getLanguage().rtl; + return ( + this.uiLastMeasured.get(name) ?? + (name === "stylesPanel" + ? { + side: isRTL ? ("right" as const) : ("left" as const), + offset: + this.dependencies.getStylesPanelMode() === "compact" + ? STYLES_PANEL_APPROX_WIDTH.compact + : STYLES_PANEL_APPROX_WIDTH.full, + } + : { + side: isRTL ? ("left" as const) : ("right" as const), + offset: RIGHT_SIDEBAR_WIDTH, + }) + ); + }; + + private querySideUI = (container: HTMLElement, name: ViewportUIName) => + container.querySelector( + `[data-viewport-ui="side"][data-viewport-ui-name="${name}"]`, + ); + + /** + * Where a rendered side UI is, relative to the editor's container, in + * screen px; null when it isn't rendered. + */ + getSideUIRect = (name: ViewportUIName) => { + const container = this.dependencies.getContainer(); + const node = container && this.querySideUI(container, name); + if (!container || !node) { + return null; + } + const containerRect = container.getBoundingClientRect(); + const rect = node.getBoundingClientRect(); + return { + left: rect.left - containerRect.left, + right: rect.right - containerRect.left, + top: rect.top - containerRect.top, + bottom: rect.bottom - containerRect.top, + }; + }; + + /** + * How far the named side UIs reach into the canvas, in screen px, from + * the left and the right: the part of the canvas they cover. Zero at a + * side none of them is rendered at — e.g. with the sidebar closed, or on + * phones, where it's an overlay that doesn't count as viewport UI — unless + * a hidden one is reserved (see `ViewportOffsetsOptions.reserve`). + */ + getSideInsets = ( + names: readonly ViewportUIName[], + opts?: Pick, + ) => { const insets = { left: 0, right: 0 }; const container = this.dependencies.getContainer(); - const sidebar = container?.querySelector( - '[data-viewport-ui="side"][data-viewport-ui-name="sidebar"]', - ); - if (container && sidebar) { - const { side, offset } = this.measureSide( - sidebar, - container.getBoundingClientRect(), - ); - insets[side] = Math.max(0, offset); + if (!container) { + return insets; + } + const containerRect = container.getBoundingClientRect(); + for (const name of names) { + const node = this.querySideUI(container, name); + let footprint: { side: "left" | "right"; offset: number } | null = null; + if (node) { + footprint = this.measureSide(node, containerRect); + if (footprint.offset > 0) { + this.uiLastMeasured.set(name, footprint); + } + } else if ( + name !== "stats" && + opts?.reserve?.[name] && + this.app.editorInterface.formFactor !== "phone" + ) { + footprint = this.getReservedSide(name); + } + if (footprint) { + insets[footprint.side] = Math.max( + insets[footprint.side], + footprint.offset, + ); + } } return insets; }; + /** How far the sidebar reaches into the canvas (see `getSideInsets`). */ + getSidebarInsets = () => this.getSideInsets(["sidebar"]); + /** * Resolves user-supplied viewport offsets into concrete per-side pixel * values. Static sides take precedence over UI-derived sides. @@ -590,31 +663,11 @@ export class AppViewport { } if (opts?.reserve && this.app.editorInterface.formFactor !== "phone") { - const reserveSurface = ( - name: ViewportUIName, - fallback: { side: "left" | "right"; offset: number }, - ) => { - if (renderedSurfaces.has(name)) { - return; + for (const name of ["stylesPanel", "sidebar"] as const) { + if (opts.reserve[name] && !renderedSurfaces.has(name)) { + const { side, offset } = this.getReservedSide(name); + measuredOffsets[side] = Math.max(measuredOffsets[side], offset); } - const { side, offset } = this.uiLastMeasured.get(name) ?? fallback; - measuredOffsets[side] = Math.max(measuredOffsets[side], offset); - }; - - if (opts.reserve.stylesPanel) { - reserveSurface("stylesPanel", { - side: isRTL ? "right" : "left", - offset: - this.dependencies.getStylesPanelMode() === "compact" - ? STYLES_PANEL_APPROX_WIDTH.compact - : STYLES_PANEL_APPROX_WIDTH.full, - }); - } - if (opts.reserve.sidebar) { - reserveSurface("sidebar", { - side: isRTL ? "left" : "right", - offset: RIGHT_SIDEBAR_WIDTH, - }); } } diff --git a/packages/excalidraw/components/Stats/index.tsx b/packages/excalidraw/components/Stats/index.tsx index 08c1f64970..0c38efd550 100644 --- a/packages/excalidraw/components/Stats/index.tsx +++ b/packages/excalidraw/components/Stats/index.tsx @@ -183,7 +183,11 @@ export const StatsInner = memo( }, [selectedElements]); return ( -
+

{t("stats.title")}

diff --git a/packages/excalidraw/tests/clipboard.test.tsx b/packages/excalidraw/tests/clipboard.test.tsx index 24e4ea89c5..1dfd026f40 100644 --- a/packages/excalidraw/tests/clipboard.test.tsx +++ b/packages/excalidraw/tests/clipboard.test.tsx @@ -6,6 +6,7 @@ import { getLineHeightInPx } from "@excalidraw/element"; import { KEYS, TEXT_VIEWPORT_PADDING, + TEXT_MAX_WRAP_WIDTH, arrayToMap, getLineHeight, } from "@excalidraw/common"; @@ -215,10 +216,8 @@ describe("paste text as a single element", () => { width: 1000, zoom: { value: 8 as NormalizedZoomValue }, }); - // the view at 800%, less some room at each side: under the 200px a - // pasted text otherwise wraps at, at the least + // the view at 800%, less some room at each side const maxWidth = (1000 - 2 * TEXT_VIEWPORT_PADDING) / 8; - expect(maxWidth).toBeLessThan(200); pasteWithCtrlCmdShiftV( "Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!", @@ -230,8 +229,138 @@ describe("paste text as a single element", () => { expect(text.autoResize).toBe(false); expect(text.width).toBeLessThanOrEqual(maxWidth); }); - it("should bring a pasted text that wraps into view", async () => { + it("should wrap a pasted text as a typed one: at TEXT_MAX_WRAP_WIDTH, clear of the full styles panel", async () => { + unmountComponent(); + await render( + "desktop" }} + />, + ); + API.setAppState({ width: 1000 }); + // nothing's selected: the panel shows only once the text is in + expect( + document.querySelector('[data-viewport-ui-name="stylesPanel"]'), + ).toBe(null); + + pasteWithCtrlCmdShiftV( + "Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams! Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!", + ); + await waitFor(() => { + expect(h.elements.length).toEqual(1); + }); + const text = h.elements[0] as ExcalidrawTextElement; + expect(text.autoResize).toBe(false); + // not at half the view… + expect(text.width).toBeGreaterThan(1000 / 2); + // …but the view less the panel's room (216, approximately, while never + // shown) and some room at each side + expect(text.width).toBeLessThanOrEqual( + Math.min(1000 - 216 - 2 * TEXT_VIEWPORT_PADDING, TEXT_MAX_WRAP_WIDTH), + ); + }); + describe("next to the stats panel (desktop)", () => { + const { getBoundingClientRect } = HTMLElement.prototype; + /** the stats panel's bottom: it grows to show a selection's properties */ + let getStatsBottom = () => 200; + + beforeEach(async () => { + unmountComponent(); + await render( + "desktop" }} + />, + ); + API.setAppState({ + width: 1000, + height: 800, + stats: { ...h.state.stats, open: true }, + }); + // the styles panel up to 216px, the stats panel from 700px + getStatsBottom = () => 200; + HTMLElement.prototype.getBoundingClientRect = function ( + this: HTMLElement, + ) { + switch (this.dataset.viewportUiName) { + case "stylesPanel": + return { + left: 16, + right: 216, + width: 200, + top: 0, + bottom: 400, + } as DOMRect; + case "stats": + return { + left: 700, + right: 904, + width: 204, + top: 0, + bottom: getStatsBottom(), + } as DOMRect; + } + return getBoundingClientRect.call(this); + }; + }); + + afterEach(() => { + HTMLElement.prototype.getBoundingClientRect = getBoundingClientRect; + }); + + const pasteAt = async (y: number) => { + API.setSelectedElements([]); + h.app.viewport.lastPosition.x = 500; + h.app.viewport.lastPosition.y = y; + const count = h.elements.length; + pasteWithCtrlCmdShiftV( + "Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams! Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!", + ); + await waitFor(() => { + expect(h.elements.length).toEqual(count + 1); + }); + return h.elements[count] as ExcalidrawTextElement; + }; + const getRight = (text: ExcalidrawTextElement) => + (text.x + text.width + h.state.scrollX) * h.state.zoom.value; + // the view less the styles panel's room, and some room at each side + const maxWidth = 1000 - 216 - 2 * TEXT_VIEWPORT_PADDING; + // and the stats panel's + const maxWidthBesideStats = maxWidth - (1000 - 700); + + it("should keep a pasted text clear of the stats panel only where it lands beside it", async () => { + // below it, it's wrapped past it + let text = await pasteAt(600); + expect(text.width).toBeGreaterThan(maxWidthBesideStats); + expect(text.width).toBeLessThanOrEqual(maxWidth); + + // beside it, it's wrapped, and moved, clear of it + text = await pasteAt(100); + expect(text.width).toBeLessThanOrEqual(maxWidthBesideStats); + expect(getRight(text)).toBeLessThanOrEqual( + 700 - TEXT_VIEWPORT_PADDING + 0.01, + ); + }); + + it("should keep a pasted text clear of the stats panel grown to show it, as one step", async () => { + getStatsBottom = () => + Object.keys(h.state.selectedElementIds).length ? 500 : 200; + const undoSteps = h.history.undoStack.length; + + // below it as it is before the paste, beside it as it is after + const text = await pasteAt(350); + expect(text.width).toBeLessThanOrEqual(maxWidthBesideStats); + expect(getRight(text)).toBeLessThanOrEqual( + 700 - TEXT_VIEWPORT_PADDING + 0.01, + ); + expect(h.history.undoStack.length).toBe(undoSteps + 1); + }); + }); + it("should move a pasted text that wraps into view, not the view", async () => { API.setAppState({ width: 1000, height: 800 }); + const { scrollX, scrollY } = h.state; // pasted at the pointer, near the bottom-right corner h.app.viewport.lastPosition.x = 950; h.app.viewport.lastPosition.y = 780; @@ -244,8 +373,9 @@ describe("paste text as a single element", () => { }); const text = h.elements[0] as ExcalidrawTextElement; expect(text.autoResize).toBe(false); + expect(h.state.scrollX).toBe(scrollX); + expect(h.state.scrollY).toBe(scrollY); // all of it within the view, with room at each side - const { scrollX, scrollY } = h.state; const zoom = h.state.zoom.value; expect((text.x + scrollX) * zoom).toBeGreaterThanOrEqual( TEXT_VIEWPORT_PADDING - 0.01, diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 3a37d94ee9..423221cad6 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -1598,9 +1598,10 @@ export type ViewportOffsets = Offsets & { /** * Value of the `data-viewport-ui-name` attribute, identifying a * conditionally-rendered surface (marked with `data-viewport-ui`) so that - * `getViewportOffsets` can reserve space for it while it's hidden (see the - * `reserve` option). Whenever a named surface is rendered, its measured - * footprint is remembered; reserving uses that remembered footprint, or an - * approximate default if the surface hasn't been rendered yet. + * it can be measured on its own, and so that `getViewportOffsets` can + * reserve space for it while it's hidden (see the `reserve` option). + * Whenever a named surface is rendered, its measured footprint is + * remembered; reserving uses that remembered footprint, or an approximate + * default if the surface hasn't been rendered yet. */ -export type ViewportUIName = "sidebar" | "stylesPanel"; +export type ViewportUIName = "sidebar" | "stylesPanel" | "stats"; diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx index 2a1ec51ec2..1b572309dd 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx @@ -19,6 +19,7 @@ import { VERTICAL_ALIGN, applyDarkModeFilter, TEXT_VIEWPORT_PADDING, + TEXT_MAX_WRAP_WIDTH, } from "@excalidraw/common"; import type { @@ -1077,6 +1078,135 @@ describe("textWysiwyg", () => { sidebar.remove(); }); + it.each([0.5, 2])( + "should stop a growing text at TEXT_MAX_WRAP_WIDTH at any zoom, however wide the view (zoom %s)", + (zoom) => { + API.setAppState({ + width: 2000, + zoom: { value: zoom as typeof h.state.zoom.value }, + }); + + updateTextEditor( + textarea, + "Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams! Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!", + ); + const text = h.elements[0] as ExcalidrawTextElement; + expect(text.autoResize).toBe(false); + // in scene units: at 200%, the view would allow 980 + expect(text.width).toBe(TEXT_MAX_WRAP_WIDTH); + }, + ); + + describe("next to the side panels (desktop)", () => { + beforeEach(async () => { + // the 800x400 view is a phone's: make it a desktop's instead + unmountComponent(); + await render( + "desktop" }} + />, + ); + API.setAppState({ stats: { ...h.state.stats, open: true } }); + }); + + const long = + "Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!"; + + /** + * Types `text` as a new text started at (x, y), with the styles panel + * at the view's left, up to 216px, and the stats panel from 500px, + * over the view's top `statsHeight`. + */ + const typeAt = async ( + x: number, + y: number, + text: string, + statsHeight = 400, + ) => { + UI.clickTool("text"); + mouse.clickAt(x, y); + textarea = await getTextEditor(); + const panel = (name: string) => + document.querySelector( + `[data-viewport-ui="side"][data-viewport-ui-name="${name}"]`, + )!; + panel("stylesPanel").getBoundingClientRect = domRect(16, 200); + panel("stats").getBoundingClientRect = () => + ({ + ...domRect(500, 204)(), + height: statsHeight, + bottom: statsHeight, + } as DOMRect); + updateTextEditor(textarea, text); + // the canvas follows the caret once the update's in the app's state + await act(() => new Promise((resolve) => setTimeout(resolve))); + return h.app.scene.getElement( + h.state.editingTextElement!.id, + ) as ExcalidrawTextElement; + }; + + it("should keep a text being edited clear of the full styles panel and the stats panel", async () => { + // starts under the styles panel's right edge + const text = await typeAt(100, 300, long); + expect(text.autoResize).toBe(false); + // it wraps at the canvas between the panels, less the room, and is + // brought in between them + expect(text.width).toBe(500 - 216 - 2 * TEXT_VIEWPORT_PADDING); + expect(text.x + h.state.scrollX).toBeCloseTo( + 216 + TEXT_VIEWPORT_PADDING, + ); + }); + + it("should keep a text being edited clear of the stats panel only beside it", async () => { + // below it, the text runs on past it + let text = await typeAt(236, 300, long, 200); + expect(text.width).toBe(800 - 216 - 2 * TEXT_VIEWPORT_PADDING); + Keyboard.exitTextEditor(textarea); + + text = await typeAt(236, 100, long, 200); + expect(text.width).toBe(500 - 216 - 2 * TEXT_VIEWPORT_PADDING); + }); + + it("should pan the canvas while typing only once the caret is under the stats panel", async () => { + // below it, the caret is in sight + let text = await typeAt(400, 300, "Excalidraw is", 200); + expect(text.x + text.width).toBeGreaterThan(500); + expect(h.state.scrollX).toBe(0); + Keyboard.exitTextEditor(textarea); + + // under it, it's brought out, with some room to spare + text = await typeAt(400, 100, "Excalidraw is", 200); + expect(h.state.scrollX).toBeLessThan(0); + expect( + (text.x + text.width + h.state.scrollX) * h.state.zoom.value, + ).toBeCloseTo(500 - CARET_FOLLOW_PADDING); + }); + + it("should find the caret in a bidirectional line where the browser lays it out", async () => { + // "א abcdef", a paragraph of its own, goes right to left, as + // "abcdef א": the caret after the "f" is 60px in (not at the line's + // left edge, where it'd be if all of it went right to left, nor its + // right one, as the text's first paragraph goes), which jsdom can't + // lay out + const { getBoundingClientRect } = Range.prototype; + Range.prototype.getBoundingClientRect = function (this: Range) { + const isRTL = + this.startContainer.parentElement?.getAttribute("dir") === "rtl"; + return { left: this.collapsed && isRTL ? 60 : 0 } as DOMRect; + }; + try { + // from 450px, 80px long: its caret's under the stats panel + const text = await typeAt(450, 100, "hello\nא abcdef", 200); + expect( + (text.x + 60 + h.state.scrollX) * h.state.zoom.value, + ).toBeCloseTo(500 - CARET_FOLLOW_PADDING); + } finally { + Range.prototype.getBoundingClientRect = getBoundingClientRect; + } + }); + }); + it("should bring a text that starts wrapping into view, with room at its right edge", async () => { Keyboard.exitTextEditor(textarea); UI.clickTool("text"); diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.tsx index 8fdc956c50..2dca1e582a 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.tsx @@ -11,6 +11,7 @@ import { MIME_TYPES, applyDarkModeFilter, isRTL, + hasRTLChars, } from "@excalidraw/common"; import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math"; @@ -126,21 +127,27 @@ const getCaretBoundaryOffsets = (text: string) => { return offsets; }; -const getLineCaretOffsetFromNativeLayout = ({ - text, - font, - lineHeightPx, - direction, - targetX, - ownerDocument, -}: { +type NativeLineLayout = { text: string; font: ReturnType; lineHeightPx: number; direction: "ltr" | "rtl"; - targetX: number; ownerDocument: Document; -}) => { +}; + +/** + * Where the caret shows at each of `offsets` into a line of text, as the + * browser lays the line out, bidirectionally: the x of each (px, from the + * viewport's left), and of the line's left edge. Null where it can't tell. + */ +const measureNativeLineCaretPositions = ({ + text, + font, + lineHeightPx, + direction, + ownerDocument, + offsets, +}: NativeLineLayout & { offsets: readonly number[] }) => { if ( !text || !ownerDocument.body || @@ -149,7 +156,6 @@ const getLineCaretOffsetFromNativeLayout = ({ return null; } - const offsets = getCaretBoundaryOffsets(text); const mirror = ownerDocument.createElement("div"); const textNode = ownerDocument.createTextNode(text); const range = ownerDocument.createRange(); @@ -173,6 +179,8 @@ const getLineCaretOffsetFromNativeLayout = ({ ownerDocument.body.append(mirror); try { + range.selectNodeContents(textNode); + const { left } = range.getBoundingClientRect(); for (const offset of offsets) { range.setStart(textNode, offset); range.setEnd(textNode, offset); @@ -184,11 +192,26 @@ const getLineCaretOffsetFromNativeLayout = ({ positions.push(caretRect.left); } + return { left, positions }; } catch { return null; } finally { mirror.remove(); } +}; + +const getLineCaretOffsetFromNativeLayout = ({ + targetX, + ...layout +}: NativeLineLayout & { targetX: number }) => { + const offsets = getCaretBoundaryOffsets(layout.text); + const positions = measureNativeLineCaretPositions({ + ...layout, + offsets, + })?.positions; + if (!positions) { + return null; + } const leftEdge = Math.min(...positions); let closestOffset = offsets[0]; @@ -206,6 +229,22 @@ const getLineCaretOffsetFromNativeLayout = ({ return closestOffset; }; +/** + * Where the caret shows at `offset` into a line of text, as the browser lays + * the line out, bidirectionally: px from the line's left edge; null where it + * can't tell. + */ +const getLineCaretXFromNativeLayout = ({ + offset, + ...layout +}: NativeLineLayout & { offset: number }) => { + const measured = measureNativeLineCaretPositions({ + ...layout, + offsets: [offset], + }); + return measured ? measured.positions[0] - measured.left : null; +}; + type SubmitHandler = () => void; export const textWysiwyg = ({ @@ -576,6 +615,115 @@ export const textWysiwyg = ({ return line.start + (lineCaretOffset || 0); }; + /** + * Where the caret is on screen (see getViewportCoords), as the editor lays + * the text out: the bounds of its line-high stroke. + */ + const getCaretViewportBounds = () => { + const layout = currentTextLayout; + if (!layout) { + return null; + } + const { value } = editable; + const caret = + editable.selectionDirection === "backward" + ? editable.selectionStart + : editable.selectionEnd; + const lines = getWrappedTextLines( + value, + layout.font, + editable.style.whiteSpace === "pre-wrap" ? layout.width : Infinity, + ); + // the last line starting at or before it: at a soft break, the caret + // shows at the start of the next line + const lineIndex = Math.max( + 0, + lines.findLastIndex((line) => line.start <= caret), + ); + const line = lines[lineIndex]; + const lineWidth = getLineWidth(line.text, layout.font); + const lineStartX = + layout.textAlign === "center" + ? (layout.width - lineWidth) / 2 + : layout.textAlign === "right" + ? layout.width - lineWidth + : 0; + const lineText = value.slice(line.start, line.end); + const offset = Math.min(caret, line.end) - line.start; + const widthBefore = getLineWidth(lineText.slice(0, offset), layout.font); + // the editor's `dir="auto"` makes it `unicode-bidi: plaintext`: each + // paragraph goes its first strong character's way + const direction = getLineDirection(value, line.start); + // any RTL character in it, and the browser's bidirectional layout of + // the line says where the caret is + const nativeX = hasRTLChars(lineText) + ? getLineCaretXFromNativeLayout({ + text: lineText, + font: layout.font, + lineHeightPx: layout.lineHeightPx, + direction, + ownerDocument, + offset, + }) + : null; + const caretX = + lineStartX + + (nativeX ?? + (direction === "rtl" ? lineWidth - widthBefore : widthBefore)); + const center = pointFrom( + layout.x + layout.width / 2, + layout.y + layout.height / 2, + ); + const [[x1, y1], [x2, y2]] = [lineIndex, lineIndex + 1].map((index) => { + const [x, y] = pointRotateRads( + pointFrom(layout.x + caretX, layout.y + index * layout.lineHeightPx), + center, + layout.angle, + ); + return getViewportCoords(x, y); + }); + return { + left: Math.min(x1, x2), + right: Math.max(x1, x2), + top: Math.min(y1, y2), + bottom: Math.max(y1, y2), + }; + }; + + /** + * A caret typed in under the stats or the styles panel (see + * App.getTextSidePanels) is out of sight, yet inside the editor's box, so + * the browser doesn't reveal it (unlike the sidebar, the panels don't span + * the box's height, see updateEditorBoxInsets). Pan the canvas to bring it + * out from under the panel, on the canvas' side, with some room to spare — + * only then: a text beside or below a panel stays where it is. + */ + const followCaretFromUnderPanels = () => { + const caret = !isDestroyed && getCaretViewportBounds(); + if (!caret) { + return; + } + for (const name of app.getTextSidePanels()) { + const panel = app.viewport.getSideUIRect(name); + if ( + panel && + caret.right >= panel.left && + caret.left <= panel.right && + caret.bottom > panel.top && + caret.top < panel.bottom + ) { + const panX = + panel.left + panel.right < app.state.width + ? panel.right + CARET_FOLLOW_PADDING - caret.left + : panel.left - CARET_FOLLOW_PADDING - caret.right; + app.viewport.translate((state) => ({ + scrollX: state.scrollX + panX / state.zoom.value, + })); + return; + } + } + }; + let pendingInitialSelection = (() => { const caretIndex = getCaretIndexFromInitialSceneCoords(); @@ -691,6 +839,9 @@ export const textWysiwyg = ({ editable.selectionEnd = selectionStart; } onChange(editable.value); + // once the update, and any scroll it made (see App.handleTextWysiwyg), + // is in the app's state + queueMicrotask(followCaretFromUnderPanels); }; }