From b22d99cbb2090870d1b8008ebe6ca75e02fe91d7 Mon Sep 17 00:00:00 2001 From: wintan1418 Date: Thu, 30 Jul 2026 19:36:59 +0100 Subject: [PATCH 1/2] Keep Shift+Enter line breaks in list items across save and reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lexical drops a
that ends a block element on import, treating it as contenteditable filler. Line breaks Lexxy exports at the end of a list item are real content the user added with Shift+Enter, so re-opening a saved document silently collapsed the spacing between list items. Register an html.import override for
that keeps a trailing line break in a list item when it follows actual content. A
that is the item's only child remains subject to the default filler rules. Pasted content is the one place where a trailing
in a list item really is filler — browsers pad copied list items with one — so PastedContentFormatter now strips those before import, preserving the established paste canonicalization behavior. Fixes #1004 --- .../contents/pasted_content_formatter.js | 11 +++++ src/elements/editor.js | 5 +- src/helpers/lexical_helper.js | 20 +++++++- .../formatting/list_item_line_breaks.test.js | 47 +++++++++++++++++++ 4 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 test/browser/tests/formatting/list_item_line_breaks.test.js diff --git a/src/editor/contents/pasted_content_formatter.js b/src/editor/contents/pasted_content_formatter.js index 1cade9625..8fad1bb70 100644 --- a/src/editor/contents/pasted_content_formatter.js +++ b/src/editor/contents/pasted_content_formatter.js @@ -13,6 +13,7 @@ export default class PastedContentFormatter { this.#unwrapWrappedListChildren() this.#nestStrayListChildren() this.#stripStrayListChildren() + this.#stripFillerListItemLineBreaks() return this.doc } @@ -110,6 +111,16 @@ export default class PastedContentFormatter { } } + // Browsers pad copied list items with a filler
at the end of each
  • . + // The editor preserves trailing list item line breaks on import — in saved + // documents they are real Shift+Enter content — so pasted filler has to be + // dropped here before it reaches the importer. + #stripFillerListItemLineBreaks() { + for (const lineBreak of this.doc.querySelectorAll("li > br:last-child")) { + lineBreak.remove() + } + } + #firstStrayListChild(list) { for (const child of list.childNodes) { if (child.nodeType !== Node.ELEMENT_NODE || child.tagName !== "LI") { diff --git a/src/elements/editor.js b/src/elements/editor.js index 7a9564df2..39f19820b 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -19,7 +19,7 @@ import { UploadRequests } from "../editor/attachments/upload_requests" import { CommandDispatcher } from "../editor/command_dispatcher" import Selection from "../editor/selection" import { createElement, dispatch, generateDomId, parseHtml } from "../helpers/html_helper" -import { isAttachmentSpacerTextNode, isEditorFocused } from "../helpers/lexical_helper" +import { importListItemTrailingLineBreak, isAttachmentSpacerTextNode, isEditorFocused } from "../helpers/lexical_helper" import { sanitize, setSanitizerConfig } from "../helpers/sanitization_helper" import { ListenerBin, registerEventListener } from "../helpers/listener_helper" import LexicalToolbar from "./toolbar" @@ -426,7 +426,8 @@ export class LexicalEditorElement extends HTMLElement { theme: theme, nodes: this.#lexicalNodes, html: { - export: new Map([ [ TextNode, exportTextNodeDOM ], [ CodeHighlightNode, exportTextNodeDOM ] ]) + export: new Map([ [ TextNode, exportTextNodeDOM ], [ CodeHighlightNode, exportTextNodeDOM ] ]), + import: { br: importListItemTrailingLineBreak } }, $initialEditorState: (editor) => { this.#configureSanitizer(editor) diff --git a/src/helpers/lexical_helper.js b/src/helpers/lexical_helper.js index b97369c5e..440be65af 100644 --- a/src/helpers/lexical_helper.js +++ b/src/helpers/lexical_helper.js @@ -1,4 +1,4 @@ -import { $caretFromPoint, $createNodeSelection, $createParagraphNode, $findMatchingParent, $getCaretInDirection, $getCaretRange, $getChildCaret, $getCommonAncestor, $getRoot, $getSelection, $getSiblingCaret, $isChildCaret, $isDecoratorNode, $isElementNode, $isExtendableTextPointCaret, $isLineBreakNode, $isParagraphNode, $isRangeSelection, $isRootNode, $isRootOrShadowRoot, $isSiblingCaret, $isTextNode, $isTextPointCaret, $normalizeCaret, $normalizeSelection__EXPERIMENTAL as $normalizeSelection, $rewindSiblingCaret, $setSelectionFromCaretRange, $splitAtPointCaretNext, TextNode } from "lexical" +import { $caretFromPoint, $createLineBreakNode, $createNodeSelection, $createParagraphNode, $findMatchingParent, $getCaretInDirection, $getCaretRange, $getChildCaret, $getCommonAncestor, $getRoot, $getSelection, $getSiblingCaret, $isChildCaret, $isDecoratorNode, $isElementNode, $isExtendableTextPointCaret, $isLineBreakNode, $isParagraphNode, $isRangeSelection, $isRootNode, $isRootOrShadowRoot, $isSiblingCaret, $isTextNode, $isTextPointCaret, $normalizeCaret, $normalizeSelection__EXPERIMENTAL as $normalizeSelection, $rewindSiblingCaret, $setSelectionFromCaretRange, $splitAtPointCaretNext, TextNode } from "lexical" import { ListNode } from "@lexical/list" import { $getNearestNodeOfType, $lastToFirstIterator } from "@lexical/utils" import { $wrapNodeInElement } from "@lexical/utils" @@ -94,6 +94,24 @@ export function extendConversion(nodeKlass, conversionName, callback = (output = } } +// Lexical drops a
    that ends a block element on import, treating it as +// contenteditable filler. A line break at the end of a list item is real content +// the user added with Shift+Enter, so keep it. A
    that is a list item's only +// child stays subject to the default filler rules. +export function importListItemTrailingLineBreak(domNode) { + if (isTrailingLineBreakAfterListItemContent(domNode)) { + return { conversion: () => ({ node: $createLineBreakNode() }), priority: 1 } + } + + return null +} + +function isTrailingLineBreakAfterListItemContent(domNode) { + const parent = domNode.parentElement + return parent !== null && parent.tagName === "LI" && + domNode.nextSibling === null && domNode.previousSibling !== null +} + export function $isCursorOnLastLine(selection) { const anchorNode = selection.anchor.getNode() const elementNode = $isElementNode(anchorNode) ? anchorNode : anchorNode.getParentOrThrow() diff --git a/test/browser/tests/formatting/list_item_line_breaks.test.js b/test/browser/tests/formatting/list_item_line_breaks.test.js new file mode 100644 index 000000000..29dc88ae7 --- /dev/null +++ b/test/browser/tests/formatting/list_item_line_breaks.test.js @@ -0,0 +1,47 @@ +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" +import { assertEditorHtml } from "../../helpers/assertions.js" + +test.describe("Line breaks inside list items", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForSelector("lexxy-editor[connected]") + }) + + test("a Shift+Enter line break at the end of a list item survives a value round-trip", async ({ editor }) => { + await editor.setValue("") + await editor.flush() + + await editor.placeCaretInside("First", "First".length) + await editor.send("Shift+Enter") + + const saved = await editor.value() + expect(saved).toContain("
    ") + + await editor.setValue(saved) + await editor.flush() + + await assertEditorHtml(editor, saved) + }) + + test("importing a list item with a trailing line break keeps it", async ({ editor }) => { + await editor.setValue("") + await editor.flush() + + await assertEditorHtml(editor, "") + }) + + test("importing a list item with a blank line keeps it", async ({ editor }) => { + await editor.setValue("") + await editor.flush() + + await assertEditorHtml(editor, "") + }) + + test("a filler line break in an empty list item is still dropped", async ({ editor }) => { + await editor.setValue("") + await editor.flush() + + await assertEditorHtml(editor, "") + }) +}) From bd2d24cb16169882211bcaa2e9f3dead5050a212 Mon Sep 17 00:00:00 2001 From: wintan1418 Date: Thu, 30 Jul 2026 20:07:50 +0100 Subject: [PATCH 2/2] Only count real content when preserving a trailing list item break A previous sibling of any kind was treated as content, so a trailing
    preceded only by whitespace text (pretty-printed HTML) or another
    imported as a real line break in an otherwise empty item. Walk the preceding siblings and require a non-whitespace text node or a non-
    element before keeping the break. --- src/helpers/lexical_helper.js | 25 +++++++++++++++++-- .../formatting/list_item_line_breaks.test.js | 7 ++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/helpers/lexical_helper.js b/src/helpers/lexical_helper.js index 440be65af..05dc8aaa3 100644 --- a/src/helpers/lexical_helper.js +++ b/src/helpers/lexical_helper.js @@ -108,8 +108,29 @@ export function importListItemTrailingLineBreak(domNode) { function isTrailingLineBreakAfterListItemContent(domNode) { const parent = domNode.parentElement - return parent !== null && parent.tagName === "LI" && - domNode.nextSibling === null && domNode.previousSibling !== null + if (parent === null || parent.tagName !== "LI" || domNode.nextSibling !== null) { + return false + } + + return hasListItemContentBefore(domNode) +} + +function hasListItemContentBefore(domNode) { + let sibling = domNode.previousSibling + while (sibling !== null) { + if (isListItemContent(sibling)) { + return true + } + sibling = sibling.previousSibling + } + return false +} + +function isListItemContent(node) { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent.trim() !== "" + } + return node.nodeType === Node.ELEMENT_NODE && node.tagName !== "BR" } export function $isCursorOnLastLine(selection) { diff --git a/test/browser/tests/formatting/list_item_line_breaks.test.js b/test/browser/tests/formatting/list_item_line_breaks.test.js index 29dc88ae7..86afa487b 100644 --- a/test/browser/tests/formatting/list_item_line_breaks.test.js +++ b/test/browser/tests/formatting/list_item_line_breaks.test.js @@ -38,6 +38,13 @@ test.describe("Line breaks inside list items", () => { await assertEditorHtml(editor, "
    • First

    • Second
    ") }) + test("a filler line break preceded only by whitespace is still dropped", async ({ editor }) => { + await editor.setValue("
    • \n
    • Second
    ") + await editor.flush() + + await assertEditorHtml(editor, "
    • Second
    ") + }) + test("a filler line break in an empty list item is still dropped", async ({ editor }) => { await editor.setValue("

    • Second
    ") await editor.flush()