fix(editor): prevent stuck list after pasting plain text

marked.parse() emits a trailing newline that became a whitespace text
node at the body level, which parseSlice converted into a spurious
paragraph at the end of the target — inside a list item this blocked
the "Enter exits list" behavior since splitListItem's empty-last-block
check never fired.
Strip whitespace-only text nodes between block elements before parsing
the slice, and place the cursor at the end of the inserted content.
Also extend transformPasted to drop trailing hardBreaks and whitespace
text nodes for the HTML-clipboard path.
This commit is contained in:
Philipinho
2026-05-12 22:32:44 +01:00
parent a689cca7a0
commit 62f0a2278d
@@ -81,6 +81,7 @@ export const MarkdownClipboard = Extension.create({
const parsed = markdownToHtml(text.replace(/\n+$/, ""));
const body = elementFromString(parsed);
stripBlockLevelWhitespaceNodes(body);
normalizeTableColumnWidths(body);
const contentNodes = DOMParser.fromSchema(
@@ -91,7 +92,7 @@ export const MarkdownClipboard = Extension.create({
tr.replaceRange(from, to, contentNodes);
const insertEnd = tr.mapping.map(from, 1);
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1));
tr.setSelection(TextSelection.near(tr.doc.resolve(insertEnd), -1));
tr.setMeta('paste', true)
view.dispatch(tr);
return true;
@@ -104,21 +105,28 @@ export const MarkdownClipboard = Extension.create({
transformPasted: (slice) => {
let { content, openStart, openEnd } = slice;
// Remove trailing paragraphs that contain only whitespace
while (content.childCount > 1) {
const lastChild = content.lastChild;
if (
lastChild?.type.name === "paragraph" &&
lastChild.textContent.trim() === ""
) {
const children = [];
for (let i = 0; i < content.childCount - 1; i++) {
children.push(content.child(i));
}
content = Fragment.from(children);
} else {
break;
const isTrailingNoise = (node: any) => {
if (!node) return false;
if (node.type.name === "hardBreak") return true;
if (node.isText && (node.text ?? "").trim() === "") return true;
if (node.type.name === "paragraph") {
let onlyNoise = true;
node.content.forEach((c: any) => {
if (c.type.name === "hardBreak") return;
if (c.isText && (c.text ?? "").trim() === "") return;
onlyNoise = false;
});
return onlyNoise;
}
return false;
};
while (content.childCount > 1 && isTrailingNoise(content.lastChild)) {
const children = [];
for (let i = 0; i < content.childCount - 1; i++) {
children.push(content.child(i));
}
content = Fragment.from(children);
}
if (content !== slice.content) {
@@ -140,6 +148,21 @@ function elementFromString(value) {
return new window.DOMParser().parseFromString(wrappedValue, "text/html").body;
}
// marked.parse() emits "<p>...</p>\n<p>...</p>\n" — those literal newlines
// become whitespace text nodes that parseSlice (preserveWhitespace: true)
// converts into spurious empty paragraphs at the insertion site. Inside a
// list item the trailing one prevents Enter from exiting the list.
function stripBlockLevelWhitespaceNodes(body: HTMLElement): void {
Array.from(body.childNodes).forEach((node) => {
if (
node.nodeType === 3 /* TEXT_NODE */ &&
(node.textContent ?? "").trim() === ""
) {
body.removeChild(node);
}
});
}
const DEFAULT_PASTE_COL_WIDTH_PX = 150;
function parsePixelWidth(el: Element): number | null {