mirror of
https://github.com/penpot/penpot.git
synced 2025-12-12 06:24:17 +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,
|
beforeinput: this.#onBeforeInput,
|
||||||
input: this.#onInput,
|
input: this.#onInput,
|
||||||
|
keydown: this.#onKeyDown,
|
||||||
};
|
};
|
||||||
this.#styleDefaults = options?.styleDefaults;
|
this.#styleDefaults = options?.styleDefaults;
|
||||||
this.#setup(options);
|
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.
|
* Notifies that the edited texts needs layout.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function tryOffset(offset) {
|
|||||||
* @throws {TypeError}
|
* @throws {TypeError}
|
||||||
*/
|
*/
|
||||||
function tryString(str) {
|
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);
|
tryOffset(end);
|
||||||
return str.slice(0, start) + str.slice(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;
|
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.
|
* Maps any HTML into a valid content DOM element.
|
||||||
*
|
*
|
||||||
@@ -124,13 +170,8 @@ export function mapContentFragmentFromDocument(document, root, styleDefaults) {
|
|||||||
* @returns {DocumentFragment}
|
* @returns {DocumentFragment}
|
||||||
*/
|
*/
|
||||||
export function mapContentFragmentFromHTML(html, styleDefaults) {
|
export function mapContentFragmentFromHTML(html, styleDefaults) {
|
||||||
const parser = new DOMParser();
|
const plainText = htmlToText(html);
|
||||||
const htmlDocument = parser.parseFromString(html, "text/html");
|
return mapContentFragmentFromString(plainText, styleDefaults);
|
||||||
return mapContentFragmentFromDocument(
|
|
||||||
htmlDocument,
|
|
||||||
htmlDocument.documentElement,
|
|
||||||
styleDefaults,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import {
|
|||||||
replaceWith,
|
replaceWith,
|
||||||
insertInto,
|
insertInto,
|
||||||
removeSlice,
|
removeSlice,
|
||||||
|
findPreviousWordBoundary,
|
||||||
|
removeWordBackward,
|
||||||
} from "../content/Text.js";
|
} from "../content/Text.js";
|
||||||
import {
|
import {
|
||||||
getTextNodeLength,
|
getTextNodeLength,
|
||||||
@@ -502,7 +504,51 @@ export class SelectionController extends EventTarget {
|
|||||||
if (this.#textEditor.isEmpty) {
|
if (this.#textEditor.isEmpty) {
|
||||||
return this;
|
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;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1095,12 +1141,14 @@ export class SelectionController extends EventTarget {
|
|||||||
this.focusParagraph.after(fragment);
|
this.focusParagraph.after(fragment);
|
||||||
mergeParagraphs(a, b);
|
mergeParagraphs(a, b);
|
||||||
} else {
|
} else {
|
||||||
const newParagraph = splitParagraph(
|
if (this.isTextSpanStart) {
|
||||||
this.focusParagraph,
|
this.focusTextSpan.before(...fragment.firstElementChild.children);
|
||||||
this.focusTextSpan,
|
} else if (this.isTextSpanEnd) {
|
||||||
this.focusOffset,
|
this.focusTextSpan.after(...fragment.firstElementChild.children);
|
||||||
);
|
} else {
|
||||||
this.focusParagraph.after(fragment, newParagraph);
|
const newTextSpan = splitTextSpan(this.focusTextSpan, this.focusOffset);
|
||||||
|
this.focusTextSpan.after(...fragment.firstElementChild.children, newTextSpan);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (isLineBreak(collapseNode)) {
|
if (isLineBreak(collapseNode)) {
|
||||||
return this.collapse(collapseNode, 0);
|
return this.collapse(collapseNode, 0);
|
||||||
@@ -1218,6 +1266,79 @@ export class SelectionController extends EventTarget {
|
|||||||
return this.collapse(this.focusNode, this.focusOffset - 1);
|
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.
|
* Inserts some text in the caret position.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user