diff --git a/frontend/text-editor/src/editor/clipboard/paste.test.js b/frontend/text-editor/src/editor/clipboard/paste.test.js new file mode 100644 index 0000000000..14dd7a9ac0 --- /dev/null +++ b/frontend/text-editor/src/editor/clipboard/paste.test.js @@ -0,0 +1,67 @@ +import { describe, test, expect } from "vitest"; +import { TextEditorMock } from "../../test/TextEditorMock.js"; +import { SelectionController } from "../controllers/SelectionController.js"; +import { paste } from "./paste.js"; + +/* @vitest-environment jsdom */ + +/** + * Creates a minimal `ClipboardEvent`-like object carrying plain text. + * + * @param {string} text + * @returns {object} + */ +function createPlainTextClipboardEvent(text) { + return { + preventDefault() {}, + clipboardData: { + types: ["text/plain"], + getData(type) { + return type === "text/plain" ? text : ""; + }, + }, + }; +} + +describe("paste", () => { + test("should insert plain text into an empty editor that was just focused", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText(""); + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + textEditorMock.element.focus(); + + paste( + createPlainTextClipboardEvent("Hello, World!"), + textEditorMock, + selectionController, + ); + + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + }); + + test("should insert plain text when the caret is on a paragraph element", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText("Hello, "); + const root = textEditorMock.root; + const paragraph = root.firstChild; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + textEditorMock.element.focus(); + selection.setBaseAndExtent(paragraph, 1, paragraph, 1); + document.dispatchEvent(new Event("selectionchange")); + + paste( + createPlainTextClipboardEvent("World!"), + textEditorMock, + selectionController, + ); + + expect(root.textContent).toBe("Hello, World!"); + }); +}); diff --git a/frontend/text-editor/src/editor/content/dom/TextNode.js b/frontend/text-editor/src/editor/content/dom/TextNode.js index 25d484d6f4..24b2fe93c3 100644 --- a/frontend/text-editor/src/editor/content/dom/TextNode.js +++ b/frontend/text-editor/src/editor/content/dom/TextNode.js @@ -62,3 +62,56 @@ export function getClosestTextNode(node) { if (isEditor(node)) return node.firstChild.firstChild.firstChild.firstChild; throw new Error("Cannot find a text node"); } + +/** + * @typedef {Object} TextNodePosition + * @property {Text|HTMLBRElement} node + * @property {number} offset + */ + +/** + * Resolves a (node, offset) pair to an equivalent position on a text node + * or a line break. + * + * Browsers are free to report a caret on a container element, in which case + * the offset is a child index instead of a character index (Firefox does this + * routinely, e.g. on empty paragraphs). This function walks down the content + * tree to the addressed descendant so callers can always work with text + * nodes. + * + * Unlike `getClosestTextNode`, this never throws: it returns `null` when the + * position cannot be resolved, letting the caller decide the fallback. + * + * @param {Node} node + * @param {number} [offset=0] + * @returns {TextNodePosition|null} + */ +export function resolveTextNodePosition(node, offset = 0) { + if (!node) return null; + if (node.nodeType === Node.TEXT_NODE || isLineBreak(node)) { + return { node, offset }; + } + + if (isTextSpan(node)) { + // Within a text span the children are text nodes or a line break, so an + // index past the last child means "at the end of the last child". + const child = node.childNodes[offset]; + if (child) return resolveTextNodePosition(child, 0); + const lastChild = node.lastChild; + if (!lastChild) return null; + if (lastChild.nodeType !== Node.TEXT_NODE && !isLineBreak(lastChild)) { + return null; + } + return resolveTextNodePosition(lastChild, getTextNodeLength(lastChild)); + } + + if (isParagraph(node) || isRoot(node) || isEditor(node)) { + const child = node.children[offset]; + if (child) return resolveTextNodePosition(child, 0); + const lastChild = node.lastElementChild; + if (!lastChild) return null; + return resolveTextNodePosition(lastChild, lastChild.childNodes.length); + } + + return null; +} diff --git a/frontend/text-editor/src/editor/content/dom/TextNode.test.js b/frontend/text-editor/src/editor/content/dom/TextNode.test.js index fc44374b85..166da09d07 100644 --- a/frontend/text-editor/src/editor/content/dom/TextNode.test.js +++ b/frontend/text-editor/src/editor/content/dom/TextNode.test.js @@ -1,6 +1,13 @@ import { describe, test, expect } from "vitest"; -import { isTextNode, getTextNodeLength } from "./TextNode.js"; +import { + isTextNode, + getTextNodeLength, + resolveTextNodePosition, +} from "./TextNode.js"; import { createLineBreak } from "./LineBreak.js"; +import { createTextSpan, createEmptyTextSpan } from "./TextSpan.js"; +import { createParagraph } from "./Paragraph.js"; +import { createRoot } from "./Root.js"; /* @vitest-environment jsdom */ describe("TextNode", () => { @@ -25,4 +32,104 @@ describe("TextNode", () => { expect(() => getTextNodeLength(null)).toThrowError("Invalid text node"); expect(() => getTextNodeLength(0)).toThrowError("Invalid text node"); }); + + describe("resolveTextNodePosition", () => { + test("should return the same position when the node is already a text node", () => { + const textNode = new Text("Hello, World!"); + expect(resolveTextNodePosition(textNode, 5)).toStrictEqual({ + node: textNode, + offset: 5, + }); + }); + + test("should return the same position when the node is a line break", () => { + const lineBreak = createLineBreak(); + expect(resolveTextNodePosition(lineBreak, 0)).toStrictEqual({ + node: lineBreak, + offset: 0, + }); + }); + + test("should resolve a text span to its child at the given index", () => { + const textNode = new Text("Hello"); + const textSpan = createTextSpan(textNode); + expect(resolveTextNodePosition(textSpan, 0)).toStrictEqual({ + node: textNode, + offset: 0, + }); + }); + + test("should resolve a text span index past the last child to the end of its text", () => { + const textNode = new Text("Hello"); + const textSpan = createTextSpan(textNode); + expect(resolveTextNodePosition(textSpan, 1)).toStrictEqual({ + node: textNode, + offset: 5, + }); + }); + + test("should resolve a paragraph to the text node of the indexed text span", () => { + const first = new Text("Hello, "); + const second = new Text("World!"); + const paragraph = createParagraph([ + createTextSpan(first), + createTextSpan(second), + ]); + expect(resolveTextNodePosition(paragraph, 0)).toStrictEqual({ + node: first, + offset: 0, + }); + expect(resolveTextNodePosition(paragraph, 1)).toStrictEqual({ + node: second, + offset: 0, + }); + expect(resolveTextNodePosition(paragraph, 2)).toStrictEqual({ + node: second, + offset: 6, + }); + }); + + test("should resolve an empty paragraph to its line break", () => { + const textSpan = createEmptyTextSpan(); + const paragraph = createParagraph([textSpan]); + expect(resolveTextNodePosition(paragraph, 0)).toStrictEqual({ + node: textSpan.firstChild, + offset: 0, + }); + }); + + test("should resolve a root to the text node of the indexed paragraph", () => { + const first = new Text("Hello, "); + const second = new Text("World!"); + const root = createRoot([ + createParagraph([createTextSpan(first)]), + createParagraph([createTextSpan(second)]), + ]); + expect(resolveTextNodePosition(root, 1)).toStrictEqual({ + node: second, + offset: 0, + }); + }); + + test("should resolve an editor element to the first text node of its root", () => { + const textNode = new Text("Hello"); + const root = createRoot([createParagraph([createTextSpan(textNode)])]); + const editor = document.createElement("div"); + editor.dataset.itype = "editor"; + editor.appendChild(root); + expect(resolveTextNodePosition(editor, 0)).toStrictEqual({ + node: textNode, + offset: 0, + }); + }); + + test("should return null instead of throwing when the position cannot be resolved", () => { + expect(resolveTextNodePosition(null, 0)).toBe(null); + expect(resolveTextNodePosition(undefined, 0)).toBe(null); + expect(resolveTextNodePosition(document.createElement("div"), 0)).toBe( + null, + ); + expect(resolveTextNodePosition(createParagraph([]), 0)).toBe(null); + }); + }); }); diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js index 371d94e99f..4ba0b4655a 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.js @@ -46,6 +46,7 @@ import { getTextNodeLength, getClosestTextNode, isTextNode, + resolveTextNodePosition, } from "../content/dom/TextNode.js"; import TextNodeIterator from "../content/dom/TextNodeIterator.js"; import TextEditor from "../TextEditor.js"; @@ -537,6 +538,14 @@ export class SelectionController extends EventTarget { */ selectAll() { if (this.#textEditor.isEmpty) { + // There is nothing to select, but we still need a valid caret: leaving + // the selection untouched keeps `focusNode` null and makes any later + // insertion (typing, pasting) fail. + const lineBreak = + this.#textEditor.root?.firstElementChild?.firstElementChild?.firstChild; + if (lineBreak) { + this.collapse(lineBreak, 0); + } return this; } @@ -1132,6 +1141,10 @@ export class SelectionController extends EventTarget { * @param {DocumentFragment} fragment */ insertPaste(fragment) { + if (this.isCollapsed && !this.#normalizeFocus()) { + return; + } + const hasOnlyOneParagraph = fragment.children.length === 1; const forceTextSpan = fragment.firstElementChild?.dataset?.textSpan === "force"; @@ -1395,6 +1408,33 @@ export class SelectionController extends EventTarget { return this.collapse(this.focusNode, this.focusOffset + newText.length); } + /** + * Moves the caret to an equivalent position on a text node or a line break. + * + * The browser can report the caret on a container element (with the offset + * being a child index) or, when the editor was focused without any content, + * on nothing at all. Both states break every insertion path, which expects + * the focus node to be a text node or a
. + * + * @returns {boolean} true when the focus is usable. + */ + #normalizeFocus() { + if (this.isTextFocus || this.isLineBreakFocus) { + return true; + } + + const position = + resolveTextNodePosition(this.focusNode, this.focusOffset) ?? + resolveTextNodePosition(this.#textEditor.root, 0); + + if (!position?.node?.isConnected) { + return false; + } + + this.collapse(position.node, position.offset); + return true; + } + /** * Replaces the currently focus element * with some text. @@ -1402,6 +1442,10 @@ export class SelectionController extends EventTarget { * @param {string} newText */ insertIntoFocus(newText) { + if (!this.#normalizeFocus()) { + return; + } + if (this.isTextFocus) { this.focusNode.nodeValue = insertInto( this.focusNode.nodeValue, diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.test.js b/frontend/text-editor/src/editor/controllers/SelectionController.test.js index 533e4c751c..662119ed29 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.test.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.test.js @@ -1706,6 +1706,96 @@ describe("SelectionController", () => { ); }); + test("`selectAll` should collapse the caret on the line break when the editor is empty", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText(""); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + textEditorMock.element.focus(); + selectionController.selectAll(); + expect(selectionController.focusNode).toBe( + root.firstChild.firstChild.firstChild, + ); + expect(selectionController.isCollapsed).toBe(true); + }); + + test("`insertIntoFocus` should insert text when the focus node is a paragraph", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText("Hello, "); + const root = textEditorMock.root; + const paragraph = root.firstChild; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus(selection, textEditorMock, paragraph, 1); + selectionController.insertIntoFocus("World!"); + expect(root.textContent).toBe("Hello, World!"); + }); + + test("`insertIntoFocus` should insert text when the focus node is the root", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText("Hello, "); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus(selection, textEditorMock, root, 1); + selectionController.insertIntoFocus("World!"); + expect(root.textContent).toBe("Hello, World!"); + }); + + test("`insertIntoFocus` should insert text when the focus node is the editor element", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText("Hello, "); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus(selection, textEditorMock, textEditorMock.element, 0); + selectionController.insertIntoFocus("World!"); + expect(root.textContent).toBe("World!Hello, "); + }); + + test("`insertIntoFocus` should insert text when there is no known focus node", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText(""); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + textEditorMock.element.focus(); + expect(selectionController.focusNode).toBe(null); + selectionController.insertIntoFocus("Hello, World!"); + expect(root.textContent).toBe("Hello, World!"); + }); + + test("`insertPaste` should insert a fragment when the focus node is a paragraph", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText(", World!"); + const root = textEditorMock.root; + const paragraph = root.firstChild; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus(selection, textEditorMock, paragraph, 0); + const fragment = document.createDocumentFragment(); + fragment.append(createParagraphWith(["Hello"])); + selectionController.insertPaste(fragment); + expect(root.textContent).toBe("Hello, World!"); + }); + test("`cursorToEnd` should move cursor to the end", () => { const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ createParagraphWith(["Hello, "], { diff --git a/frontend/text-editor/src/test/TextEditorMock.js b/frontend/text-editor/src/test/TextEditorMock.js index 0e20d209e7..457d184eb1 100644 --- a/frontend/text-editor/src/test/TextEditorMock.js +++ b/frontend/text-editor/src/test/TextEditorMock.js @@ -4,7 +4,10 @@ import { createEmptyTextSpan, createTextSpan, } from "../editor/content/dom/TextSpan.js"; -import { createLineBreak } from "../editor/content/dom/LineBreak.js"; +import { + createLineBreak, + isLineBreak, +} from "../editor/content/dom/LineBreak.js"; export class TextEditorMock extends EventTarget { /** @@ -135,6 +138,7 @@ export class TextEditorMock extends EventTarget { this.#element = element; this.#root = options?.root; this.#selectionImposterElement = options?.selectionImposterElement; + this.#element.dataset.itype = "editor"; this.#element.appendChild(options?.root); } @@ -145,6 +149,14 @@ export class TextEditorMock extends EventTarget { get root() { return this.#root; } + + get isEmpty() { + return ( + this.#root.children.length === 1 && + this.#root.firstElementChild.children.length === 1 && + isLineBreak(this.#root.firstElementChild.firstElementChild.firstChild) + ); + } } export default TextEditorMock;