diff --git a/packages/element/src/textElement.ts b/packages/element/src/textElement.ts index 523a8b8804..b891fdf2e9 100644 --- a/packages/element/src/textElement.ts +++ b/packages/element/src/textElement.ts @@ -441,7 +441,8 @@ const VALID_CONTAINER_TYPES = new Set([ export const isValidTextContainer = (element: { type: ExcalidrawElementType; -}) => VALID_CONTAINER_TYPES.has(element.type); +}): element is ExcalidrawTextContainer => + VALID_CONTAINER_TYPES.has(element.type); export const computeContainerDimensionForBoundText = ( dimension: number, diff --git a/packages/element/src/textWrapping.ts b/packages/element/src/textWrapping.ts index 5ec9bb42a9..b580f52ed1 100644 --- a/packages/element/src/textWrapping.ts +++ b/packages/element/src/textWrapping.ts @@ -4,6 +4,22 @@ import { charWidth, getLineWidth } from "./textMeasurements"; import type { FontString } from "./types"; +/** + * This module approximates browser-like soft wrapping for Excalidraw text. + * + * The flow is: + * 1. `parseTokens()` splits a hard line into breakable tokens using a unicode-aware regex. + * 2. `getWrappedTextLines()` reflows each hard line into one or more visual lines and + * records where each visual line came from in the source text. + * 3. `wrapLine()` assembles tokens into lines, and `wrapWord()` handles a single token + * that is wider than the available width. + * 4. `trimLine()` / `trimLineEndAtSoftBreak()` mirror browser behavior around trailing + * whitespace so the rendered text stays consistent with what users see on canvas. + * + * Mostly, you'll want to use wrapText(). getWrappedTextLines() is for callers + * that need metadata such as mapping visual lines back to `originalText` + * for caret placement or future editor features. + */ let cachedCjkRegex: RegExp | undefined; let cachedLineBreakRegex: RegExp | undefined; let cachedEmojiRegex: RegExp | undefined; @@ -358,6 +374,10 @@ const Break = { /** * Breaks the line into the tokens based on the found line break opporutnities. + * + * Note: tokenization normalizes to NFC first so decomposed graphemes are treated as + * their composed variants for wrapping. Any code that needs exact source offsets should + * keep in mind that this assumes the input text is already NFC-normalized. */ export const parseTokens = (line: string) => { const breakLineRegex = getLineBreakRegex(); @@ -370,56 +390,120 @@ export const parseTokens = (line: string) => { /** * Wraps the original text into the lines based on the given width. + * + * This is a convenience adapter over `getWrappedTextLines()` for call sites + * that only need the rendered wrapped string and not the source offsets. */ export const wrapText = ( text: string, font: FontString, maxWidth: number, ): string => { + return getWrappedTextLines(text, font, maxWidth) + .map((line) => line.text) + .join("\n"); +}; + +/** + * A single rendered visual line produced from the original text. + * + * `start` and `end` are end-exclusive code-unit offsets into the original text, and do + * not include synthetic soft line breaks inserted by this module. If trailing whitespace + * was trimmed away at a wrap boundary, `end` points to the last rendered character. + */ +export type WrappedTextLine = { + text: string; + start: number; + end: number; +}; + +/** + * Splits only on existing hard line breaks and preserves original offsets. + */ +const getHardLineBreaks = (text: string): WrappedTextLine[] => { + let offset = 0; + + return text.split("\n").map((line) => { + const start = offset; + const end = start + line.length; + + offset = end + 1; + + return { + text: line, + start, + end, + }; + }); +}; + +/** + * Returns the rendered visual lines together with their source offsets. + * + * This is the source-of-truth wrapping pipeline for callers that need more than the + * final wrapped string, for example caret placement or future editor/rich-text mapping. + */ +export const getWrappedTextLines = ( + text: string, + font: FontString, + maxWidth: number, +): WrappedTextLine[] => { // if maxWidth is not finite or NaN which can happen in case of bugs in // computation, we need to make sure we don't continue as we'll end up // in an infinite loop if (!Number.isFinite(maxWidth) || maxWidth < 0) { - return text; + return getHardLineBreaks(text); } - const lines: Array = []; - const originalLines = text.split("\n"); + const lines: WrappedTextLine[] = []; + let offset = 0; - for (const originalLine of originalLines) { - const currentLineWidth = getLineWidth(originalLine, font); + for (const originalLine of text.split("\n")) { + const originalLineWidth = getLineWidth(originalLine, font); - if (currentLineWidth <= maxWidth) { - lines.push(originalLine); - continue; + if (originalLineWidth <= maxWidth) { + lines.push({ + text: originalLine, + start: offset, + end: offset + originalLine.length, + }); + } else { + lines.push(...wrapLine(originalLine, font, maxWidth, offset)); } - const wrappedLine = wrapLine(originalLine, font, maxWidth); - lines.push(...wrappedLine); + offset += originalLine.length + 1; } - return lines.join("\n"); + return lines; }; /** - * Wraps the original line into the lines based on the given width. + * Wraps a single hard line into one or more visual lines. + * + * The line-local offsets are tracked in original-text code units so + * we can map the visual line back to the source. */ const wrapLine = ( line: string, font: FontString, maxWidth: number, -): string[] => { - const lines: Array = []; + lineStart: number, +): WrappedTextLine[] => { + const lines: WrappedTextLine[] = []; const tokens = parseTokens(line); - const tokenIterator = tokens[Symbol.iterator](); let currentLine = ""; + let currentLineStart = lineStart; + let currentLineEnd = lineStart; let currentLineWidth = 0; + // Tracks the next token's code-unit position in the original source string. + let tokenOffset = lineStart; + let tokenIndex = 0; - let iterator = tokenIterator.next(); - - while (!iterator.done) { - const token = iterator.value; + while (tokenIndex < tokens.length) { + const token = tokens[tokenIndex]; + const tokenStart = tokenOffset; + const tokenEnd = tokenStart + token.length; const testLine = currentLine + token; // cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here @@ -429,37 +513,59 @@ const wrapLine = ( // build up the current line, skipping length check for possibly trailing whitespaces if (/\s/.test(token) || testLineWidth <= maxWidth) { + if (!currentLine) { + currentLineStart = tokenStart; + } currentLine = testLine; + currentLineEnd = tokenEnd; currentLineWidth = testLineWidth; - iterator = tokenIterator.next(); + tokenOffset = tokenEnd; + tokenIndex++; continue; } // current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped if (!currentLine) { - const wrappedWord = wrapWord(token, font, maxWidth); - const trailingLine = wrappedWord[wrappedWord.length - 1] ?? ""; + const wrappedWord = wrapWord(token, font, maxWidth, tokenStart); + const trailingLine = wrappedWord[wrappedWord.length - 1] ?? { + text: "", + start: tokenStart, + end: tokenStart, + }; const precedingLines = wrappedWord.slice(0, -1); lines.push(...precedingLines); // trailing line of the wrapped word might still be joined with next token/s - currentLine = trailingLine; - currentLineWidth = getLineWidth(trailingLine, font); - iterator = tokenIterator.next(); + currentLine = trailingLine.text; + currentLineStart = trailingLine.start; + currentLineEnd = trailingLine.end; + currentLineWidth = getLineWidth(trailingLine.text, font); + tokenOffset = tokenEnd; + tokenIndex++; } else { // push & reset, but don't iterate on the next token, as we didn't use it yet! - lines.push(currentLine.trimEnd()); + lines.push( + trimLineEndAtSoftBreak(currentLine, currentLineStart, currentLineEnd), + ); // purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above currentLine = ""; + currentLineStart = tokenStart; + currentLineEnd = tokenStart; currentLineWidth = 0; } } // iterator done, push the trailing line if exists if (currentLine) { - const trailingLine = trimLine(currentLine, font, maxWidth); + const trailingLine = trimLine( + currentLine, + currentLineStart, + currentLineEnd, + font, + maxWidth, + ); lines.push(trailingLine); } @@ -467,59 +573,100 @@ const wrapLine = ( }; /** - * Wraps the word into the lines based on the given width. + * Wraps a single word that could not be placed on an empty line as-is. */ const wrapWord = ( word: string, font: FontString, maxWidth: number, -): Array => { + wordStart: number, +): WrappedTextLine[] => { // multi-codepoint emojis are already broken apart and shouldn't be broken further if (getEmojiRegex().test(word)) { - return [word]; + return [ + { + text: word, + start: wordStart, + end: wordStart + word.length, + }, + ]; } satisfiesWordInvariant(word); - const lines: Array = []; + const lines: WrappedTextLine[] = []; const chars = Array.from(word); let currentLine = ""; + let currentLineStart = wordStart; + let currentLineEnd = wordStart; let currentLineWidth = 0; + let offset = wordStart; for (const char of chars) { + const charStart = offset; + const charEnd = charStart + char.length; const _charWidth = charWidth.calculate(char, font); const testLineWidth = currentLineWidth + _charWidth; if (testLineWidth <= maxWidth) { + if (!currentLine) { + currentLineStart = charStart; + } currentLine = currentLine + char; + currentLineEnd = charEnd; currentLineWidth = testLineWidth; + offset = charEnd; continue; } if (currentLine) { - lines.push(currentLine); + lines.push({ + text: currentLine, + start: currentLineStart, + end: currentLineEnd, + }); } currentLine = char; + currentLineStart = charStart; + currentLineEnd = charEnd; currentLineWidth = _charWidth; + offset = charEnd; } if (currentLine) { - lines.push(currentLine); + lines.push({ + text: currentLine, + start: currentLineStart, + end: currentLineEnd, + }); } return lines; }; /** - * Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`. + * Trims trailing whitespace that is exceeding the `maxWidth`. + * + * Used for the trailing visual line of a hard line, where some trailing + * whitespace may still be visible if it fits into the available width. */ -const trimLine = (line: string, font: FontString, maxWidth: number) => { +const trimLine = ( + line: string, + start: number, + end: number, + font: FontString, + maxWidth: number, +): WrappedTextLine => { const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth; if (!shouldTrimWhitespaces) { - return line; + return { + text: line, + start, + end, + }; } // defensively default to `trimeEnd` in case the regex does not match @@ -543,7 +690,30 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => { trimmedLineWidth = testLineWidth; } - return trimmedLine; + return { + text: trimmedLine, + start, + end: end - (line.length - trimmedLine.length), + }; +}; + +/** + * Used for internal soft-wrap boundaries, where trailing whitespace should not + * survive into the rendered line even though it still exists in the original + * text. + */ +const trimLineEndAtSoftBreak = ( + line: string, + start: number, + end: number, +): WrappedTextLine => { + const trimmedLine = line.trimEnd(); + + return { + text: trimmedLine, + start, + end: end - (line.length - trimmedLine.length), + }; }; /** diff --git a/packages/element/tests/textWrapping.test.ts b/packages/element/tests/textWrapping.test.ts index 87c96a4c91..2149dd5622 100644 --- a/packages/element/tests/textWrapping.test.ts +++ b/packages/element/tests/textWrapping.test.ts @@ -1,4 +1,8 @@ -import { wrapText, parseTokens } from "../src/textWrapping"; +import { + getWrappedTextLines, + parseTokens, + wrapText, +} from "../src/textWrapping"; import type { FontString } from "../src/types"; @@ -102,6 +106,71 @@ describe("Test wrapText", () => { expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`); }); + it("should retain original text offsets for wrapped lines", () => { + expect(getWrappedTextLines("Hello World!", font, 60)).toEqual([ + { + text: "Hello", + start: 0, + end: 5, + }, + { + text: "World!", + start: 6, + end: 12, + }, + ]); + }); + + it("should exclude whitespace trimmed away at soft-wrap boundaries from line offsets", () => { + expect(getWrappedTextLines(" Hello World", font, 90)).toEqual([ + { + text: " Hello", + start: 0, + end: 7, + }, + { + text: "World", + start: 9, + end: 14, + }, + ]); + }); + + it("should retain offsets when wrapping a single long token", () => { + expect(getWrappedTextLines("Excalidraw", font, 50)).toEqual([ + { + text: "Excal", + start: 0, + end: 5, + }, + { + text: "idraw", + start: 5, + end: 10, + }, + ]); + }); + + it("should preserve empty hard lines in metadata", () => { + expect(getWrappedTextLines("A\n\nB", font, 100)).toEqual([ + { + text: "A", + start: 0, + end: 1, + }, + { + text: "", + start: 2, + end: 2, + }, + { + text: "B", + start: 3, + end: 4, + }, + ]); + }); + describe("When text is CJK", () => { it("should break each CJK character when width is very small", () => { // "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi" diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index ab2f4c708c..905c17048f 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -684,6 +684,11 @@ class App extends React.Component { lastPointerDownEvent: React.PointerEvent | null = null; lastPointerUpEvent: React.PointerEvent | PointerEvent | null = null; + // TODO this is a hack and we should ideally unify touch and pointer events + // and implement our own double click handling end-to-end (currently we're + // using a mix of native browser for click events and manual for touch - + // and browser doubleClick sucks to begin with) + lastPointerUpIsDoubleClick: boolean = false; lastPointerMoveEvent: PointerEvent | null = null; /** current frame pointer cords */ lastPointerMoveCoords: { x: number; y: number } | null = null; @@ -1438,6 +1443,21 @@ class App extends React.Component { return true; } + private isDoubleClick = ( + lastPointerEvent: + | PointerEvent + | React.PointerEvent + | undefined + | null, + currentPointerEvent: PointerEvent | React.PointerEvent, + ) => { + return ( + lastPointerEvent != null && + currentPointerEvent.timeStamp - lastPointerEvent.timeStamp <= + TAP_TWICE_TIMEOUT + ); + }; + private isIframeLikeElementCenter( el: ExcalidrawIframeLikeElement | null, event: React.PointerEvent | PointerEvent, @@ -5617,8 +5637,14 @@ class App extends React.Component { element: ExcalidrawTextElement, { isExistingElement = false, + initialCaretSceneCoords = null, }: { isExistingElement?: boolean; + /** + * supply null if no caret positioning is desired, and instead + * text should be auto-selected + */ + initialCaretSceneCoords?: { x: number; y: number } | null; }, ) { const elementsMap = this.scene.getElementsMapIncludingDeleted(); @@ -5721,6 +5747,7 @@ class App extends React.Component { element, excalidrawContainer: this.excalidrawContainerRef.current, app: this, + initialCaretSceneCoords, // when text is selected, it's hard (at least on iOS) to re-position the // caret (i.e. deselect). There's not much use for always selecting // the text on edit anyway (and users can select-all from contextmenu @@ -5744,6 +5771,68 @@ class App extends React.Component { }); } + private getSelectedTextElement( + container?: ExcalidrawTextContainer | null, + ): NonDeleted | null { + const selectedElements = this.scene.getSelectedElements(this.state); + + if (selectedElements.length !== 1) { + return null; + } + + const selectedElement = selectedElements[0]!; + + if (isTextElement(selectedElement)) { + return selectedElement; + } + + if (!container) { + return null; + } + + return getBoundTextElement( + selectedElement, + this.scene.getNonDeletedElementsMap(), + ); + } + + private getSelectedTextEditingContainerAtPosition( + hitElement: NonDeletedExcalidrawElement | null, + sceneCoords: { x: number; y: number }, + ): ExcalidrawTextContainer | null | undefined { + const selectedElements = this.scene.getSelectedElements(this.state); + + if ( + selectedElements.length !== 1 || + !hitElement || + hitElement.id !== selectedElements[0]!.id + ) { + return null; + } + + const selectedElement = selectedElements[0]!; + + if (isTextElement(selectedElement)) { + return null; + } + + if (!isValidTextContainer(selectedElement)) { + return undefined; + } + + const textElement = this.getSelectedTextElement(selectedElement); + const hitTextElement = this.getTextElementAtPosition( + sceneCoords.x, + sceneCoords.y, + ); + + if (!textElement || hitTextElement?.id !== textElement.id) { + return undefined; + } + + return selectedElement; + } + private getTextElementAtPosition( x: number, y: number, @@ -5969,6 +6058,7 @@ class App extends React.Component { insertAtParentCenter = true, container, autoEdit = true, + initialCaretSceneCoords, }: { /** X position to insert text at */ sceneX: number; @@ -5978,6 +6068,7 @@ class App extends React.Component { insertAtParentCenter?: boolean; container?: ExcalidrawTextContainer | null; autoEdit?: boolean; + initialCaretSceneCoords?: { x: number; y: number }; }) => { let shouldBindToContainer = false; @@ -5998,24 +6089,9 @@ class App extends React.Component { shouldBindToContainer = true; } } - let existingTextElement: NonDeleted | null = null; - - const selectedElements = this.scene.getSelectedElements(this.state); - - if (selectedElements.length === 1) { - if (isTextElement(selectedElements[0])) { - existingTextElement = selectedElements[0]; - } else if (container) { - existingTextElement = getBoundTextElement( - selectedElements[0], - this.scene.getNonDeletedElementsMap(), - ); - } else { - existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); - } - } else { - existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); - } + const existingTextElement = + this.getSelectedTextElement(container) || + this.getTextElementAtPosition(sceneX, sceneY); const fontFamily = existingTextElement?.fontFamily || this.state.currentItemFontFamily; @@ -6116,6 +6192,9 @@ class App extends React.Component { if (autoEdit || existingTextElement || container) { this.handleTextWysiwyg(element, { isExistingElement: !!existingTextElement, + initialCaretSceneCoords: existingTextElement + ? initialCaretSceneCoords + : null, }); } else { this.setState({ @@ -6144,6 +6223,9 @@ class App extends React.Component { private handleCanvasDoubleClick = ( event: React.MouseEvent, ) => { + if (this.state.editingTextElement) { + return; + } // case: double-clicking with arrow/line tool selected would both create // text and enter multiElement mode if (this.state.multiElement) { @@ -7680,6 +7762,10 @@ class App extends React.Component { } this.removePointer(event); + this.lastPointerUpIsDoubleClick = this.isDoubleClick( + this.lastPointerUpEvent, + event, + ); this.lastPointerUpEvent = event; if (!event.ctrlKey) { @@ -8516,6 +8602,7 @@ class App extends React.Component { insertAtParentCenter: !event.altKey, container, autoEdit: false, + initialCaretSceneCoords: { x: sceneX, y: sceneY }, }); resetCursor(this.interactiveCanvas); @@ -11001,6 +11088,35 @@ class App extends React.Component { return; } + const selectedTextEditingContainer = + this.getSelectedTextEditingContainerAtPosition(hitElement, sceneCoords); + + if ( + activeTool.type === this.state.preferredSelectionTool.type && + !this.state.editingTextElement && + !pointerDownState.drag.hasOccurred && + !pointerDownState.hit.wasAddedToSelection && + !childEvent.shiftKey && + !childEvent[KEYS.CTRL_OR_CMD] && + !childEvent.altKey && + childEvent.pointerType !== "touch" && + hitElement && + ((isTextElement(hitElement) && + this.state.selectedElementIds[hitElement.id] && + this.scene.getSelectedElements(this.state).length === 1) || + selectedTextEditingContainer) + ) { + this.startTextEditing({ + sceneX: sceneCoords.x, + sceneY: sceneCoords.y, + container: selectedTextEditingContainer, + initialCaretSceneCoords: this.lastPointerUpIsDoubleClick + ? undefined + : sceneCoords, + }); + return; + } + if (!activeTool.locked && activeTool.type !== "freedraw" && newElement) { this.setState((prevState) => ({ selectedElementIds: makeNextSelectedElementIds( diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx index a43a5fb600..283bdb40d3 100644 --- a/packages/excalidraw/components/Stats/stats.test.tsx +++ b/packages/excalidraw/components/Stats/stats.test.tsx @@ -361,12 +361,10 @@ describe("stats for a non-generic element", () => { mouse.clickAt(20, 30); const editor = await getTextEditor(); updateTextEditor(editor, "Hello!"); - act(() => { - editor.blur(); - }); + Keyboard.exitTextEditor(editor); const text = h.elements[0] as ExcalidrawTextElement; - mouse.clickOn(text); + API.setSelectedElements([text]); elementStats = stats?.querySelector("#elementStats"); diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx index 7d3da4d049..352b1a1529 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx @@ -232,6 +232,67 @@ describe("textWysiwyg", () => { expect(h.elements.length).toBe(1); }); + it("should edit selected bound text on single click", async () => { + const container = API.createElement({ + type: "rectangle", + width: 160, + height: 70, + boundElements: [], + }); + const text = API.createElement({ + type: "text", + text: "Hello World!", + x: container.x + 20, + y: container.y + 20, + width: 120, + height: 25, + containerId: container.id, + }); + + API.setElements([container, text]); + API.updateElement(container, { + boundElements: [{ type: "text", id: text.id }], + }); + API.setSelectedElements([container]); + UI.clickTool("selection"); + + mouse.clickAt(text.x + 26, text.y + 10); + + const editor = await getTextEditor(); + + expect(editor).not.toBe(null); + }); + + it("should not edit selected bound text container when only the container was single-clicked", async () => { + const container = API.createElement({ + type: "rectangle", + width: 160, + height: 70, + boundElements: [], + }); + const text = API.createElement({ + type: "text", + text: "Hello World!", + x: container.x + 20, + y: container.y + 20, + width: 120, + height: 25, + containerId: container.id, + }); + + API.setElements([container, text]); + API.updateElement(container, { + boundElements: [{ type: "text", id: text.id }], + }); + API.setSelectedElements([container]); + UI.clickTool("selection"); + + mouse.clickAt(container.x + 5, container.y + 10); + + expect(h.state.editingTextElement).toBe(null); + expect(await getTextEditor({ waitForEditor: false })).toBe(null); + }); + // FIXME too flaky. No one knows why. it.skip("should bump the version of a labeled arrow when the label is updated", async () => { const arrow = UI.createElement("arrow", { diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.tsx index 27086e07b9..262558727c 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.tsx @@ -10,7 +10,9 @@ import { isTestEnv, MIME_TYPES, applyDarkModeFilter, + isRTL, } from "@excalidraw/common"; +import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math"; import { getTextFromElements, @@ -33,8 +35,11 @@ import { getBoundTextElement, } from "@excalidraw/element"; import { getTextWidth } from "@excalidraw/element"; +import { getLineHeightInPx } from "@excalidraw/element"; +import { getLineWidth } from "@excalidraw/element"; import { normalizeText } from "@excalidraw/element"; import { wrapText } from "@excalidraw/element"; +import { getWrappedTextLines } from "@excalidraw/element"; import { isArrowElement, isBoundToContainer, @@ -91,6 +96,103 @@ const getTransform = ( return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`; }; +const getLineDirection = (text: string, offset: number) => { + const hardLineStart = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1; + const hardLineEnd = text.indexOf("\n", offset); + const hardLineText = text.slice( + hardLineStart, + hardLineEnd === -1 ? text.length : hardLineEnd, + ); + + return isRTL(hardLineText) ? "rtl" : "ltr"; +}; + +const getCaretBoundaryOffsets = (text: string) => { + const offsets = [0]; + let offset = 0; + + for (const char of Array.from(text)) { + offset += char.length; + offsets.push(offset); + } + + return offsets; +}; + +const getLineCaretOffsetFromNativeLayout = ({ + text, + font, + lineHeightPx, + direction, + targetX, +}: { + text: string; + font: ReturnType; + lineHeightPx: number; + direction: "ltr" | "rtl"; + targetX: number; +}) => { + if (!text || !document.body || typeof document.createRange !== "function") { + return null; + } + + const offsets = getCaretBoundaryOffsets(text); + const mirror = document.createElement("div"); + const textNode = document.createTextNode(text); + const range = document.createRange(); + const positions: number[] = []; + + mirror.dir = direction; + Object.assign(mirror.style, { + position: "fixed", + top: "0", + left: "0", + margin: 0, + padding: 0, + border: 0, + opacity: "0", + pointerEvents: "none", + whiteSpace: "pre", + font, + lineHeight: `${lineHeightPx}px`, + }); + mirror.append(textNode); + document.body.append(mirror); + + try { + for (const offset of offsets) { + range.setStart(textNode, offset); + range.setEnd(textNode, offset); + const caretRect = range.getBoundingClientRect(); + + if (!Number.isFinite(caretRect.left)) { + return null; + } + + positions.push(caretRect.left); + } + } catch { + return null; + } finally { + mirror.remove(); + } + + const leftEdge = Math.min(...positions); + let closestOffset = offsets[0]; + let closestDistance = Infinity; + + for (let index = 0; index < offsets.length; index++) { + const distance = Math.abs(positions[index] - leftEdge - targetX); + + if (distance < closestDistance) { + closestDistance = distance; + closestOffset = offsets[index]; + } + } + + return closestOffset; +}; + type SubmitHandler = () => void; export const textWysiwyg = ({ @@ -103,6 +205,7 @@ export const textWysiwyg = ({ excalidrawContainer, app, autoSelect = true, + initialCaretSceneCoords = null, }: { id: ExcalidrawElement["id"]; /** @@ -119,7 +222,19 @@ export const textWysiwyg = ({ excalidrawContainer: HTMLDivElement | null; app: App; autoSelect?: boolean; + initialCaretSceneCoords?: { x: number; y: number } | null; }): SubmitHandler => { + let currentTextLayout: { + angle: Radians; + font: ReturnType; + height: number; + lineHeightPx: number; + textAlign: ExcalidrawTextElement["textAlign"]; + width: number; + x: number; + y: number; + } | null = null; + const textPropertiesUpdated = ( updatedTextElement: ExcalidrawTextElement, editable: HTMLTextAreaElement, @@ -254,6 +369,7 @@ export const textWysiwyg = ({ height *= 1.05; const font = getFontString(updatedTextElement); + const angle = getTextElementAngle(updatedTextElement, container); // Make sure text editor height doesn't go beyond viewport const editorMaxHeight = @@ -269,7 +385,7 @@ export const textWysiwyg = ({ transform: getTransform( width, height, - getTextElementAngle(updatedTextElement, container), + angle, appState, maxWidth, editorMaxHeight, @@ -283,6 +399,19 @@ export const textWysiwyg = ({ opacity: updatedTextElement.opacity / 100, maxHeight: `${editorMaxHeight}px`, }); + currentTextLayout = { + angle: angle as Radians, + font, + height: updatedTextElement.height, + lineHeightPx: getLineHeightInPx( + updatedTextElement.fontSize, + updatedTextElement.lineHeight, + ), + textAlign, + width: updatedTextElement.width, + x: coordX, + y: coordY, + }; editable.scrollTop = 0; // For some reason updating font attribute doesn't set font family // hence updating font family explicitly for test environment @@ -333,6 +462,71 @@ export const textWysiwyg = ({ editable.value = element.originalText; updateWysiwygStyle(); + const getCaretIndexFromInitialSceneCoords = () => { + if (!initialCaretSceneCoords || !currentTextLayout) { + return null; + } + + const layout = currentTextLayout; + const center = pointFrom( + layout.x + layout.width / 2, + layout.y + layout.height / 2, + ); + const [unrotatedX, unrotatedY] = pointRotateRads( + pointFrom(initialCaretSceneCoords.x, initialCaretSceneCoords.y), + center, + -layout.angle as Radians, + ); + const localX = unrotatedX - layout.x; + const localY = unrotatedY - layout.y; + const lines = getWrappedTextLines( + editable.value, + layout.font, + whiteSpace === "pre-wrap" ? layout.width : Infinity, + ); + const lineIndex = Math.max( + 0, + Math.min(lines.length - 1, Math.floor(localY / layout.lineHeightPx)), + ); + const line = lines[lineIndex]; + const direction = getLineDirection(editable.value, line.start); + const lineWidth = getLineWidth(line.text, layout.font); + const lineStartX = + layout.textAlign === "center" + ? (layout.width - lineWidth) / 2 + : layout.textAlign === "right" + ? layout.width - lineWidth + : 0; + const relativeX = localX - lineStartX; + + if (!line.text) { + return line.start; + } + + const lineCaretOffset = getLineCaretOffsetFromNativeLayout({ + text: line.text, + font: layout.font, + lineHeightPx: layout.lineHeightPx, + direction, + targetX: relativeX, + }); + + return line.start + (lineCaretOffset || 0); + }; + + let pendingInitialSelection = (() => { + const caretIndex = getCaretIndexFromInitialSceneCoords(); + + if (caretIndex === null) { + return null; + } + + return { + start: caretIndex, + end: caretIndex, + }; + })(); + if (onChange) { editable.onpaste = async (event) => { // we need to synchronously get the MIME types so we can preventDefault() @@ -696,6 +890,13 @@ export const textWysiwyg = ({ // Otherwise, re-enable submit on blur and refocus the editor. editable.onblur = handleSubmit; editable.focus(); + if (pendingInitialSelection) { + editable.setSelectionRange( + pendingInitialSelection.start, + pendingInitialSelection.end, + ); + pendingInitialSelection = null; + } }); }; @@ -786,7 +987,7 @@ export const textWysiwyg = ({ let isDestroyed = false; - if (autoSelect) { + if (autoSelect && !pendingInitialSelection) { // select on init (focusing is done separately inside the bindBlurEvent() // because we need it to happen *after* the blur event from `pointerdown`) editable.select();