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); }; }