From 62f0a2278d8e320922e8ce0abfeb07fd8a6295b8 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Tue, 12 May 2026 22:32:44 +0100 Subject: [PATCH] =?UTF-8?q?fix(editor):=20prevent=20stuck=20list=20after?= =?UTF-8?q?=20pasting=20plain=20text=20marked.parse()=20emits=20a=20traili?= =?UTF-8?q?ng=20newline=20that=20became=20a=20whitespace=20text=20node=20a?= =?UTF-8?q?t=20the=20body=20level,=20which=20parseSlice=20converted=20into?= =?UTF-8?q?=20a=20spurious=20paragraph=20at=20the=20end=20of=20the=20targe?= =?UTF-8?q?t=20=E2=80=94=20inside=20a=20list=20item=20this=20blocked=20the?= =?UTF-8?q?=20"Enter=20exits=20list"=20behavior=20since=20splitListItem's?= =?UTF-8?q?=20empty-last-block=20check=20never=20fired.=20Strip=20whitespa?= =?UTF-8?q?ce-only=20text=20nodes=20between=20block=20elements=20before=20?= =?UTF-8?q?parsing=20the=20slice,=20and=20place=20the=20cursor=20at=20the?= =?UTF-8?q?=20end=20of=20the=20inserted=20content.=20Also=20extend=20trans?= =?UTF-8?q?formPasted=20to=20drop=20trailing=20hardBreaks=20and=20whitespa?= =?UTF-8?q?ce=20text=20nodes=20for=20the=20HTML-clipboard=20path.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/extensions/markdown-clipboard.ts | 53 +++++++++++++------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/apps/client/src/features/editor/extensions/markdown-clipboard.ts b/apps/client/src/features/editor/extensions/markdown-clipboard.ts index bebb567ab..ccbd70d03 100644 --- a/apps/client/src/features/editor/extensions/markdown-clipboard.ts +++ b/apps/client/src/features/editor/extensions/markdown-clipboard.ts @@ -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 "
...
\n...
\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 {