diff --git a/frontend/text-editor/src/editor/TextEditor.js b/frontend/text-editor/src/editor/TextEditor.js index 268f405581..92800d21f0 100644 --- a/frontend/text-editor/src/editor/TextEditor.js +++ b/frontend/text-editor/src/editor/TextEditor.js @@ -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. * diff --git a/frontend/text-editor/src/editor/clipboard/paste.js b/frontend/text-editor/src/editor/clipboard/paste.js index af472888bd..e4872e61e1 100644 --- a/frontend/text-editor/src/editor/clipboard/paste.js +++ b/frontend/text-editor/src/editor/clipboard/paste.js @@ -51,4 +51,4 @@ export function paste(event, editor, selectionController) { } else { selectionController.replaceWithPaste(fragment); } -} +} \ No newline at end of file diff --git a/frontend/text-editor/src/editor/content/Text.js b/frontend/text-editor/src/editor/content/Text.js index 9f06c2dcca..2e52e0d746 100644 --- a/frontend/text-editor/src/editor/content/Text.js +++ b/frontend/text-editor/src/editor/content/Text.js @@ -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); +} diff --git a/frontend/text-editor/src/editor/content/dom/Content.js b/frontend/text-editor/src/editor/content/dom/Content.js index 55aa7ad7f0..81485164a5 100644 --- a/frontend/text-editor/src/editor/content/dom/Content.js +++ b/frontend/text-editor/src/editor/content/dom/Content.js @@ -116,6 +116,52 @@ export function mapContentFragmentFromDocument(document, root, styleDefaults) { return fragment; } +/** + * Converts HTML to plain text, preserving line breaks from
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); } /** diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js index 4dc8979542..f61924455c 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.js @@ -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. *