🐛 Fix text editor select all functionality and inner paste corner cases

This commit is contained in:
Elena Torro
2025-11-17 15:03:42 +01:00
parent 30413dbc66
commit 368f4cfe81
5 changed files with 272 additions and 16 deletions

View File

@@ -106,6 +106,7 @@ export class TextEditor extends EventTarget {
beforeinput: this.#onBeforeInput,
input: this.#onInput,
keydown: this.#onKeyDown,
};
this.#styleDefaults = options?.styleDefaults;
this.#setup(options);
@@ -363,6 +364,36 @@ export class TextEditor extends EventTarget {
}
};
/**
* Handles keydown events
*
* @param {KeyboardEvent} e
*/
#onKeyDown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
e.preventDefault();
this.selectAll();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === "Backspace") {
e.preventDefault();
if (!this.#selectionController.startMutation()) {
return;
}
if (this.#selectionController.isCollapsed) {
this.#selectionController.removeWordBackward();
} else {
this.#selectionController.removeSelected();
}
const mutations = this.#selectionController.endMutation();
this.#notifyLayout(LayoutType.FULL, mutations);
}
};
/**
* Notifies that the edited texts needs layout.
*

View File

@@ -24,7 +24,7 @@ function tryOffset(offset) {
* @throws {TypeError}
*/
function tryString(str) {
if (typeof str !== "string") throw new TypeError("Invalid string");
if (typeof str !== "string") throw new TypeError(`Invalid string ${str}`);
}
/**
@@ -102,3 +102,66 @@ export function removeSlice(str, start, end) {
tryOffset(end);
return str.slice(0, start) + str.slice(end);
}
/**
* Finds the start of the previous word from the given offset.
* Word boundaries are defined by whitespace and punctuation.
*
* @param {string} str
* @param {number} offset
* @returns {number}
*/
export function findPreviousWordBoundary(str, offset) {
if (str == null) {
return 0;
}
tryString(str);
tryOffset(offset);
if (offset === 0) {
return 0;
}
// Start from the character before the cursor
let pos = offset - 1;
// Skip any whitespace characters
while (pos >= 0 && /\s/.test(str[pos])) {
pos--;
}
// If we're now at a non-word character, skip all non-word characters
if (pos >= 0 && /\W/.test(str[pos]) && !/\s/.test(str[pos])) {
while (pos >= 0 && /\W/.test(str[pos]) && !/\s/.test(str[pos])) {
pos--;
}
}
// Otherwise, skip all word characters
else if (pos >= 0 && /\w/.test(str[pos])) {
while (pos >= 0 && /\w/.test(str[pos])) {
pos--;
}
}
return pos + 1;
}
/**
* Removes a word backward from specified offset.
*
* @param {string} str
* @param {number} offset
* @returns {string}
*/
export function removeWordBackward(str, offset) {
if (str == null) {
return "";
}
tryString(str);
tryOffset(offset);
const wordStart = findPreviousWordBoundary(str, offset);
return str.slice(0, wordStart) + str.slice(offset);
}

View File

@@ -116,6 +116,52 @@ export function mapContentFragmentFromDocument(document, root, styleDefaults) {
return fragment;
}
/**
* Converts HTML to plain text, preserving line breaks from <br> tags and block elements.
*
* @param {string} html - The HTML string to convert
* @returns {string} Plain text with preserved line breaks
*/
export function htmlToText(html) {
const tmp = document.createElement("div");
tmp.innerHTML = html;
const blockTags = [
"P", "DIV", "SECTION", "ARTICLE", "HEADER", "FOOTER",
"UL", "OL", "LI", "TABLE", "TR", "TD", "TH", "PRE"
];
function walk(node) {
let text = "";
node.childNodes.forEach(child => {
if (child.nodeType === Node.TEXT_NODE) {
text += child.textContent;
} else if (child.nodeType === Node.ELEMENT_NODE) {
if (child.tagName === "BR") {
text += "\n";
}
if (blockTags.includes(child.tagName)) {
text += "\n" + walk(child) + "\n";
return;
}
text += walk(child);
}
});
return text;
}
let result = walk(tmp);
result = result.replace(/\n{3,}/g, "\n\n");
return result.trim();
}
/**
* Maps any HTML into a valid content DOM element.
*
@@ -124,13 +170,8 @@ export function mapContentFragmentFromDocument(document, root, styleDefaults) {
* @returns {DocumentFragment}
*/
export function mapContentFragmentFromHTML(html, styleDefaults) {
const parser = new DOMParser();
const htmlDocument = parser.parseFromString(html, "text/html");
return mapContentFragmentFromDocument(
htmlDocument,
htmlDocument.documentElement,
styleDefaults,
);
const plainText = htmlToText(html);
return mapContentFragmentFromString(plainText, styleDefaults);
}
/**

View File

@@ -39,6 +39,8 @@ import {
replaceWith,
insertInto,
removeSlice,
findPreviousWordBoundary,
removeWordBackward,
} from "../content/Text.js";
import {
getTextNodeLength,
@@ -502,7 +504,51 @@ export class SelectionController extends EventTarget {
if (this.#textEditor.isEmpty) {
return this;
}
this.#selection.selectAllChildren(this.#textEditor.root);
// Find the first and last text nodes to create a proper text selection
// instead of selecting container elements
const root = this.#textEditor.root;
const firstParagraph = root.firstElementChild;
const lastParagraph = root.lastElementChild;
if (!firstParagraph || !lastParagraph) {
return this;
}
const firstTextSpan = firstParagraph.firstElementChild;
const lastTextSpan = lastParagraph.lastElementChild;
if (!firstTextSpan || !lastTextSpan) {
return this;
}
const firstTextNode = firstTextSpan.firstChild;
const lastTextNode = lastTextSpan.lastChild;
if (!firstTextNode || !lastTextNode) {
return this;
}
// Create a range from first text node to last text node
const range = document.createRange();
range.setStart(firstTextNode, 0);
range.setEnd(lastTextNode, lastTextNode.nodeValue?.length || 0);
this.#selection.removeAllRanges();
this.#selection.addRange(range);
// Ensure internal state is synchronized
this.#focusNode = this.#selection.focusNode;
this.#focusOffset = this.#selection.focusOffset;
this.#anchorNode = this.#selection.anchorNode;
this.#anchorOffset = this.#selection.anchorOffset;
this.#range = range;
this.#ranges.clear();
this.#ranges.add(range);
// Notify style changes
this.#notifyStyleChange();
return this;
}
@@ -1095,12 +1141,14 @@ export class SelectionController extends EventTarget {
this.focusParagraph.after(fragment);
mergeParagraphs(a, b);
} else {
const newParagraph = splitParagraph(
this.focusParagraph,
this.focusTextSpan,
this.focusOffset,
);
this.focusParagraph.after(fragment, newParagraph);
if (this.isTextSpanStart) {
this.focusTextSpan.before(...fragment.firstElementChild.children);
} else if (this.isTextSpanEnd) {
this.focusTextSpan.after(...fragment.firstElementChild.children);
} else {
const newTextSpan = splitTextSpan(this.focusTextSpan, this.focusOffset);
this.focusTextSpan.after(...fragment.firstElementChild.children, newTextSpan);
}
}
if (isLineBreak(collapseNode)) {
return this.collapse(collapseNode, 0);
@@ -1218,6 +1266,79 @@ export class SelectionController extends EventTarget {
return this.collapse(this.focusNode, this.focusOffset - 1);
}
/**
* Removes word backward from the current caret position.
*/
removeWordBackward() {
if (!this.isCollapsed) {
return this.removeSelected();
}
this.#textNodeIterator.currentNode = this.focusNode;
const originalNodeValue = this.focusNode.nodeValue || "";
const wordStart = findPreviousWordBoundary(originalNodeValue, this.focusOffset);
// Start node
if (wordStart === this.focusOffset && this.focusOffset === 0) {
if (this.focusTextSpan.previousElementSibling) {
const prevTextSpan = this.focusTextSpan.previousElementSibling;
const prevTextNode = prevTextSpan.lastChild;
if (prevTextNode && prevTextNode.nodeType === Node.TEXT_NODE) {
this.collapse(prevTextNode, prevTextNode.nodeValue.length);
return this.removeWordBackward();
}
} else if (this.focusParagraph.previousElementSibling) {
// Move to previous paragraph
const prevParagraph = this.focusParagraph.previousElementSibling;
const prevTextSpan = prevParagraph.lastElementChild;
const prevTextNode = prevTextSpan?.lastChild;
if (prevTextNode && prevTextNode.nodeType === Node.TEXT_NODE) {
this.collapse(prevTextNode, prevTextNode.nodeValue.length);
return this.removeWordBackward();
} else {
return this.mergeBackwardParagraph();
}
}
return this;
}
const removedData = removeWordBackward(originalNodeValue, this.focusOffset);
if (this.focusNode.nodeValue !== removedData) {
this.focusNode.nodeValue = removedData;
this.#mutations.update(this.focusTextSpan);
}
const paragraph = this.focusParagraph;
if (!paragraph) throw new Error("Cannot find paragraph");
const textSpan = this.focusTextSpan;
if (!textSpan) throw new Error("Cannot find text span");
// If the text node is empty, handle cleanup
if (this.focusNode.nodeValue === "") {
const previousTextNode = this.#textNodeIterator.previousNode();
this.focusNode.remove();
if (paragraph.children.length === 1 && textSpan.childNodes.length === 0) {
const lineBreak = createLineBreak();
textSpan.appendChild(lineBreak);
return this.collapse(lineBreak, 0);
} else if (
paragraph.children.length > 1 &&
textSpan.childNodes.length === 0
) {
textSpan.remove();
return this.collapse(
previousTextNode,
getTextNodeLength(previousTextNode),
);
}
}
return this.collapse(this.focusNode, wordStart);
}
/**
* Inserts some text in the caret position.
*