mirror of
https://github.com/penpot/penpot.git
synced 2025-12-11 22:14:05 +01:00
🐛 Fix text editor select all functionality and inner paste corner cases
This commit is contained in:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -51,4 +51,4 @@ export function paste(event, editor, selectionController) {
|
||||
} else {
|
||||
selectionController.replaceWithPaste(fragment);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user