From 54b9fa6b5e519b09b9f909860ee2f4e85f496561 Mon Sep 17 00:00:00 2001 From: fixfon <12512757+fixfon@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:41:39 +0300 Subject: [PATCH 01/27] [lexical-markdown] Bug Fix: Keep closing ** or * before a link after a formatted space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description When a text node made up only of whitespace is the last text sibling before a link (for example the space in `**_a_ [b](u)**`), `exportTextFormat()` moved the tags that close at that node into `closingTagsAfter` and removed them from `unclosedTags`, but the whitespace-only early return only returned `closingTagsBefore + output`. The closing `**` or `*` was dropped from the output, so `**_a_ [b](u)**` exported as `***a* [**b**](u)` and, exported a second time, as `\*\**a* [**b**](u)`. `**_a_ [b](u) c**` and `*a **b** [c](u)*` were broken the same way. The early return now also includes `closingTagsAfter`. Whitespace-only nodes still never get opening tags and the closing tags stay next to the preceding non-whitespace character, so flanking rules are respected. Closes #9403 ## Test plan ### Before ``` pnpm exec vitest --project unit --no-watch packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "formatted whitespace before a link" × round-trips "**_a_ [b](u)**" with a stable export × round-trips "**_a_ [b](u) c**" with a stable export × round-trips "*a **b** [c](u)*" with a stable export ✓ round-trips "**a [b](u)**" with a stable export Tests 3 failed | 1 passed | 610 skipped (614) ``` Exported markdown (export, then import and export again): ``` **_a_ [b](u)** -> ***a* [**b**](u) -> \*\**a* [**b**](u) **_a_ [b](u) c** -> ***a* [**b**](u) **c** -> \*\**a* [**b**](u) **c** *a **b** [c](u)* -> *a **b** [*c*](u) -> \*a **b** [*c*](u) ``` ### After ``` pnpm exec vitest --project unit --no-watch packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "formatted whitespace before a link" ✓ round-trips "**_a_ [b](u)**" with a stable export ✓ round-trips "**_a_ [b](u) c**" with a stable export ✓ round-trips "*a **b** [c](u)*" with a stable export ✓ round-trips "**a [b](u)**" with a stable export Tests 4 passed | 610 skipped (614) ``` Exported markdown, stable on the second pass: ``` **_a_ [b](u)** -> ***a*** [**b**](u) **_a_ [b](u) c** -> ***a*** [**b**](u) **c** *a **b** [c](u)* -> *a **b*** [*c*](u) ``` Also run: `vitest --project unit` for `packages/lexical-markdown` and `packages/lexical-mdast` (15 files, 916 tests passed), eslint and prettier on the two touched files, and `tsc` over `packages/lexical-markdown/src` only (no errors). Not exercised: Node 24.21.0 from `.nvmrc` (everything ran on Node 22.23.2), the browser and e2e tests, and the repo-wide `pnpm run tsc` and `pnpm run flow`. --- .../lexical-markdown/src/MarkdownExport.ts | 6 +- .../__tests__/unit/LexicalMarkdown.test.ts | 57 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index eaecb8075e8..42b56ed4caf 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -614,9 +614,11 @@ function exportTextFormat( break; } // If the node is entirely whitespace, we don't apply opening/closing tags around it. - // However, it must still output closing tags from previous nodes. + // However, it must still output the closing tags: those left open by + // previous nodes, and those that close here because the next sibling is not + // formatted (e.g. a link), which would otherwise be dropped from the output. if (isWhitespaceOnly && !node.hasFormat('code')) { - return closingTagsBefore + output; + return closingTagsBefore + closingTagsAfter + output; } // Flanking Compliance: Notice how openingTags and closingTagsAfter are placed INSIDE the whitespace boundaries! diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 80bb9545a9b..5e3069832e5 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -1678,6 +1678,63 @@ describe('Markdown', () => { }); }); + describe('formatted whitespace before a link (#9403)', () => { + type Char = [char: string, format: number, url: string | null]; + + function importMarkdown(markdown: string): LexicalEditor { + const editor = createTestEditor(); + editor.update( + () => { + $convertFromMarkdownString(markdown, TRANSFORMERS); + }, + {discrete: true}, + ); + return editor; + } + + /** + * The format and link URL of every non-whitespace character. Whitespace + * is left out because an exported tag cannot wrap it (CommonMark flanking + * rules), so a formatted space legitimately comes back unformatted. + */ + function $chars(): Char[] { + const chars: Char[] = []; + for (const node of $getRoot().getAllTextNodes()) { + const parent = node.getParent(); + const url = $isLinkNode(parent) ? parent.getURL() : null; + for (const char of node.getTextContent()) { + if (char.trim() !== '') { + chars.push([char, node.getFormat(), url]); + } + } + } + return chars; + } + + const $text = () => $getRoot().getTextContent(); + const $exportMarkdown = () => $convertToMarkdownString(TRANSFORMERS); + + it.each([ + '**_a_ [b](u)**', + '**_a_ [b](u) c**', + '*a **b** [c](u)*', + '**a [b](u)**', + ])('round-trips "%s" with a stable export', original => { + const first = importMarkdown(original); + const chars = first.read($chars); + // Guard against a vacuous comparison: every delimiter was consumed and + // the link was parsed. + expect(chars.some(([char]) => char === '*' || char === '_')).toBe(false); + expect(chars.some(([, , url]) => url !== null)).toBe(true); + + const exported = first.read($exportMarkdown); + const second = importMarkdown(exported); + expect(second.read($chars)).toEqual(chars); + expect(second.read($text)).toBe(first.read($text)); + expect(second.read($exportMarkdown)).toBe(exported); + }); + }); + describe('list marker', () => { it('should remember marker used on import', () => { const editor = createTestEditor({ From 012f1812e309a636953c9dcc060badf1b946bb44 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:56:08 +0000 Subject: [PATCH 02/27] [lexical-markdown] Bug Fix: Don't reopen a tag after a formatted space, add changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description A whitespace-only text node never emits tags, but one that carries a format its next neighbour also has keeps that tag open. The next node then checked `checkHasFormat(prevNode)`, which treats whitespace-only nodes as unformatted, so it opened the tag a second time: bold `a`, bold italic ` `, bold `c` exported as `**a **c****`. It now checks `hasFormat(prevNode)`, and still opens the tag when it is not actually open. Adds tests for a formatted space before unformatted text, at the end of a link, and between two runs of the same format, and the changeset for the PR. ## Test plan ### Before ``` pnpm vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "formatted whitespace" × does not reopen a tag it kept open Received: "**a **c****" ``` ### After ``` Tests 7 passed | 610 skipped (617) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-formatted-whitespace-export.md | 7 +++ .../lexical-markdown/src/MarkdownExport.ts | 6 +- .../__tests__/unit/LexicalMarkdown.test.ts | 56 ++++++++++++++++++- 3 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 .changeset/markdown-formatted-whitespace-export.md diff --git a/.changeset/markdown-formatted-whitespace-export.md b/.changeset/markdown-formatted-whitespace-export.md new file mode 100644 index 00000000000..6ff53ce9a6b --- /dev/null +++ b/.changeset/markdown-formatted-whitespace-export.md @@ -0,0 +1,7 @@ +--- +'@lexical/markdown': patch +--- + +[fix] Markdown export no longer drops or repeats the closing `**` or `*` around a whitespace-only text node whose formats differ from its neighbours, such as a bold space after bold italic text and before a link (`**_a_ [b](u)**` used to export as `***a* [**b**](u)`) + +Verify: Markdown export of text where a space carries different formatting than the words around it. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 6db8212d0bd..1e3970ab343 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -570,9 +570,11 @@ function exportTextFormat( applied.add(format); // append the tag to openingTags, if it's not applied to the previous nodes, - // or the nodes before that (which would result in an unclosed tag) + // or the nodes before that (which would result in an unclosed tag). + // A whitespace-only previous node with the format keeps the tag open + // without emitting it, so it counts as applied here. if ( - !checkHasFormat(prevNode, format) || + !hasFormat(prevNode, format) || !unclosedTags.find(element => element.tag === tag) ) { unclosedTags.push({format, tag}); diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index ae6729e7315..702689c6d10 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -1688,7 +1688,7 @@ describe('Markdown', () => { }); }); - describe('formatted whitespace before a link (#9403)', () => { + describe('formatted whitespace-only text (#9403)', () => { type Char = [char: string, format: number, url: string | null]; function importMarkdown(markdown: string): LexicalEditor { @@ -1743,6 +1743,60 @@ describe('Markdown', () => { expect(second.read($text)).toBe(first.read($text)); expect(second.read($exportMarkdown)).toBe(exported); }); + + // A whitespace-only text node needs a format the nodes around it lack to + // stay separate, which markdown import alone cannot produce. + const BOLD = TEXT_TYPE_TO_FORMAT.bold; + const ITALIC = TEXT_TYPE_TO_FORMAT.italic; + const $formatted = (text: string, format: number) => + $createTextNode(text).setFormat(format); + + it.each<[name: string, $nodes: () => LexicalNode[], expected: string]>([ + [ + 'closes before unformatted text', + () => [ + $formatted('a', BOLD | ITALIC), + $formatted(' ', BOLD), + $formatted('b', 0), + ], + '***a*** b', + ], + [ + 'closes at the end of a link', + () => [ + $createLinkNode('u').append( + $formatted('a', BOLD | ITALIC), + $formatted(' ', BOLD), + ), + $formatted('c', 0), + ], + '[***a*** ](u)c', + ], + [ + 'does not reopen a tag it kept open', + () => [ + $formatted('a', BOLD), + $formatted(' ', BOLD | ITALIC), + $formatted('c', BOLD), + ], + '**a c**', + ], + ])('%s', (_name, $nodes, expected) => { + const editor = createTestEditor(); + editor.update( + () => { + $getRoot() + .clear() + .append($createParagraphNode().append(...$nodes())); + }, + {discrete: true}, + ); + const exported = editor.read($exportMarkdown); + expect(exported).toBe(expected); + expect(importMarkdown(exported).read($chars)).toEqual( + editor.read($chars), + ); + }); }); describe('list marker', () => { From eb0cbb3368310b89cd2ac022ff02e6fe0b12376b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:03:17 +0000 Subject: [PATCH 03/27] [lexical-markdown] Bug Fix: Close a tag before a run of formatted spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Whether a tag stays open past a text node was decided by the next sibling alone. When that sibling was whitespace-only, the tag stayed open and was closed by the whitespace node, after a space where it cannot close anything: bold `a`, bold italic ` `, bold strikethrough ` `, plain `c` exported as `**a ** c`, and bold `a ` followed by a bold italic space as `**a ** c` too. The decision now looks past whitespace-only siblings that keep the format, so the tag closes on the last text that can hold it, before its trailing space. Also renames `exportTextFormat` to `$exportTextFormat`, since it reads node state, and drops a `code` check that `isWhitespaceOnly` already rules out. ## Test plan ### Before ``` ./node_modules/.bin/vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "whitespace" × closes before consecutive formatted spaces × closes before the trailing space of the text it formats × does not reopen a tag it kept open × keeps a tag open across consecutive formatted spaces ``` ### After ``` ./node_modules/.bin/vitest run packages/lexical-markdown Test Files 10 passed (10) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 31 ++++++++--- .../__tests__/unit/LexicalMarkdown.test.ts | 55 +++++++++++++++++++ 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 1e3970ab343..9a41630941e 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -259,7 +259,7 @@ function $exportChildrenForSelection( textNode, 'clone', ); - return exportTextFormat( + return $exportTextFormat( textNode, slicedNode.getTextContent(), textFormatTransformers, @@ -297,7 +297,7 @@ function $exportChildrenForSelection( if (childIncluded) { const target = $sliceSelectedTextNodeContent(selection, child, 'clone'); output.push( - exportTextFormat( + $exportTextFormat( child, target.getTextContent(), textFormatTransformers, @@ -427,7 +427,7 @@ function $exportChildren( shouldPreserveNewLines, ), (textNode, textContent) => - exportTextFormat( + $exportTextFormat( textNode, textContent, textTransformersIndex, @@ -447,7 +447,7 @@ function $exportChildren( output.push($exportLineBreak(child)); } else if ($isTextNode(child)) { output.push( - exportTextFormat( + $exportTextFormat( child, child.getTextContent(), textTransformersIndex, @@ -480,7 +480,7 @@ function $exportLineBreak(node: LineBreakNode): string { return $getState(node, hardLineBreakState) + '\n'; } -function exportTextFormat( +function $exportTextFormat( node: TextNode, textContent: string, textTransformers: TextFormatTransformer[], @@ -542,7 +542,6 @@ function exportTextFormat( let closingTagsAfter = ''; const prevNode = getTextSibling(node, true); - const nextNode = getTextSibling(node, false); const applied = new Set(); @@ -586,7 +585,7 @@ function exportTextFormat( // close any tags in the same order they were applied, if necessary for (let i = 0; i < unclosedTags.length; i++) { const nodeHasFormat = hasFormat(node, unclosedTags[i].format); - const nextNodeHasFormat = hasFormat(nextNode, unclosedTags[i].format); + const nextNodeHasFormat = $continuesFormat(node, unclosedTags[i].format); // prevent adding closing tag if next sibling will do it if (nodeHasFormat && nextNodeHasFormat) { @@ -626,7 +625,7 @@ function exportTextFormat( // However, it must still output the closing tags: those left open by // previous nodes, and those that close here because the next sibling is not // formatted (e.g. a link), which would otherwise be dropped from the output. - if (isWhitespaceOnly && !node.hasFormat('code')) { + if (isWhitespaceOnly) { return closingTagsBefore + closingTagsAfter + output; } @@ -651,6 +650,22 @@ function getTextSibling(node: TextNode, backward: boolean): TextNode | null { return null; } +/** + * Whether `format` carries on past `node` to text that can hold its tag. + * Whitespace-only siblings cannot (the tag must close before the space), so + * they are looked past while they keep the format. + */ +function $continuesFormat(node: TextNode, format: TextFormatType): boolean { + let next = getTextSibling(node, false); + while (next && next.hasFormat(format)) { + if (checkHasFormat(next, format)) { + return true; + } + next = getTextSibling(next, false); + } + return false; +} + function hasFormat( node: LexicalNode | null | undefined, format: TextFormatType, diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 702689c6d10..c11fa2e0a04 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -1748,6 +1748,7 @@ describe('Markdown', () => { // stay separate, which markdown import alone cannot produce. const BOLD = TEXT_TYPE_TO_FORMAT.bold; const ITALIC = TEXT_TYPE_TO_FORMAT.italic; + const STRIKE = TEXT_TYPE_TO_FORMAT.strikethrough; const $formatted = (text: string, format: number) => $createTextNode(text).setFormat(format); @@ -1772,6 +1773,25 @@ describe('Markdown', () => { ], '[***a*** ](u)c', ], + [ + 'closes before consecutive formatted spaces', + () => [ + $formatted('a', BOLD), + $formatted(' ', BOLD | ITALIC), + $formatted(' ', BOLD | STRIKE), + $formatted('c', 0), + ], + '**a** c', + ], + [ + 'closes before the trailing space of the text it formats', + () => [ + $formatted('a ', BOLD), + $formatted(' ', BOLD | ITALIC), + $formatted('c', 0), + ], + '**a** c', + ], [ 'does not reopen a tag it kept open', () => [ @@ -1781,6 +1801,16 @@ describe('Markdown', () => { ], '**a c**', ], + [ + 'keeps a tag open across consecutive formatted spaces', + () => [ + $formatted('a', BOLD), + $formatted(' ', BOLD | ITALIC), + $formatted(' ', BOLD | STRIKE), + $formatted('c', BOLD), + ], + '**a c**', + ], ])('%s', (_name, $nodes, expected) => { const editor = createTestEditor(); editor.update( @@ -3992,6 +4022,31 @@ describe('$convertSelectionToMarkdownString whitespace slices', () => { ); expect(result).toBe(' **b**'); }); + + it('closes a tag left open by the previous node when the slice is cut down to whitespace', () => { + const editor = createTestEditor({nodes: [LinkNode]}); + editor.update( + () => { + const first = $createTextNode('a'); + first.toggleFormat('bold'); + first.toggleFormat('italic'); + const second = $createTextNode(' b'); + second.toggleFormat('bold'); + $getRoot().append($createParagraphNode().append(first, second)); + $setSelectionFromCaretRange( + $getCaretRange( + $getTextPointCaret(first, 'next', 0), + $getTextPointCaret(second, 'next', 1), + ), + ); + }, + {discrete: true}, + ); + const result = editor.read('latest', () => + $convertSelectionToMarkdownString(TRANSFORMERS, $getSelection()), + ); + expect(result).toBe('***a*** '); + }); }); type ImportedLink = {title: null | string; url: string}; From ac084a87e3096b13650ce1b8453a7f00d3922f71 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:03:31 +0000 Subject: [PATCH 04/27] [lexical-markdown] Chore: Cover misplaced closing tags in the changeset Co-Authored-By: Claude Opus 5.5 --- .changeset/markdown-formatted-whitespace-export.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/markdown-formatted-whitespace-export.md b/.changeset/markdown-formatted-whitespace-export.md index 6ff53ce9a6b..57c033bd29c 100644 --- a/.changeset/markdown-formatted-whitespace-export.md +++ b/.changeset/markdown-formatted-whitespace-export.md @@ -2,6 +2,6 @@ '@lexical/markdown': patch --- -[fix] Markdown export no longer drops or repeats the closing `**` or `*` around a whitespace-only text node whose formats differ from its neighbours, such as a bold space after bold italic text and before a link (`**_a_ [b](u)**` used to export as `***a* [**b**](u)`) +[fix] Markdown export no longer drops, repeats or misplaces the closing `**` or `*` around a whitespace-only text node whose formats differ from its neighbours, such as a bold space after bold italic text and before a link (`**_a_ [b](u)**` used to export as `***a* [**b**](u)`) Verify: Markdown export of text where a space carries different formatting than the words around it. From d213512f551bf3b7d2d608767639cc19c9ad0a1b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:11:57 +0000 Subject: [PATCH 05/27] [lexical-markdown] Bug Fix: Close tags at the end of an exported selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Selection export decided whether a tag continues from each sibling's whole text, even past the end of the selection, so a tag whose format went on after the selection was never closed (bold `a`, bold italic `bc`, bold `d`, selected up to `b`, exported `**a*b*`), and a sibling cut down to a space still counted as text that holds the tag (`**a ** `). `$exportTextFormat` now takes the selection, and the look-ahead stops at it and reads each sibling's selected slice. A whitespace-only inline code node also counts as able to hold a tag, since it is a fenced span, so bold `a` followed by a bold code space keeps the bold on both. The look-ahead only runs when the node has the format. ## Test plan ### Before ``` ./node_modules/.bin/vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "whitespace" × keeps a tag on inline code that holds only a space × closes before a slice cut down to a formatted space × closes when the format continues only after the selection × closes every tag at the end of a sliced node ``` ### After ``` ./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts Test Files 19 passed (19) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-formatted-whitespace-export.md | 4 +- .../lexical-markdown/src/MarkdownExport.ts | 32 +++++-- .../__tests__/unit/LexicalMarkdown.test.ts | 84 +++++++++++++++++++ 3 files changed, 111 insertions(+), 9 deletions(-) diff --git a/.changeset/markdown-formatted-whitespace-export.md b/.changeset/markdown-formatted-whitespace-export.md index 57c033bd29c..0dfd5a320b7 100644 --- a/.changeset/markdown-formatted-whitespace-export.md +++ b/.changeset/markdown-formatted-whitespace-export.md @@ -2,6 +2,6 @@ '@lexical/markdown': patch --- -[fix] Markdown export no longer drops, repeats or misplaces the closing `**` or `*` around a whitespace-only text node whose formats differ from its neighbours, such as a bold space after bold italic text and before a link (`**_a_ [b](u)**` used to export as `***a* [**b**](u)`) +[fix] Markdown export no longer drops, repeats or misplaces the closing `**` or `*` around a space whose formats differ from the text beside it, such as a bold space after bold italic text and before a link (`**_a_ [b](u)**` used to export as `***a* [**b**](u)`), and `$convertSelectionToMarkdownString` closes every tag when the selection ends inside formatted text -Verify: Markdown export of text where a space carries different formatting than the words around it. +Verify: Markdown export, including of selections, of text where a space carries different formatting than the words around it. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 9a41630941e..7a3cc7a9f6d 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -266,6 +266,7 @@ function $exportChildrenForSelection( unclosedTags, unclosableTags, shouldPreserveNewLines, + selection, ); }, ); @@ -304,6 +305,7 @@ function $exportChildrenForSelection( unclosedTags, unclosableTags, shouldPreserveNewLines, + selection, ), ); anyChildIncluded = true; @@ -488,6 +490,8 @@ function $exportTextFormat( unclosedTags: {format: TextFormatType; tag: string}[], unclosableTags?: {format: TextFormatType; tag: string}[], shouldPreserveNewLines: boolean = false, + // the selection being exported, which ends the text a tag can extend over + selection: BaseSelection | null = null, ): string { // This function handles the case of a string looking like this: " foo " // Where it would be invalid markdown to generate: "** foo **" @@ -585,7 +589,9 @@ function $exportTextFormat( // close any tags in the same order they were applied, if necessary for (let i = 0; i < unclosedTags.length; i++) { const nodeHasFormat = hasFormat(node, unclosedTags[i].format); - const nextNodeHasFormat = $continuesFormat(node, unclosedTags[i].format); + const nextNodeHasFormat = + nodeHasFormat && + $continuesFormat(node, unclosedTags[i].format, selection); // prevent adding closing tag if next sibling will do it if (nodeHasFormat && nextNodeHasFormat) { @@ -651,14 +657,25 @@ function getTextSibling(node: TextNode, backward: boolean): TextNode | null { } /** - * Whether `format` carries on past `node` to text that can hold its tag. - * Whitespace-only siblings cannot (the tag must close before the space), so - * they are looked past while they keep the format. + * Whether `format` carries on past `node` to exported text that can hold its + * tag. Whitespace-only siblings cannot (the tag must close before the space), + * so they are looked past while they keep the format. Text outside the + * exported selection does not count. */ -function $continuesFormat(node: TextNode, format: TextFormatType): boolean { +function $continuesFormat( + node: TextNode, + format: TextFormatType, + selection: BaseSelection | null, +): boolean { let next = getTextSibling(node, false); while (next && next.hasFormat(format)) { - if (checkHasFormat(next, format)) { + if (selection && !next.isSelected(selection)) { + return false; + } + const text = selection + ? $sliceSelectedTextNodeContent(selection, next, 'clone').getTextContent() + : next.getTextContent(); + if (next.hasFormat('code') || /\S/.test(text)) { return true; } next = getTextSibling(next, false); @@ -677,7 +694,8 @@ function checkHasFormat(n: TextNode | null, f: TextFormatType): boolean { if (!hasFormat(n, f)) { return false; } - if (f === 'code') { + // Inline code is a fenced span, which can hold a tag whatever its text. + if (n && n.hasFormat('code')) { return true; } if (n && /^\s*$/.test(n.getTextContent())) { diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index c11fa2e0a04..849fcb09081 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -1749,6 +1749,7 @@ describe('Markdown', () => { const BOLD = TEXT_TYPE_TO_FORMAT.bold; const ITALIC = TEXT_TYPE_TO_FORMAT.italic; const STRIKE = TEXT_TYPE_TO_FORMAT.strikethrough; + const INLINE_CODE = TEXT_TYPE_TO_FORMAT.code; const $formatted = (text: string, format: number) => $createTextNode(text).setFormat(format); @@ -1827,6 +1828,29 @@ describe('Markdown', () => { editor.read($chars), ); }); + + it('keeps a tag on inline code that holds only a space', () => { + const editor = createTestEditor(); + editor.update( + () => { + $getRoot() + .clear() + .append( + $createParagraphNode().append( + $formatted('a', BOLD), + $formatted(' ', BOLD | INLINE_CODE), + ), + ); + }, + {discrete: true}, + ); + const formats = importMarkdown(editor.read($exportMarkdown)).read(() => + $getRoot() + .getAllTextNodes() + .map(node => node.getFormat()), + ); + expect(formats).toEqual([BOLD, BOLD | INLINE_CODE]); + }); }); describe('list marker', () => { @@ -4047,6 +4071,66 @@ describe('$convertSelectionToMarkdownString whitespace slices', () => { ); expect(result).toBe('***a*** '); }); + + it.each< + [ + name: string, + formats: [text: string, format: number][], + end: number, + expected: string, + ] + >([ + [ + 'closes before a slice cut down to a formatted space', + [ + ['a ', TEXT_TYPE_TO_FORMAT.bold], + [' b', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.italic], + ], + 1, + '**a** ', + ], + [ + 'closes when the format continues only after the selection', + [ + ['a', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.italic], + [' x', TEXT_TYPE_TO_FORMAT.bold], + ['c', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.italic], + ], + 1, + '***a*** ', + ], + [ + 'closes every tag at the end of a sliced node', + [ + ['a', TEXT_TYPE_TO_FORMAT.bold], + ['bc', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.italic], + ['d', TEXT_TYPE_TO_FORMAT.bold], + ], + 1, + '**a*b***', + ], + ])('%s', (_name, formats, end, expected) => { + const editor = createTestEditor({nodes: [LinkNode]}); + editor.update( + () => { + const nodes = formats.map(([text, format]) => + $createTextNode(text).setFormat(format), + ); + $getRoot().append($createParagraphNode().append(...nodes)); + $setSelectionFromCaretRange( + $getCaretRange( + $getTextPointCaret(nodes[0], 'next', 0), + $getTextPointCaret(nodes[1], 'next', end), + ), + ); + }, + {discrete: true}, + ); + const result = editor.read('latest', () => + $convertSelectionToMarkdownString(TRANSFORMERS, $getSelection()), + ); + expect(result).toBe(expected); + }); }); type ImportedLink = {title: null | string; url: string}; From e68d97ff3301fddf944e3001003591b94602a569 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:20:39 +0000 Subject: [PATCH 06/27] [lexical-markdown] Refactor: One rule for which text can hold a tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Whether exported text can carry a format's tag was decided in three places (`checkHasFormat`, the whitespace match, and the look-ahead), from whole-node text in some and the exported slice in others. `canHoldTag(node, text)` now decides it for both the node being exported and the siblings the look-ahead reads, and `checkHasFormat` is gone. An inline code node whose selected slice is empty can no longer hold a tag, so a selection that only touches inline code no longer emits an empty code span inside the tag. Drops the duplicated `nodeHasFormat` checks in the close loop. ## Test plan ### Before ``` ./node_modules/.bin/vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "touches" × leaves out inline code the selection only touches Received: "**a` `**" ``` ### After ``` ./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts Test Files 19 passed (19) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-formatted-whitespace-export.md | 2 +- .../lexical-markdown/src/MarkdownExport.ts | 54 ++++++------------- .../__tests__/unit/LexicalMarkdown.test.ts | 9 ++++ 3 files changed, 27 insertions(+), 38 deletions(-) diff --git a/.changeset/markdown-formatted-whitespace-export.md b/.changeset/markdown-formatted-whitespace-export.md index 0dfd5a320b7..66148fab689 100644 --- a/.changeset/markdown-formatted-whitespace-export.md +++ b/.changeset/markdown-formatted-whitespace-export.md @@ -2,6 +2,6 @@ '@lexical/markdown': patch --- -[fix] Markdown export no longer drops, repeats or misplaces the closing `**` or `*` around a space whose formats differ from the text beside it, such as a bold space after bold italic text and before a link (`**_a_ [b](u)**` used to export as `***a* [**b**](u)`), and `$convertSelectionToMarkdownString` closes every tag when the selection ends inside formatted text +[fix] Markdown export no longer drops, repeats or misplaces the closing `**` or `*` around a space whose formats differ from the text beside it, such as a bold space after bold italic text and before a link (`**_a_ [b](u)**` used to export as `***a* [**b**](u)`), and `$convertSelectionToMarkdownString` closes every tag when the selection ends inside formatted text and leaves out inline code the selection only touches Verify: Markdown export, including of selections, of text where a space carries different formatting than the words around it. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 7a3cc7a9f6d..0bc69617f2c 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -519,16 +519,15 @@ function $exportTextFormat( let leadingSpace: string; let trimmedOutput: string; let trailingSpace: string; - let isWhitespaceOnly: boolean; + const isWhitespaceOnly = !canHoldTag(node, textContent); - if (isCode) { + if (isCode && !isWhitespaceOnly) { // Inline code is an atomic literal span with a content-derived fence, so // its whitespace stays inside the fence and other formats wrap around it. const {fence, padded} = getCodeSpanDelimiter(textContent); leadingSpace = ''; trailingSpace = ''; trimmedOutput = fence + padded + fence; - isWhitespaceOnly = false; } else { // Extract leading and trailing whitespaces. // CommonMark flanking rules require formatting tags to be adjacent to non-whitespace characters. @@ -536,7 +535,6 @@ function $exportTextFormat( leadingSpace = match[1]; trimmedOutput = match[2]; trailingSpace = match[3]; - isWhitespaceOnly = trimmedOutput === ''; } // the opening tags to be added to the result @@ -559,17 +557,8 @@ function $exportTextFormat( } // dedup applied formats - // `checkHasFormat` reads the node's whole text, but the text being - // exported may be a selection slice of it. When the slice is entirely - // whitespace the tags are dropped by the early return below, so - // registering them here would leave an unclosed tag for a later node to - // close. In the full-document path `checkHasFormat` already rejects - // whitespace-only nodes, so this guard only affects sliced content. - if ( - !isWhitespaceOnly && - checkHasFormat(node, format) && - !applied.has(format) - ) { + // Whitespace-only text cannot hold a tag (see the early return below). + if (!isWhitespaceOnly && node.hasFormat(format) && !applied.has(format)) { applied.add(format); // append the tag to openingTags, if it's not applied to the previous nodes, @@ -589,12 +578,11 @@ function $exportTextFormat( // close any tags in the same order they were applied, if necessary for (let i = 0; i < unclosedTags.length; i++) { const nodeHasFormat = hasFormat(node, unclosedTags[i].format); - const nextNodeHasFormat = - nodeHasFormat && - $continuesFormat(node, unclosedTags[i].format, selection); - // prevent adding closing tag if next sibling will do it - if (nodeHasFormat && nextNodeHasFormat) { + if ( + nodeHasFormat && + $continuesFormat(node, unclosedTags[i].format, selection) + ) { continue; } @@ -618,7 +606,7 @@ function $exportTextFormat( // Handles cases where the tag has not been closed before, e.g. if the previous node // was a text match transformer that did not account for closing tags of the next node (e.g. a link) closingTagsBefore += unclosedTag.tag; - } else if (!nextNodeHasFormat) { + } else { closingTagsAfter += unclosedTag.tag; } } @@ -628,9 +616,7 @@ function $exportTextFormat( break; } // If the node is entirely whitespace, we don't apply opening/closing tags around it. - // However, it must still output the closing tags: those left open by - // previous nodes, and those that close here because the next sibling is not - // formatted (e.g. a link), which would otherwise be dropped from the output. + // However, it must still output any closing tags, before the whitespace. if (isWhitespaceOnly) { return closingTagsBefore + closingTagsAfter + output; } @@ -675,7 +661,7 @@ function $continuesFormat( const text = selection ? $sliceSelectedTextNodeContent(selection, next, 'clone').getTextContent() : next.getTextContent(); - if (next.hasFormat('code') || /\S/.test(text)) { + if (canHoldTag(next, text)) { return true; } next = getTextSibling(next, false); @@ -690,16 +676,10 @@ function hasFormat( return $isTextNode(node) && node.hasFormat(format); } -function checkHasFormat(n: TextNode | null, f: TextFormatType): boolean { - if (!hasFormat(n, f)) { - return false; - } - // Inline code is a fenced span, which can hold a tag whatever its text. - if (n && n.hasFormat('code')) { - return true; - } - if (n && /^\s*$/.test(n.getTextContent())) { - return false; - } - return true; +/** + * Whether exported text can carry a format's tag: CommonMark flanking rules + * keep tags off whitespace, but inline code is a fenced span. + */ +function canHoldTag(node: TextNode, text: string): boolean { + return node.hasFormat('code') ? text !== '' : /\S/.test(text); } diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 849fcb09081..05169d8d501 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -4109,6 +4109,15 @@ describe('$convertSelectionToMarkdownString whitespace slices', () => { 1, '**a*b***', ], + [ + 'leaves out inline code the selection only touches', + [ + ['a', TEXT_TYPE_TO_FORMAT.bold], + ['xy', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.code], + ], + 0, + '**a**', + ], ])('%s', (_name, formats, end, expected) => { const editor = createTestEditor({nodes: [LinkNode]}); editor.update( From ccd31288eca1ca3bcfca2fe229368e2f2f6f68aa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:29:40 +0000 Subject: [PATCH 07/27] [lexical-markdown] Bug Fix: Close a tag before a formatted line break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The look-ahead that keeps a tag open across formatted whitespace also looked past whitespace holding a newline, so bold `a`, bold italic `\n`, bold `c` exported as `**a\nc**`, which markdown import cannot match because it pairs tags within one line. A newline now ends the look-ahead, and the export is `**a**\n**c**`. Adds a selection test that starts at the end of an inline code node, and names the other format markers in the changeset. ## Test plan ### Before ``` ./node_modules/.bin/vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "line break" × closes before a formatted line break Received: "**a\nc**" ``` ### After ``` ./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts Test Files 19 passed (19) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-formatted-whitespace-export.md | 2 +- .../lexical-markdown/src/MarkdownExport.ts | 4 +++ .../__tests__/unit/LexicalMarkdown.test.ts | 32 +++++++++++++++---- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/.changeset/markdown-formatted-whitespace-export.md b/.changeset/markdown-formatted-whitespace-export.md index 66148fab689..36a43c0fbee 100644 --- a/.changeset/markdown-formatted-whitespace-export.md +++ b/.changeset/markdown-formatted-whitespace-export.md @@ -2,6 +2,6 @@ '@lexical/markdown': patch --- -[fix] Markdown export no longer drops, repeats or misplaces the closing `**` or `*` around a space whose formats differ from the text beside it, such as a bold space after bold italic text and before a link (`**_a_ [b](u)**` used to export as `***a* [**b**](u)`), and `$convertSelectionToMarkdownString` closes every tag when the selection ends inside formatted text and leaves out inline code the selection only touches +[fix] Markdown export no longer drops, repeats or misplaces a closing format marker such as `**`, `*` or `~~` around a space whose formats differ from the text beside it, such as a bold space after bold italic text and before a link (`**_a_ [b](u)**` used to export as `***a* [**b**](u)`), and `$convertSelectionToMarkdownString` closes every tag when the selection ends inside formatted text and leaves out inline code the selection only touches Verify: Markdown export, including of selections, of text where a space carries different formatting than the words around it. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 0bc69617f2c..8b66a0008b6 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -664,6 +664,10 @@ function $continuesFormat( if (canHoldTag(next, text)) { return true; } + // Markdown import matches a tag within one line. + if (text.includes('\n')) { + return false; + } next = getTextSibling(next, false); } return false; diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 05169d8d501..7d1ff7325b7 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -1812,6 +1812,15 @@ describe('Markdown', () => { ], '**a c**', ], + [ + 'closes before a formatted line break', + () => [ + $formatted('a', BOLD), + $formatted('\n', BOLD | ITALIC), + $formatted('c', BOLD), + ], + '**a**\n**c**', + ], ])('%s', (_name, $nodes, expected) => { const editor = createTestEditor(); editor.update( @@ -4076,7 +4085,7 @@ describe('$convertSelectionToMarkdownString whitespace slices', () => { [ name: string, formats: [text: string, format: number][], - end: number, + offsets: [start: number, end: number], expected: string, ] >([ @@ -4086,7 +4095,7 @@ describe('$convertSelectionToMarkdownString whitespace slices', () => { ['a ', TEXT_TYPE_TO_FORMAT.bold], [' b', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.italic], ], - 1, + [0, 1], '**a** ', ], [ @@ -4096,7 +4105,7 @@ describe('$convertSelectionToMarkdownString whitespace slices', () => { [' x', TEXT_TYPE_TO_FORMAT.bold], ['c', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.italic], ], - 1, + [0, 1], '***a*** ', ], [ @@ -4106,7 +4115,7 @@ describe('$convertSelectionToMarkdownString whitespace slices', () => { ['bc', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.italic], ['d', TEXT_TYPE_TO_FORMAT.bold], ], - 1, + [0, 1], '**a*b***', ], [ @@ -4115,10 +4124,19 @@ describe('$convertSelectionToMarkdownString whitespace slices', () => { ['a', TEXT_TYPE_TO_FORMAT.bold], ['xy', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.code], ], - 0, + [0, 0], + '**a**', + ], + [ + 'leaves out inline code the selection starts at the end of', + [ + ['xy', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.code], + ['a', TEXT_TYPE_TO_FORMAT.bold], + ], + [2, 1], '**a**', ], - ])('%s', (_name, formats, end, expected) => { + ])('%s', (_name, formats, [start, end], expected) => { const editor = createTestEditor({nodes: [LinkNode]}); editor.update( () => { @@ -4128,7 +4146,7 @@ describe('$convertSelectionToMarkdownString whitespace slices', () => { $getRoot().append($createParagraphNode().append(...nodes)); $setSelectionFromCaretRange( $getCaretRange( - $getTextPointCaret(nodes[0], 'next', 0), + $getTextPointCaret(nodes[0], 'next', start), $getTextPointCaret(nodes[1], 'next', end), ), ); From c0c6640eedb2268447dc654d40a083be185200da Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:36:49 +0000 Subject: [PATCH 08/27] [lexical-markdown] Chore: Describe every behavior change in the changeset Co-Authored-By: Claude Opus 5.5 --- .changeset/markdown-formatted-whitespace-export.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/markdown-formatted-whitespace-export.md b/.changeset/markdown-formatted-whitespace-export.md index 36a43c0fbee..f56369d44e6 100644 --- a/.changeset/markdown-formatted-whitespace-export.md +++ b/.changeset/markdown-formatted-whitespace-export.md @@ -2,6 +2,8 @@ '@lexical/markdown': patch --- -[fix] Markdown export no longer drops, repeats or misplaces a closing format marker such as `**`, `*` or `~~` around a space whose formats differ from the text beside it, such as a bold space after bold italic text and before a link (`**_a_ [b](u)**` used to export as `***a* [**b**](u)`), and `$convertSelectionToMarkdownString` closes every tag when the selection ends inside formatted text and leaves out inline code the selection only touches +[fix] Markdown export keeps format markers balanced around a space whose formats differ from the text beside it -Verify: Markdown export, including of selections, of text where a space carries different formatting than the words around it. +Closing markers such as `**`, `*` or `~~` were dropped, repeated or placed after the space, so `**_a_ [b](u)**` exported as `***a* [**b**](u)`. `$convertSelectionToMarkdownString` also closes every marker when the selection ends inside formatted text. Inline code that holds only spaces now keeps the formats around it, and empty inline code, such as inline code a selection only touches, is left out. + +Verify: Markdown export, including of selections, of text where a space or inline code carries different formatting than the words around it. From a56e3077830c7f241f2545054bb3a587d435c9c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:44:15 +0000 Subject: [PATCH 09/27] [lexical-markdown] Bug Fix: Close a tag before formatted text on the next line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The look-ahead checked for a newline only after deciding whether a sibling could hold the tag, so a sibling that starts on the next line (bold `a`, bold italic `\nc`) kept `**` open across the line break, which markdown import cannot match. The newline check now comes first. Also moves the inline code and whitespace split to after the early return, so text that cannot hold a tag skips the split, and renames `isWhitespaceOnly` to `cannotHoldTag`, since it also covers empty inline code. ## Test plan ### Before ``` ./node_modules/.bin/vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "next line" × closes before formatted text on the next line ``` ### After ``` ./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts Test Files 19 passed (19) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 57 +++++++++---------- .../__tests__/unit/LexicalMarkdown.test.ts | 5 ++ 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 8b66a0008b6..677ebfb205a 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -516,26 +516,7 @@ function $exportTextFormat( ); } - let leadingSpace: string; - let trimmedOutput: string; - let trailingSpace: string; - const isWhitespaceOnly = !canHoldTag(node, textContent); - - if (isCode && !isWhitespaceOnly) { - // Inline code is an atomic literal span with a content-derived fence, so - // its whitespace stays inside the fence and other formats wrap around it. - const {fence, padded} = getCodeSpanDelimiter(textContent); - leadingSpace = ''; - trailingSpace = ''; - trimmedOutput = fence + padded + fence; - } else { - // Extract leading and trailing whitespaces. - // CommonMark flanking rules require formatting tags to be adjacent to non-whitespace characters. - const match = output.match(/^(\s*)(.*?)(\s*)$/s) || ['', '', output, '']; - leadingSpace = match[1]; - trimmedOutput = match[2]; - trailingSpace = match[3]; - } + const cannotHoldTag = !canHoldTag(node, textContent); // the opening tags to be added to the result let openingTags = ''; @@ -557,8 +538,7 @@ function $exportTextFormat( } // dedup applied formats - // Whitespace-only text cannot hold a tag (see the early return below). - if (!isWhitespaceOnly && node.hasFormat(format) && !applied.has(format)) { + if (!cannotHoldTag && node.hasFormat(format) && !applied.has(format)) { applied.add(format); // append the tag to openingTags, if it's not applied to the previous nodes, @@ -615,13 +595,32 @@ function $exportTextFormat( } break; } - // If the node is entirely whitespace, we don't apply opening/closing tags around it. - // However, it must still output any closing tags, before the whitespace. - if (isWhitespaceOnly) { + // If the node is entirely whitespace (or empty inline code), we don't apply + // opening/closing tags around it, but it must still output any closing tags. + if (cannotHoldTag) { return closingTagsBefore + closingTagsAfter + output; } - // Flanking Compliance: Notice how openingTags and closingTagsAfter are placed INSIDE the whitespace boundaries! + if (isCode) { + // Inline code is an atomic literal span with a content-derived fence, so + // its whitespace stays inside the fence and other formats wrap around it. + const {fence, padded} = getCodeSpanDelimiter(textContent); + return ( + closingTagsBefore + + openingTags + + fence + + padded + + fence + + closingTagsAfter + ); + } + + // Flanking Compliance: CommonMark flanking rules require formatting tags to be + // adjacent to non-whitespace characters, so openingTags and closingTagsAfter + // are placed INSIDE the whitespace boundaries! + const [, leadingSpace, trimmedOutput, trailingSpace] = output.match( + /^(\s*)(.*?)(\s*)$/s, + ) || ['', '', output, '']; return ( closingTagsBefore + leadingSpace + @@ -661,13 +660,13 @@ function $continuesFormat( const text = selection ? $sliceSelectedTextNodeContent(selection, next, 'clone').getTextContent() : next.getTextContent(); - if (canHoldTag(next, text)) { - return true; - } // Markdown import matches a tag within one line. if (text.includes('\n')) { return false; } + if (canHoldTag(next, text)) { + return true; + } next = getTextSibling(next, false); } return false; diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 7d1ff7325b7..170faec9ddb 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -1821,6 +1821,11 @@ describe('Markdown', () => { ], '**a**\n**c**', ], + [ + 'closes before formatted text on the next line', + () => [$formatted('a', BOLD), $formatted('\nc', BOLD | ITALIC)], + '**a**\n***c***', + ], ])('%s', (_name, $nodes, expected) => { const editor = createTestEditor(); editor.update( From 0008de7b96bd2781661806573217f9e2839e2527 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:52:26 +0000 Subject: [PATCH 10/27] [lexical-markdown] Chore: Fix stale comments in $exportTextFormat, rename cannotHoldTag to holdsTag Co-Authored-By: Claude Opus 5.5 --- packages/lexical-markdown/src/MarkdownExport.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 677ebfb205a..8bd4e40dcd2 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -495,9 +495,7 @@ function $exportTextFormat( ): string { // This function handles the case of a string looking like this: " foo " // Where it would be invalid markdown to generate: "** foo **" - // If the node has no format, we use the original text. - // Otherwise, we escape leading and trailing whitespaces to their corresponding code points, - // ensuring the returned string maintains its original formatting, e.g., "** foo **". + // so the tags go inside the whitespace instead: " **foo** ". const isCode = node.hasFormat('code'); @@ -516,7 +514,7 @@ function $exportTextFormat( ); } - const cannotHoldTag = !canHoldTag(node, textContent); + const holdsTag = canHoldTag(node, textContent); // the opening tags to be added to the result let openingTags = ''; @@ -532,13 +530,13 @@ function $exportTextFormat( const format = transformer.format[0]; const tag = transformer.tag; - // Inline code uses a content-derived fence handled above, not a static tag. + // Inline code uses a content-derived fence handled below, not a static tag. if (format === 'code') { continue; } // dedup applied formats - if (!cannotHoldTag && node.hasFormat(format) && !applied.has(format)) { + if (holdsTag && node.hasFormat(format) && !applied.has(format)) { applied.add(format); // append the tag to openingTags, if it's not applied to the previous nodes, @@ -597,7 +595,7 @@ function $exportTextFormat( } // If the node is entirely whitespace (or empty inline code), we don't apply // opening/closing tags around it, but it must still output any closing tags. - if (cannotHoldTag) { + if (!holdsTag) { return closingTagsBefore + closingTagsAfter + output; } From a13106f3772bd4ec41545c05f90fd0cc99ca21fb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 17:00:57 +0000 Subject: [PATCH 11/27] [lexical-markdown] Bug Fix: Keep a tag open across soft-wrapped text again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Checking for a newline before asking whether a sibling can hold the tag closed and reopened a tag between same-format nodes whose second node holds text around a newline (bold `a`, bold `b\nc` kept apart by a style), exporting `**a****b\nc**` where main exported `**ab\nc**`. The newline now only stops the look-ahead at whitespace-only siblings, as before. Selection export reads a node's emitted text through one `$exportedText` helper in all three places, and a selection test covers a formatted space at the end of a link. ## Test plan ### Before ``` ./node_modules/.bin/vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "selected link" # against main's exporter × closes a tag before a formatted space at the end of a selected link ``` ### After ``` ./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts Test Files 19 passed (19) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 34 +++++++++---------- .../__tests__/unit/LexicalMarkdown.test.ts | 28 ++++++++++++--- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 8bd4e40dcd2..fa8e3e91c2e 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -253,22 +253,16 @@ function $exportChildrenForSelection( unclosedTags, [...unclosableTags, ...unclosedTags], ).markdown, - (textNode, textContent) => { - const slicedNode = $sliceSelectedTextNodeContent( - selection, - textNode, - 'clone', - ); - return $exportTextFormat( + textNode => + $exportTextFormat( textNode, - slicedNode.getTextContent(), + $exportedText(textNode, selection), textFormatTransformers, unclosedTags, unclosableTags, shouldPreserveNewLines, selection, - ); - }, + ), ); if (result != null) { @@ -296,11 +290,10 @@ function $exportChildrenForSelection( } } else if ($isTextNode(child)) { if (childIncluded) { - const target = $sliceSelectedTextNodeContent(selection, child, 'clone'); output.push( $exportTextFormat( child, - target.getTextContent(), + $exportedText(child, selection), textFormatTransformers, unclosedTags, unclosableTags, @@ -655,21 +648,26 @@ function $continuesFormat( if (selection && !next.isSelected(selection)) { return false; } - const text = selection - ? $sliceSelectedTextNodeContent(selection, next, 'clone').getTextContent() - : next.getTextContent(); + const text = $exportedText(next, selection); + if (canHoldTag(next, text)) { + return true; + } // Markdown import matches a tag within one line. if (text.includes('\n')) { return false; } - if (canHoldTag(next, text)) { - return true; - } next = getTextSibling(next, false); } return false; } +/** The text of `node` that the export emits: its selected slice, if any. */ +function $exportedText(node: TextNode, selection: BaseSelection | null) { + return selection + ? $sliceSelectedTextNodeContent(selection, node, 'clone').getTextContent() + : node.getTextContent(); +} + function hasFormat( node: LexicalNode | null | undefined, format: TextFormatType, diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 170faec9ddb..ba2d10a65c3 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -1821,11 +1821,6 @@ describe('Markdown', () => { ], '**a**\n**c**', ], - [ - 'closes before formatted text on the next line', - () => [$formatted('a', BOLD), $formatted('\nc', BOLD | ITALIC)], - '**a**\n***c***', - ], ])('%s', (_name, $nodes, expected) => { const editor = createTestEditor(); editor.update( @@ -4086,6 +4081,29 @@ describe('$convertSelectionToMarkdownString whitespace slices', () => { expect(result).toBe('***a*** '); }); + it('closes a tag before a formatted space at the end of a selected link', () => { + const editor = createTestEditor({nodes: [LinkNode]}); + editor.update( + () => { + const bold = TEXT_TYPE_TO_FORMAT.bold; + const paragraph = $createParagraphNode().append( + $createLinkNode('u').append( + $createTextNode('a').setFormat(bold | TEXT_TYPE_TO_FORMAT.italic), + $createTextNode(' ').setFormat(bold), + ), + $createTextNode('c'), + ); + $getRoot().append(paragraph); + paragraph.select(0, paragraph.getChildrenSize()); + }, + {discrete: true}, + ); + const result = editor.read('latest', () => + $convertSelectionToMarkdownString(TRANSFORMERS, $getSelection()), + ); + expect(result).toBe('[***a*** ](u)c'); + }); + it.each< [ name: string, From 9a90f35a42090fd6635a3372f04920b6c5ed4917 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 17:08:49 +0000 Subject: [PATCH 12/27] [lexical-markdown] Bug Fix: Don't put tags around inline code that holds only spaces ## Description Letting space-only inline code hold format tags exported tags its neighbours don't share, next to a code fence, where CommonMark flanking fails: plain `a`, bold code ` `, plain `b` exported ``a**` `**b`` and re-imported with literal asterisks, where main exported ``a` `b``. `canHoldTag` is back to "has non-whitespace text" for every node, as main decided, and only empty inline code is left out of the export. ## Test plan ### Before Probe against this branch's previous head: ``a**` `**b``. ### After ``` ./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts Test Files 19 passed (19) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-formatted-whitespace-export.md | 4 ++-- .../lexical-markdown/src/MarkdownExport.ts | 24 +++++++++---------- .../__tests__/unit/LexicalMarkdown.test.ts | 24 ------------------- 3 files changed, 14 insertions(+), 38 deletions(-) diff --git a/.changeset/markdown-formatted-whitespace-export.md b/.changeset/markdown-formatted-whitespace-export.md index f56369d44e6..9e41906d47f 100644 --- a/.changeset/markdown-formatted-whitespace-export.md +++ b/.changeset/markdown-formatted-whitespace-export.md @@ -4,6 +4,6 @@ [fix] Markdown export keeps format markers balanced around a space whose formats differ from the text beside it -Closing markers such as `**`, `*` or `~~` were dropped, repeated or placed after the space, so `**_a_ [b](u)**` exported as `***a* [**b**](u)`. `$convertSelectionToMarkdownString` also closes every marker when the selection ends inside formatted text. Inline code that holds only spaces now keeps the formats around it, and empty inline code, such as inline code a selection only touches, is left out. +Closing markers such as `**`, `*` or `~~` were dropped, repeated or placed after the space, so `**_a_ [b](u)**` exported as `***a* [**b**](u)`. `$convertSelectionToMarkdownString` also closes every marker when the selection ends inside formatted text. Empty inline code, such as inline code a selection only touches, is left out. -Verify: Markdown export, including of selections, of text where a space or inline code carries different formatting than the words around it. +Verify: Markdown export, including of selections, of text where a space carries different formatting than the words around it. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index fa8e3e91c2e..98748f33b28 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -507,7 +507,7 @@ function $exportTextFormat( ); } - const holdsTag = canHoldTag(node, textContent); + const holdsTag = canHoldTag(textContent); // the opening tags to be added to the result let openingTags = ''; @@ -586,13 +586,7 @@ function $exportTextFormat( } break; } - // If the node is entirely whitespace (or empty inline code), we don't apply - // opening/closing tags around it, but it must still output any closing tags. - if (!holdsTag) { - return closingTagsBefore + closingTagsAfter + output; - } - - if (isCode) { + if (isCode && textContent !== '') { // Inline code is an atomic literal span with a content-derived fence, so // its whitespace stays inside the fence and other formats wrap around it. const {fence, padded} = getCodeSpanDelimiter(textContent); @@ -606,6 +600,12 @@ function $exportTextFormat( ); } + // If the node is entirely whitespace, we don't apply opening/closing tags + // around it, but it must still output any closing tags. + if (!holdsTag) { + return closingTagsBefore + closingTagsAfter + output; + } + // Flanking Compliance: CommonMark flanking rules require formatting tags to be // adjacent to non-whitespace characters, so openingTags and closingTagsAfter // are placed INSIDE the whitespace boundaries! @@ -649,7 +649,7 @@ function $continuesFormat( return false; } const text = $exportedText(next, selection); - if (canHoldTag(next, text)) { + if (canHoldTag(text)) { return true; } // Markdown import matches a tag within one line. @@ -677,8 +677,8 @@ function hasFormat( /** * Whether exported text can carry a format's tag: CommonMark flanking rules - * keep tags off whitespace, but inline code is a fenced span. + * keep tags off whitespace. */ -function canHoldTag(node: TextNode, text: string): boolean { - return node.hasFormat('code') ? text !== '' : /\S/.test(text); +function canHoldTag(text: string): boolean { + return /\S/.test(text); } diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index ba2d10a65c3..450fb093dd3 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -1749,7 +1749,6 @@ describe('Markdown', () => { const BOLD = TEXT_TYPE_TO_FORMAT.bold; const ITALIC = TEXT_TYPE_TO_FORMAT.italic; const STRIKE = TEXT_TYPE_TO_FORMAT.strikethrough; - const INLINE_CODE = TEXT_TYPE_TO_FORMAT.code; const $formatted = (text: string, format: number) => $createTextNode(text).setFormat(format); @@ -1837,29 +1836,6 @@ describe('Markdown', () => { editor.read($chars), ); }); - - it('keeps a tag on inline code that holds only a space', () => { - const editor = createTestEditor(); - editor.update( - () => { - $getRoot() - .clear() - .append( - $createParagraphNode().append( - $formatted('a', BOLD), - $formatted(' ', BOLD | INLINE_CODE), - ), - ); - }, - {discrete: true}, - ); - const formats = importMarkdown(editor.read($exportMarkdown)).read(() => - $getRoot() - .getAllTextNodes() - .map(node => node.getFormat()), - ); - expect(formats).toEqual([BOLD, BOLD | INLINE_CODE]); - }); }); describe('list marker', () => { From f560bce6bb7356ad31bbcd7fdaca5b98d71cec52 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 17:17:25 +0000 Subject: [PATCH 13/27] [lexical-markdown] Chore: Test inline code that holds only a space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Adds round-trip tests for space-only inline code with another format: between plain text it must not gain tags (an earlier head of this branch exported ``a**` `**b``), and between bold text the bold run stays open across it. ## Test plan ### Before ``` ./node_modules/.bin/vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "only a space" # against a13106f's exporter × leaves the formats of inline code that holds only a space off ``` ### After ``` ./node_modules/.bin/vitest run packages/lexical-markdown Test Files 10 passed (10) ``` Co-Authored-By: Claude Opus 5.5 --- .../__tests__/unit/LexicalMarkdown.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 450fb093dd3..770d2dd4204 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -1836,6 +1836,38 @@ describe('Markdown', () => { editor.read($chars), ); }); + + it.each<[name: string, $nodes: () => LexicalNode[]]>([ + [ + 'leaves the formats of inline code that holds only a space off', + () => [ + $formatted('a', 0), + $formatted(' ', BOLD | TEXT_TYPE_TO_FORMAT.code), + $formatted('b', 0), + ], + ], + [ + 'keeps a tag open across inline code that holds only a space', + () => [ + $formatted('a', BOLD), + $formatted(' ', BOLD | TEXT_TYPE_TO_FORMAT.code), + $formatted('b', BOLD), + ], + ], + ])('%s', (_name, $nodes) => { + const editor = createTestEditor(); + editor.update( + () => { + $getRoot() + .clear() + .append($createParagraphNode().append(...$nodes())); + }, + {discrete: true}, + ); + expect(importMarkdown(editor.read($exportMarkdown)).read($chars)).toEqual( + editor.read($chars), + ); + }); }); describe('list marker', () => { From 18eed9337520476cd60ed39525a6c37708f6c0db Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 17:31:25 +0000 Subject: [PATCH 14/27] [lexical-markdown] Refactor: Require every argument of $exportTextFormat ## Description A selection-path call that left out the trailing `selection` argument would still type-check and run the look-ahead over the whole document, bringing back unclosed tags at the end of a selection. `$exportTextFormat`'s last three parameters are now required, and the full-document calls pass `null`. The space-only inline code tests now also match the exported string (padding left open), and a selection test is renamed to what it now covers. ## Test plan ### After ``` ./node_modules/.bin/tsc -p ./tsconfig.test.json ./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts Test Files 19 passed (19) ``` Co-Authored-By: Claude Opus 5.5 --- packages/lexical-markdown/src/MarkdownExport.ts | 8 +++++--- .../src/__tests__/unit/LexicalMarkdown.test.ts | 13 +++++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 98748f33b28..a3d47cf786a 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -429,6 +429,7 @@ function $exportChildren( unclosedTags, unclosableTags, shouldPreserveNewLines, + null, ), ); @@ -449,6 +450,7 @@ function $exportChildren( unclosedTags, unclosableTags, shouldPreserveNewLines, + null, ), ); } else if ($isElementNode(child)) { @@ -481,10 +483,10 @@ function $exportTextFormat( textTransformers: TextFormatTransformer[], // unclosed tags include the markdown tags that haven't been closed yet, and their associated formats unclosedTags: {format: TextFormatType; tag: string}[], - unclosableTags?: {format: TextFormatType; tag: string}[], - shouldPreserveNewLines: boolean = false, + unclosableTags: {format: TextFormatType; tag: string}[], + shouldPreserveNewLines: boolean, // the selection being exported, which ends the text a tag can extend over - selection: BaseSelection | null = null, + selection: BaseSelection | null, ): string { // This function handles the case of a string looking like this: " foo " // Where it would be invalid markdown to generate: "** foo **" diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 770d2dd4204..d77a480527e 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -1837,7 +1837,8 @@ describe('Markdown', () => { ); }); - it.each<[name: string, $nodes: () => LexicalNode[]]>([ + // The code span's padding is not what these test, so it is left open. + it.each<[name: string, $nodes: () => LexicalNode[], expected: RegExp]>([ [ 'leaves the formats of inline code that holds only a space off', () => [ @@ -1845,6 +1846,7 @@ describe('Markdown', () => { $formatted(' ', BOLD | TEXT_TYPE_TO_FORMAT.code), $formatted('b', 0), ], + /^a` +`b$/, ], [ 'keeps a tag open across inline code that holds only a space', @@ -1853,8 +1855,9 @@ describe('Markdown', () => { $formatted(' ', BOLD | TEXT_TYPE_TO_FORMAT.code), $formatted('b', BOLD), ], + /^\*\*a` +`b\*\*$/, ], - ])('%s', (_name, $nodes) => { + ])('%s', (_name, $nodes, expected) => { const editor = createTestEditor(); editor.update( () => { @@ -1864,7 +1867,9 @@ describe('Markdown', () => { }, {discrete: true}, ); - expect(importMarkdown(editor.read($exportMarkdown)).read($chars)).toEqual( + const exported = editor.read($exportMarkdown); + expect(exported).toMatch(expected); + expect(importMarkdown(exported).read($chars)).toEqual( editor.read($chars), ); }); @@ -4064,7 +4069,7 @@ describe('$convertSelectionToMarkdownString whitespace slices', () => { expect(result).toBe(' **b**'); }); - it('closes a tag left open by the previous node when the slice is cut down to whitespace', () => { + it('closes the tags of the previous node before a slice cut down to whitespace', () => { const editor = createTestEditor({nodes: [LinkNode]}); editor.update( () => { From 1ef27af9fcd005a0e30fd413ac86680f846519a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 17:40:25 +0000 Subject: [PATCH 15/27] [lexical-markdown] Refactor: Share one return for inline code and text in $exportTextFormat ## Description Inline code now sets its fenced span as the output and goes through the same tag assembly as text (a fence has no surrounding whitespace, so the whitespace split leaves it whole), instead of a second copy of the concatenation. Drops a `unclosableTags &&` guard the now-required parameter makes redundant, and adds a selection test across consecutive formatted spaces. ## Test plan ### After ``` ./node_modules/.bin/tsc -p ./tsconfig.test.json ./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts Test Files 19 passed (19) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 18 +++-------- .../__tests__/unit/LexicalMarkdown.test.ts | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index a3d47cf786a..888446dcb9b 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -567,7 +567,6 @@ function $exportTextFormat( // If tag is unclosable, don't close it and leave it in the original array, // So that it can be closed when it's no longer unclosable if ( - unclosableTags && unclosedTag && unclosableTags.find(element => element.tag === unclosedTag.tag) ) { @@ -592,19 +591,10 @@ function $exportTextFormat( // Inline code is an atomic literal span with a content-derived fence, so // its whitespace stays inside the fence and other formats wrap around it. const {fence, padded} = getCodeSpanDelimiter(textContent); - return ( - closingTagsBefore + - openingTags + - fence + - padded + - fence + - closingTagsAfter - ); - } - - // If the node is entirely whitespace, we don't apply opening/closing tags - // around it, but it must still output any closing tags. - if (!holdsTag) { + output = fence + padded + fence; + } else if (!holdsTag) { + // If the node is entirely whitespace, we don't apply opening/closing tags + // around it, but it must still output any closing tags. return closingTagsBefore + closingTagsAfter + output; } diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index d77a480527e..07e52393cb8 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -4117,6 +4117,38 @@ describe('$convertSelectionToMarkdownString whitespace slices', () => { expect(result).toBe('[***a*** ](u)c'); }); + it('keeps a tag open across consecutive formatted spaces in a selection', () => { + const editor = createTestEditor({nodes: [LinkNode]}); + editor.update( + () => { + const bold = TEXT_TYPE_TO_FORMAT.bold; + const first = $createTextNode('a').setFormat(bold); + const last = $createTextNode('cd').setFormat(bold); + $getRoot().append( + $createParagraphNode().append( + first, + $createTextNode(' ').setFormat(bold | TEXT_TYPE_TO_FORMAT.italic), + $createTextNode(' ').setFormat( + bold | TEXT_TYPE_TO_FORMAT.strikethrough, + ), + last, + ), + ); + $setSelectionFromCaretRange( + $getCaretRange( + $getTextPointCaret(first, 'next', 0), + $getTextPointCaret(last, 'next', 1), + ), + ); + }, + {discrete: true}, + ); + const result = editor.read('latest', () => + $convertSelectionToMarkdownString(TRANSFORMERS, $getSelection()), + ); + expect(result).toBe('**a c**'); + }); + it.each< [ name: string, From b4b5aa26b5963b4e751db3aaa39292bf171135cc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 17:55:53 +0000 Subject: [PATCH 16/27] [lexical-markdown] Chore: Fold the new whitespace export tests into their tables ## Description The space-only inline code cases join the formatted-whitespace table (which now takes a string or a pattern), and the two standalone selection tests join the selection table, which now names the start and end node of the selection. ## Test plan ### After ``` ./node_modules/.bin/vitest run packages/lexical-markdown Tests 820 passed (820) ``` Co-Authored-By: Claude Opus 5.5 --- .../__tests__/unit/LexicalMarkdown.test.ts | 131 ++++++------------ 1 file changed, 44 insertions(+), 87 deletions(-) diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 07e52393cb8..6665b13735e 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -1752,7 +1752,11 @@ describe('Markdown', () => { const $formatted = (text: string, format: number) => $createTextNode(text).setFormat(format); - it.each<[name: string, $nodes: () => LexicalNode[], expected: string]>([ + // The code span's padding is not what the inline code cases test, so + // their expected export leaves it open. + it.each< + [name: string, $nodes: () => LexicalNode[], expected: string | RegExp] + >([ [ 'closes before unformatted text', () => [ @@ -1820,25 +1824,6 @@ describe('Markdown', () => { ], '**a**\n**c**', ], - ])('%s', (_name, $nodes, expected) => { - const editor = createTestEditor(); - editor.update( - () => { - $getRoot() - .clear() - .append($createParagraphNode().append(...$nodes())); - }, - {discrete: true}, - ); - const exported = editor.read($exportMarkdown); - expect(exported).toBe(expected); - expect(importMarkdown(exported).read($chars)).toEqual( - editor.read($chars), - ); - }); - - // The code span's padding is not what these test, so it is left open. - it.each<[name: string, $nodes: () => LexicalNode[], expected: RegExp]>([ [ 'leaves the formats of inline code that holds only a space off', () => [ @@ -1868,7 +1853,11 @@ describe('Markdown', () => { {discrete: true}, ); const exported = editor.read($exportMarkdown); - expect(exported).toMatch(expected); + if (typeof expected === 'string') { + expect(exported).toBe(expected); + } else { + expect(exported).toMatch(expected); + } expect(importMarkdown(exported).read($chars)).toEqual( editor.read($chars), ); @@ -4069,31 +4058,6 @@ describe('$convertSelectionToMarkdownString whitespace slices', () => { expect(result).toBe(' **b**'); }); - it('closes the tags of the previous node before a slice cut down to whitespace', () => { - const editor = createTestEditor({nodes: [LinkNode]}); - editor.update( - () => { - const first = $createTextNode('a'); - first.toggleFormat('bold'); - first.toggleFormat('italic'); - const second = $createTextNode(' b'); - second.toggleFormat('bold'); - $getRoot().append($createParagraphNode().append(first, second)); - $setSelectionFromCaretRange( - $getCaretRange( - $getTextPointCaret(first, 'next', 0), - $getTextPointCaret(second, 'next', 1), - ), - ); - }, - {discrete: true}, - ); - const result = editor.read('latest', () => - $convertSelectionToMarkdownString(TRANSFORMERS, $getSelection()), - ); - expect(result).toBe('***a*** '); - }); - it('closes a tag before a formatted space at the end of a selected link', () => { const editor = createTestEditor({nodes: [LinkNode]}); editor.update( @@ -4117,53 +4081,46 @@ describe('$convertSelectionToMarkdownString whitespace slices', () => { expect(result).toBe('[***a*** ](u)c'); }); - it('keeps a tag open across consecutive formatted spaces in a selection', () => { - const editor = createTestEditor({nodes: [LinkNode]}); - editor.update( - () => { - const bold = TEXT_TYPE_TO_FORMAT.bold; - const first = $createTextNode('a').setFormat(bold); - const last = $createTextNode('cd').setFormat(bold); - $getRoot().append( - $createParagraphNode().append( - first, - $createTextNode(' ').setFormat(bold | TEXT_TYPE_TO_FORMAT.italic), - $createTextNode(' ').setFormat( - bold | TEXT_TYPE_TO_FORMAT.strikethrough, - ), - last, - ), - ); - $setSelectionFromCaretRange( - $getCaretRange( - $getTextPointCaret(first, 'next', 0), - $getTextPointCaret(last, 'next', 1), - ), - ); - }, - {discrete: true}, - ); - const result = editor.read('latest', () => - $convertSelectionToMarkdownString(TRANSFORMERS, $getSelection()), - ); - expect(result).toBe('**a c**'); - }); - it.each< [ name: string, formats: [text: string, format: number][], - offsets: [start: number, end: number], + selection: [ + startNode: number, + startOffset: number, + endNode: number, + endOffset: number, + ], expected: string, ] >([ + [ + 'closes the tags of the previous node before a slice cut down to whitespace', + [ + ['a', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.italic], + [' b', TEXT_TYPE_TO_FORMAT.bold], + ], + [0, 0, 1, 1], + '***a*** ', + ], + [ + 'keeps a tag open across consecutive formatted spaces', + [ + ['a', TEXT_TYPE_TO_FORMAT.bold], + [' ', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.italic], + [' ', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.strikethrough], + ['cd', TEXT_TYPE_TO_FORMAT.bold], + ], + [0, 0, 3, 1], + '**a c**', + ], [ 'closes before a slice cut down to a formatted space', [ ['a ', TEXT_TYPE_TO_FORMAT.bold], [' b', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.italic], ], - [0, 1], + [0, 0, 1, 1], '**a** ', ], [ @@ -4173,7 +4130,7 @@ describe('$convertSelectionToMarkdownString whitespace slices', () => { [' x', TEXT_TYPE_TO_FORMAT.bold], ['c', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.italic], ], - [0, 1], + [0, 0, 1, 1], '***a*** ', ], [ @@ -4183,7 +4140,7 @@ describe('$convertSelectionToMarkdownString whitespace slices', () => { ['bc', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.italic], ['d', TEXT_TYPE_TO_FORMAT.bold], ], - [0, 1], + [0, 0, 1, 1], '**a*b***', ], [ @@ -4192,7 +4149,7 @@ describe('$convertSelectionToMarkdownString whitespace slices', () => { ['a', TEXT_TYPE_TO_FORMAT.bold], ['xy', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.code], ], - [0, 0], + [0, 0, 1, 0], '**a**', ], [ @@ -4201,10 +4158,10 @@ describe('$convertSelectionToMarkdownString whitespace slices', () => { ['xy', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.code], ['a', TEXT_TYPE_TO_FORMAT.bold], ], - [2, 1], + [0, 2, 1, 1], '**a**', ], - ])('%s', (_name, formats, [start, end], expected) => { + ])('%s', (_name, formats, [startNode, start, endNode, end], expected) => { const editor = createTestEditor({nodes: [LinkNode]}); editor.update( () => { @@ -4214,8 +4171,8 @@ describe('$convertSelectionToMarkdownString whitespace slices', () => { $getRoot().append($createParagraphNode().append(...nodes)); $setSelectionFromCaretRange( $getCaretRange( - $getTextPointCaret(nodes[0], 'next', start), - $getTextPointCaret(nodes[1], 'next', end), + $getTextPointCaret(nodes[startNode], 'next', start), + $getTextPointCaret(nodes[endNode], 'next', end), ), ); }, From e1701552f3939a7846314d20abe4a444be050149 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 18:04:47 +0000 Subject: [PATCH 17/27] [lexical-markdown] Chore: Update the keep-open comment for the look-ahead Co-Authored-By: Claude Opus 5.5 --- packages/lexical-markdown/src/MarkdownExport.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 888446dcb9b..f8a9c1ba2d7 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -551,7 +551,7 @@ function $exportTextFormat( // close any tags in the same order they were applied, if necessary for (let i = 0; i < unclosedTags.length; i++) { const nodeHasFormat = hasFormat(node, unclosedTags[i].format); - // prevent adding closing tag if next sibling will do it + // prevent adding closing tag if later text with the format will do it if ( nodeHasFormat && $continuesFormat(node, unclosedTags[i].format, selection) From 84b6e3fb596aaa4994af506622078d8057a6faee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 20:06:35 +0000 Subject: [PATCH 18/27] [lexical-markdown] Bug Fix: Walk each run of formatted spaces once in Markdown export ## Description The look-ahead that keeps a tag open across formatted whitespace walked from every node of a run to its end, so a long run of alternating formatted spaces made export quadratic (6.1 s for 8,000 nodes, against 18 ms on main). The open tag now remembers the node that kept it open and the later text that holds it, so the rest of the run reuses that answer, and each sibling is visited a bounded number of times. Selection export also asked the selection for its nodes once per node, and a read-only selection doesn't cache them, so each call rebuilt the whole range; that made it quadratic on main already. It now works on a copy of the selection that caches its nodes. ## Test plan ### Before ``` ./node_modules/.bin/vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "bounded" # against 8faba12 AssertionError: expected 126254 to be less than 5000 ``` Scratch benchmark, 1 bold `a`, N alternating bold italic / bold strikethrough spaces, bold `c` (full export): 8faba12 6.1 s at 8,000. ### After ``` ./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts Test Files 19 passed (19) ``` Same benchmark: full export 4 / 11 / 18 ms at 1,000 / 4,000 / 16,000 nodes; selection export 33 / 174 / 2,510 ms (main: 351 / 4,959 ms at 1,000 / 4,000). Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 58 +++++++++++++------ .../__tests__/unit/LexicalMarkdown.test.ts | 48 ++++++++++++++- 2 files changed, 86 insertions(+), 20 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index f8a9c1ba2d7..acbacc75865 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -111,7 +111,12 @@ export function createSelectionMarkdownExport( ); }); - return selection => { + return exportedSelection => { + // Every node asks whether it is selected, which reads the selection's + // nodes. A read-only selection doesn't cache them, so this works on a + // copy that does, instead of recomputing them for every node. + const selection = exportedSelection.clone(); + selection.setCachedNodes(exportedSelection.getNodes()); const output = []; // The separator depends on the previously *emitted* block, not on the // previous root child: unselected children produce no output, so keying @@ -212,14 +217,21 @@ function $processNodeForSelection( } } +type OpenTag = { + format: TextFormatType; + tag: string; + /** See `$continuesTag`: the last node it kept this tag open past, and the later text that holds the tag. */ + continues?: {from: TextNode; to: TextNode}; +}; + function $exportChildrenForSelection( node: ElementNode, selection: BaseSelection, textFormatTransformers: TextFormatTransformer[], textMatchTransformers: TextMatchTransformer[], shouldPreserveNewLines: boolean, - unclosedTags?: {format: TextFormatType; tag: string}[], - unclosableTags?: {format: TextFormatType; tag: string}[], + unclosedTags?: OpenTag[], + unclosableTags?: OpenTag[], ): {shouldInclude: boolean; markdown: string} { const output = []; const children = node.getChildren(); @@ -385,8 +397,8 @@ function $exportChildren( node: ElementNode, textTransformersIndex: TextFormatTransformer[], textMatchTransformers: TextMatchTransformer[], - unclosedTags?: {format: TextFormatType; tag: string}[], - unclosableTags?: {format: TextFormatType; tag: string}[], + unclosedTags?: OpenTag[], + unclosableTags?: OpenTag[], shouldPreserveNewLines: boolean = false, ): string { const output = []; @@ -482,8 +494,8 @@ function $exportTextFormat( textContent: string, textTransformers: TextFormatTransformer[], // unclosed tags include the markdown tags that haven't been closed yet, and their associated formats - unclosedTags: {format: TextFormatType; tag: string}[], - unclosableTags: {format: TextFormatType; tag: string}[], + unclosedTags: OpenTag[], + unclosableTags: OpenTag[], shouldPreserveNewLines: boolean, // the selection being exported, which ends the text a tag can extend over selection: BaseSelection | null, @@ -552,10 +564,7 @@ function $exportTextFormat( for (let i = 0; i < unclosedTags.length; i++) { const nodeHasFormat = hasFormat(node, unclosedTags[i].format); // prevent adding closing tag if later text with the format will do it - if ( - nodeHasFormat && - $continuesFormat(node, unclosedTags[i].format, selection) - ) { + if (nodeHasFormat && $continuesTag(node, unclosedTags[i], selection)) { continue; } @@ -625,23 +634,36 @@ function getTextSibling(node: TextNode, backward: boolean): TextNode | null { } /** - * Whether `format` carries on past `node` to exported text that can hold its - * tag. Whitespace-only siblings cannot (the tag must close before the space), - * so they are looked past while they keep the format. Text outside the - * exported selection does not count. + * Whether `tag` carries on past `node`, which has its format, to exported + * text that can hold it. Whitespace-only siblings cannot (the tag must close + * before the space), so they are looked past while they keep the format. + * Text outside the exported selection does not count. The text found is + * remembered on the tag, so the nodes of a whitespace run before it reuse the + * answer instead of walking the rest of the run again. */ -function $continuesFormat( +function $continuesTag( node: TextNode, - format: TextFormatType, + tag: OpenTag, selection: BaseSelection | null, ): boolean { + const {continues} = tag; + if ( + continues && + !node.is(continues.to) && + continues.from.is(node.getPreviousSibling()) + ) { + continues.from = node; + return true; + } + tag.continues = undefined; let next = getTextSibling(node, false); - while (next && next.hasFormat(format)) { + while (next && next.hasFormat(tag.format)) { if (selection && !next.isSelected(selection)) { return false; } const text = $exportedText(next, selection); if (canHoldTag(text)) { + tag.continues = {from: node, to: next}; return true; } // Markdown import matches a tag within one line. diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index a0e6bcfa4da..ae3406305ff 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -68,9 +68,9 @@ import { type LexicalNode, TEXT_TYPE_TO_FORMAT, type TextFormatType, - type TextNode, + TextNode, } from 'lexical'; -import {assert, describe, expect, it} from 'vitest'; +import {assert, describe, expect, it, onTestFinished, vi} from 'vitest'; import { hardLineBreakState, @@ -4044,6 +4044,50 @@ describe('$generateNodesFromMarkdownString', () => { }); }); +describe('Markdown export of long runs of formatted spaces', () => { + // Each space differs in format from its neighbours, so none of them merge. + // Visiting each sibling a bounded number of times keeps export linear; a + // look-ahead from every space to the end of the run is quadratic. + const SPACES = 500; + + it.each<[name: string, $export: () => string]>([ + ['the document', () => $convertToMarkdownString(TRANSFORMERS)], + [ + 'a selection', + () => $convertSelectionToMarkdownString(TRANSFORMERS, $getSelection()), + ], + ])( + 'visits each sibling a bounded number of times exporting %s', + (_name, $export) => { + const editor = createTestEditor(); + editor.update( + () => { + const {bold, italic, strikethrough} = TEXT_TYPE_TO_FORMAT; + const paragraph = $createParagraphNode().append( + $createTextNode('a').setFormat(bold), + ); + for (let i = 0; i < SPACES; i++) { + paragraph.append( + $createTextNode(' ').setFormat( + bold | (i % 2 ? italic : strikethrough), + ), + ); + } + paragraph.append($createTextNode('c').setFormat(bold)); + $getRoot().append(paragraph); + paragraph.select(0, paragraph.getChildrenSize()); + }, + {discrete: true}, + ); + const getNextSibling = vi.spyOn(TextNode.prototype, 'getNextSibling'); + onTestFinished(() => getNextSibling.mockRestore()); + const markdown = editor.read($export); + expect(markdown).toBe(`**a${' '.repeat(SPACES)}c**`); + expect(getNextSibling.mock.calls.length).toBeLessThan(10 * SPACES); + }, + ); +}); + describe('$convertSelectionToMarkdownString whitespace slices', () => { it('does not emit a dangling closing tag when the selection slices a format down to whitespace', () => { const editor = createTestEditor({nodes: [LinkNode]}); From 820f131e4d494461cd189a8c86b09b72dcba8c1a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 20:21:54 +0000 Subject: [PATCH 19/27] [lexical][lexical-markdown] Bug Fix: Look up isSelected in a cached key set ## Description `LexicalNode.isSelected` scanned `selection.getNodes()` on every call, so anything that asks it for every node of a selection, such as `$convertSelectionToMarkdownString` (directly and through `$sliceSelectedTextNodeContent`), was quadratic in the selection's size. When the selection caches its nodes, a Set of their keys is now cached with that node list (in a WeakMap keyed by the list), so each check is O(1). A selection that doesn't cache its nodes, such as a read-only one, still scans; Markdown selection export now works on a copy that caches them. The changeset now lists `lexical` too. ## Test plan ### Before Scratch benchmark, selection export of bold `a`, N alternating formatted spaces, bold `c`: 33 / 174 / 2,510 ms at 1,000 / 4,000 / 16,000 nodes. ### After Same benchmark: 39 / 44 / 51 / 200 ms at 1,000 / 4,000 / 16,000 / 64,000 nodes (full export 45 ms at 64,000). ``` ./node_modules/.bin/vitest run packages/lexical/src packages/lexical-selection packages/lexical-html packages/lexical-markdown packages/lexical-clipboard Test Files 172 passed (172) ./node_modules/.bin/vitest run scripts/__tests__/unit/treeShakingSource.test.ts Test Files 1 passed (1) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-formatted-whitespace-export.md | 3 ++- packages/lexical/src/LexicalNode.ts | 5 ++-- packages/lexical/src/LexicalUtils.ts | 23 +++++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.changeset/markdown-formatted-whitespace-export.md b/.changeset/markdown-formatted-whitespace-export.md index 9e41906d47f..d895a39e68e 100644 --- a/.changeset/markdown-formatted-whitespace-export.md +++ b/.changeset/markdown-formatted-whitespace-export.md @@ -1,9 +1,10 @@ --- +'lexical': patch '@lexical/markdown': patch --- [fix] Markdown export keeps format markers balanced around a space whose formats differ from the text beside it -Closing markers such as `**`, `*` or `~~` were dropped, repeated or placed after the space, so `**_a_ [b](u)**` exported as `***a* [**b**](u)`. `$convertSelectionToMarkdownString` also closes every marker when the selection ends inside formatted text. Empty inline code, such as inline code a selection only touches, is left out. +Closing markers such as `**`, `*` or `~~` were dropped, repeated or placed after the space, so `**_a_ [b](u)**` exported as `***a* [**b**](u)`. `$convertSelectionToMarkdownString` also closes every marker when the selection ends inside formatted text. Empty inline code, such as inline code a selection only touches, is left out. Export stays linear in the length of a run of formatted spaces, and `$convertSelectionToMarkdownString` no longer rebuilds the selection's nodes for every node it exports; `LexicalNode.isSelected` looks the node up in a cached set of keys when the selection caches its nodes, instead of scanning them on every call. Verify: Markdown export, including of selections, of text where a space carries different formatting than the words around it. diff --git a/packages/lexical/src/LexicalNode.ts b/packages/lexical/src/LexicalNode.ts index b2e3a1bf424..e080e356d0c 100644 --- a/packages/lexical/src/LexicalNode.ts +++ b/packages/lexical/src/LexicalNode.ts @@ -76,6 +76,7 @@ import { $getEditorDOMRenderConfig, $getNodeByKey, $hasAncestor, + $isKeyInSelectionNodes, $isRootOrShadowRoot, $maybeMoveChildrenSelectionToParent, $setCompositionKey, @@ -1313,9 +1314,7 @@ export class LexicalNode { return false; } - const isSelected = targetSelection - .getNodes() - .some(n => n.__key === this.__key); + const isSelected = $isKeyInSelectionNodes(targetSelection, this.__key); if ($isTextNode(this)) { return isSelected; diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index 739aee457c1..19e3312ea67 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -3099,6 +3099,29 @@ export function $getDOMTextNode( return getDOMTextNode(slot.element); } +const cachedSelectedKeys = new WeakMap>(); +/** + * @internal + * Whether the node with `key` is among the nodes of `selection`. When the + * selection caches its nodes, a Set of their keys is cached with them, so + * asking this for every node of a large selection stays linear. + */ +export function $isKeyInSelectionNodes( + selection: BaseSelection, + key: NodeKey, +): boolean { + const nodes = selection.getNodes(); + if (nodes !== selection.getCachedNodes()) { + return nodes.some(node => node.__key === key); + } + let keys = cachedSelectedKeys.get(nodes); + if (keys === undefined) { + keys = new Set(nodes.map(node => node.__key)); + cachedSelectedKeys.set(nodes, keys); + } + return keys.has(key); +} + /** @internal */ export type TypeToNodeMap = Map; /** From 1987ac9ac4e041ba5163cca5f82219edc16342f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 20:36:18 +0000 Subject: [PATCH 20/27] [lexical][lexical-markdown] Bug Fix: Keep Markdown selection export linear for any selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Selection export still sliced every exported text node with `$sliceSelectedTextNodeContent`, which works out the selection's direction and, for element points, its caret range on every call, so a selection ending mid-paragraph was quadratic. Only the text nodes at the selection's ends can have a slice that differs from their whole text, so those are found once per export and the rest use their text directly. `isSelected`'s key Set is now built only on a repeat lookup of the same cached node list, so an update that writes between lookups doesn't allocate one per call. The linearity test now selects to the middle of the paragraph with text points and from the middle with element points, counts both sibling directions, and uses 200 spaces (each case runs in under 20 ms). ## Test plan ### Before ``` ./node_modules/.bin/vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "bounded" # against 820f131's MarkdownExport.ts × visits each sibling a bounded number of times exporting a selection between text points × visits each sibling a bounded number of times exporting a selection between element points AssertionError: expected 40904 to be less than 2000 AssertionError: expected 10809 to be less than 2000 ``` Scratch benchmark, N alternating bold/italic `x` nodes, read-only selection export: text points to the middle 224 ms / 5,328 ms at 1,000 / 4,000; element points from the middle 106 ms / 1,877 ms. ### After Same benchmark: 35 / 33 ms and 5 / 16 ms. ``` ./node_modules/.bin/vitest run packages/lexical/src packages/lexical-selection packages/lexical-html packages/lexical-markdown packages/lexical-clipboard packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts scripts/__tests__/unit/treeShakingSource.test.ts Test Files 182 passed (182) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-formatted-whitespace-export.md | 2 +- .../lexical-markdown/src/MarkdownExport.ts | 41 ++++++++++-- .../__tests__/unit/LexicalMarkdown.test.ts | 67 +++++++++++++++---- packages/lexical/src/LexicalUtils.ts | 29 +++++--- 4 files changed, 110 insertions(+), 29 deletions(-) diff --git a/.changeset/markdown-formatted-whitespace-export.md b/.changeset/markdown-formatted-whitespace-export.md index d895a39e68e..5700f890a38 100644 --- a/.changeset/markdown-formatted-whitespace-export.md +++ b/.changeset/markdown-formatted-whitespace-export.md @@ -5,6 +5,6 @@ [fix] Markdown export keeps format markers balanced around a space whose formats differ from the text beside it -Closing markers such as `**`, `*` or `~~` were dropped, repeated or placed after the space, so `**_a_ [b](u)**` exported as `***a* [**b**](u)`. `$convertSelectionToMarkdownString` also closes every marker when the selection ends inside formatted text. Empty inline code, such as inline code a selection only touches, is left out. Export stays linear in the length of a run of formatted spaces, and `$convertSelectionToMarkdownString` no longer rebuilds the selection's nodes for every node it exports; `LexicalNode.isSelected` looks the node up in a cached set of keys when the selection caches its nodes, instead of scanning them on every call. +Closing markers such as `**`, `*` or `~~` were dropped, repeated or placed after the space, so `**_a_ [b](u)**` exported as `***a* [**b**](u)`. `$convertSelectionToMarkdownString` also closes every marker when the selection ends inside formatted text. Empty inline code, such as inline code a selection only touches, is left out. Export stays linear in the length of a run of formatted spaces, and `$convertSelectionToMarkdownString` no longer works out the selection's nodes or range again for every node it exports. `LexicalNode.isSelected`, asked repeatedly of a selection that caches its nodes, looks the node up in a cached set of their keys instead of scanning them each time. Verify: Markdown export, including of selections, of text where a space carries different formatting than the words around it. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index acbacc75865..2a864d80e72 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -8,16 +8,19 @@ import {$sliceSelectedTextNodeContent} from '@lexical/selection'; import { + $caretRangeFromSelection, $getRoot, $getState, $isDecoratorNode, $isElementNode, $isLineBreakNode, + $isRangeSelection, $isTextNode, type BaseSelection, type ElementNode, type LexicalNode, type LineBreakNode, + type NodeKey, type TextFormatType, type TextNode, } from 'lexical'; @@ -117,6 +120,7 @@ export function createSelectionMarkdownExport( // copy that does, instead of recomputing them for every node. const selection = exportedSelection.clone(); selection.setCachedNodes(exportedSelection.getNodes()); + boundaryTextKeys.set(selection, $getBoundaryTextKeys(selection)); const output = []; // The separator depends on the previously *emitted* block, not on the // previous root child: unselected children produce no output, so keying @@ -675,11 +679,40 @@ function $continuesTag( return false; } -/** The text of `node` that the export emits: its selected slice, if any. */ +/** + * The text nodes at the ends of each exported selection, the only ones whose + * selected slice can differ from their whole text. + */ +const boundaryTextKeys = new WeakMap>(); + +function $getBoundaryTextKeys(selection: BaseSelection): Set { + const keys = new Set(); + for (const point of selection.getStartEndPoints() || []) { + keys.add(point.key); + } + if ($isRangeSelection(selection)) { + for (const slice of $caretRangeFromSelection(selection).getTextSlices()) { + if (slice) { + keys.add(slice.caret.origin.getKey()); + } + } + } + return keys; +} + +/** + * The text of `node` that the export emits: its selected slice, if any. + * Slicing works out the selection's range, so it is only done for the nodes + * at its ends, which keeps a selection export linear. + */ function $exportedText(node: TextNode, selection: BaseSelection | null) { - return selection - ? $sliceSelectedTextNodeContent(selection, node, 'clone').getTextContent() - : node.getTextContent(); + if (selection === null) { + return node.getTextContent(); + } + const boundary = boundaryTextKeys.get(selection); + return boundary && !boundary.has(node.getKey()) + ? node.getTextContent() + : $sliceSelectedTextNodeContent(selection, node, 'clone').getTextContent(); } function hasFormat( diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index ae3406305ff..7e15d9ca1ac 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -63,6 +63,7 @@ import { $setState, COMPOSITION_END_TAG, type CreateEditorArgs, + type ElementNode, KEY_ENTER_COMMAND, type LexicalEditor, type LexicalNode, @@ -4047,18 +4048,52 @@ describe('$generateNodesFromMarkdownString', () => { describe('Markdown export of long runs of formatted spaces', () => { // Each space differs in format from its neighbours, so none of them merge. // Visiting each sibling a bounded number of times keeps export linear; a - // look-ahead from every space to the end of the run is quadratic. - const SPACES = 500; + // look-ahead from every space to the end of the run, or working out the + // selection's range again for every node, is quadratic. + const SPACES = 200; + const HALF = SPACES / 2; + const $convertSelection = () => + $convertSelectionToMarkdownString(TRANSFORMERS, $getSelection()); - it.each<[name: string, $export: () => string]>([ - ['the document', () => $convertToMarkdownString(TRANSFORMERS)], + it.each< + [ + name: string, + $select: (paragraph: ElementNode) => void, + $export: () => string, + expected: string, + ] + >([ + [ + 'the document', + () => {}, + () => $convertToMarkdownString(TRANSFORMERS), + `**a${' '.repeat(SPACES)}c**`, + ], [ - 'a selection', - () => $convertSelectionToMarkdownString(TRANSFORMERS, $getSelection()), + 'a selection between text points', + paragraph => { + const first = paragraph.getFirstChild(); + const middle = paragraph.getChildAtIndex(HALF); + assert($isTextNode(first) && $isTextNode(middle)); + $setSelectionFromCaretRange( + $getCaretRange( + $getTextPointCaret(first, 'next', 0), + $getTextPointCaret(middle, 'next', 1), + ), + ); + }, + $convertSelection, + `**a**${' '.repeat(HALF)}`, + ], + [ + 'a selection between element points', + paragraph => paragraph.select(HALF + 1, paragraph.getChildrenSize()), + $convertSelection, + `${' '.repeat(SPACES - HALF)}**c**`, ], ])( 'visits each sibling a bounded number of times exporting %s', - (_name, $export) => { + (_name, $select, $export, expected) => { const editor = createTestEditor(); editor.update( () => { @@ -4075,15 +4110,21 @@ describe('Markdown export of long runs of formatted spaces', () => { } paragraph.append($createTextNode('c').setFormat(bold)); $getRoot().append(paragraph); - paragraph.select(0, paragraph.getChildrenSize()); + $select(paragraph); }, {discrete: true}, ); - const getNextSibling = vi.spyOn(TextNode.prototype, 'getNextSibling'); - onTestFinished(() => getNextSibling.mockRestore()); - const markdown = editor.read($export); - expect(markdown).toBe(`**a${' '.repeat(SPACES)}c**`); - expect(getNextSibling.mock.calls.length).toBeLessThan(10 * SPACES); + const siblingReads = [ + vi.spyOn(TextNode.prototype, 'getNextSibling'), + vi.spyOn(TextNode.prototype, 'getPreviousSibling'), + ]; + onTestFinished(() => siblingReads.forEach(spy => spy.mockRestore())); + expect(editor.read($export)).toBe(expected); + const visits = siblingReads.reduce( + (sum, spy) => sum + spy.mock.calls.length, + 0, + ); + expect(visits).toBeLessThan(10 * SPACES); }, ); }); diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index 19e3312ea67..8a003db32f4 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -3099,27 +3099,34 @@ export function $getDOMTextNode( return getDOMTextNode(slot.element); } -const cachedSelectedKeys = new WeakMap>(); +const cachedSelectedKeys = new WeakMap | null>(); /** * @internal * Whether the node with `key` is among the nodes of `selection`. When the - * selection caches its nodes, a Set of their keys is cached with them, so - * asking this for every node of a large selection stays linear. + * selection caches its nodes and is asked more than once, a Set of their keys + * is cached with them, so asking this for every node of a large selection + * stays linear. */ export function $isKeyInSelectionNodes( selection: BaseSelection, key: NodeKey, ): boolean { const nodes = selection.getNodes(); - if (nodes !== selection.getCachedNodes()) { - return nodes.some(node => node.__key === key); - } - let keys = cachedSelectedKeys.get(nodes); - if (keys === undefined) { - keys = new Set(nodes.map(node => node.__key)); - cachedSelectedKeys.set(nodes, keys); + if (nodes === selection.getCachedNodes()) { + let keys = cachedSelectedKeys.get(nodes); + if (keys === null) { + keys = new Set(nodes.map(node => node.__key)); + cachedSelectedKeys.set(nodes, keys); + } else if (keys === undefined) { + // The first lookup scans, so a node list replaced after a single + // lookup (as an update that writes in between does) builds no Set. + cachedSelectedKeys.set(nodes, null); + } + if (keys) { + return keys.has(key); + } } - return keys.has(key); + return nodes.some(node => node.__key === key); } /** @internal */ From 11df62a0d274f48f2ca91e6613525156a1d1bf88 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 20:44:46 +0000 Subject: [PATCH 21/27] [lexical][lexical-markdown] Bug Fix: Remove the remaining quadratic paths in Markdown export ## Description - The whitespace split around formatting tags used `/^(\s*)(.*?)(\s*)$/s`, which backtracks quadratically over a long run of spaces inside one text node; it now uses `trim`/`trimStart`, which is linear and needs no fallback. - Selection export caches the selection's direction on its copy as well as its nodes, since slicing a node asks for it and a read-only selection doesn't cache it. - The copy is made after `getNodes()`, which can adjust a table selection's points. - `isSelected` returns early for an unselected node, so an element-point range no longer works out its direction for every inline decorator outside it. ## Test plan ### After ``` ./node_modules/.bin/vitest run packages/lexical/src packages/lexical-selection packages/lexical-html packages/lexical-markdown packages/lexical-clipboard packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts scripts/__tests__/unit/treeShakingSource.test.ts Test Files 182 passed (182) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 19 +++++++++++++++---- packages/lexical/src/LexicalNode.ts | 2 +- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 2a864d80e72..6f03376c601 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -118,8 +118,13 @@ export function createSelectionMarkdownExport( // Every node asks whether it is selected, which reads the selection's // nodes. A read-only selection doesn't cache them, so this works on a // copy that does, instead of recomputing them for every node. + // Its direction is cached too, since slicing a node asks for it. + const nodes = exportedSelection.getNodes(); const selection = exportedSelection.clone(); - selection.setCachedNodes(exportedSelection.getNodes()); + selection.setCachedNodes(nodes); + if ($isRangeSelection(selection)) { + selection._cachedIsBackward = exportedSelection.isBackward(); + } boundaryTextKeys.set(selection, $getBoundaryTextKeys(selection)); const output = []; // The separator depends on the previously *emitted* block, not on the @@ -614,9 +619,15 @@ function $exportTextFormat( // Flanking Compliance: CommonMark flanking rules require formatting tags to be // adjacent to non-whitespace characters, so openingTags and closingTagsAfter // are placed INSIDE the whitespace boundaries! - const [, leadingSpace, trimmedOutput, trailingSpace] = output.match( - /^(\s*)(.*?)(\s*)$/s, - ) || ['', '', output, '']; + // (Trimming rather than a regex keeps this linear in long whitespace runs.) + const trimmedOutput = output.trim(); + const leadingSpace = output.slice( + 0, + output.length - output.trimStart().length, + ); + const trailingSpace = output.slice( + leadingSpace.length + trimmedOutput.length, + ); return ( closingTagsBefore + leadingSpace + diff --git a/packages/lexical/src/LexicalNode.ts b/packages/lexical/src/LexicalNode.ts index e080e356d0c..88cf541362e 100644 --- a/packages/lexical/src/LexicalNode.ts +++ b/packages/lexical/src/LexicalNode.ts @@ -1316,7 +1316,7 @@ export class LexicalNode { const isSelected = $isKeyInSelectionNodes(targetSelection, this.__key); - if ($isTextNode(this)) { + if (!isSelected || $isTextNode(this)) { return isSelected; } // For inline images inside of element nodes. From 105a27bef70889eb2727a65bb2c9bb7ca28e61c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:02:45 +0000 Subject: [PATCH 22/27] [lexical-markdown] Bug Fix: Remember negative look-ahead answers too ## Description `$continuesTag` remembered only a positive answer, so a tag that stayed open after a negative one (an unclosable tag in a text match transformer's children) walked the rest of the run from every node. The tag now remembers the answer and the sibling the walk stopped at, which every node in between shares. Also drops a redundant caret-range pass when finding the selection's end nodes (only text points produce text slices), and trims the flanking whitespace in one pass from each end. ## Test plan ### After ``` ./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts Test Files 19 passed (19) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 59 ++++++++----------- 1 file changed, 26 insertions(+), 33 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 6f03376c601..2b283badd30 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -8,7 +8,6 @@ import {$sliceSelectedTextNodeContent} from '@lexical/selection'; import { - $caretRangeFromSelection, $getRoot, $getState, $isDecoratorNode, @@ -229,8 +228,11 @@ function $processNodeForSelection( type OpenTag = { format: TextFormatType; tag: string; - /** See `$continuesTag`: the last node it kept this tag open past, and the later text that holds the tag. */ - continues?: {from: TextNode; to: TextNode}; + /** + * See `$continuesTag`: the last node it answered for, its answer, and the + * sibling its walk stopped at, which every node in between shares. + */ + continues?: {from: TextNode; result: boolean; until: TextNode | null}; }; function $exportChildrenForSelection( @@ -620,14 +622,10 @@ function $exportTextFormat( // adjacent to non-whitespace characters, so openingTags and closingTagsAfter // are placed INSIDE the whitespace boundaries! // (Trimming rather than a regex keeps this linear in long whitespace runs.) - const trimmedOutput = output.trim(); - const leadingSpace = output.slice( - 0, - output.length - output.trimStart().length, - ); - const trailingSpace = output.slice( - leadingSpace.length + trimmedOutput.length, - ); + const trimmedStart = output.trimStart(); + const trimmedOutput = trimmedStart.trimEnd(); + const leadingSpace = output.slice(0, output.length - trimmedStart.length); + const trailingSpace = trimmedStart.slice(trimmedOutput.length); return ( closingTagsBefore + leadingSpace + @@ -652,9 +650,9 @@ function getTextSibling(node: TextNode, backward: boolean): TextNode | null { * Whether `tag` carries on past `node`, which has its format, to exported * text that can hold it. Whitespace-only siblings cannot (the tag must close * before the space), so they are looked past while they keep the format. - * Text outside the exported selection does not count. The text found is - * remembered on the tag, so the nodes of a whitespace run before it reuse the - * answer instead of walking the rest of the run again. + * Text outside the exported selection does not count. The answer and where + * the walk stopped are remembered on the tag, so the rest of a whitespace run + * reuses it instead of walking the run again. */ function $continuesTag( node: TextNode, @@ -664,30 +662,32 @@ function $continuesTag( const {continues} = tag; if ( continues && - !node.is(continues.to) && - continues.from.is(node.getPreviousSibling()) + continues.from.is(node.getPreviousSibling()) && + !node.is(continues.until) ) { continues.from = node; - return true; + return continues.result; } - tag.continues = undefined; let next = getTextSibling(node, false); - while (next && next.hasFormat(tag.format)) { - if (selection && !next.isSelected(selection)) { - return false; - } + let result = false; + while ( + next && + next.hasFormat(tag.format) && + (!selection || next.isSelected(selection)) + ) { const text = $exportedText(next, selection); if (canHoldTag(text)) { - tag.continues = {from: node, to: next}; - return true; + result = true; + break; } // Markdown import matches a tag within one line. if (text.includes('\n')) { - return false; + break; } next = getTextSibling(next, false); } - return false; + tag.continues = {from: node, result, until: next}; + return result; } /** @@ -701,13 +701,6 @@ function $getBoundaryTextKeys(selection: BaseSelection): Set { for (const point of selection.getStartEndPoints() || []) { keys.add(point.key); } - if ($isRangeSelection(selection)) { - for (const slice of $caretRangeFromSelection(selection).getTextSlices()) { - if (slice) { - keys.add(slice.caret.origin.getKey()); - } - } - } return keys; } From 7a8e26c65716c56e759e8347504ef62717c3e9da Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:12:19 +0000 Subject: [PATCH 23/27] [lexical-markdown] Refactor: Slice only text-point nodes without a side table ## Description Only the node of a text point can be cut short by a selection, so `$exportedText` now compares the node with the selection's points directly instead of looking the end nodes up in a module-level table keyed by the selection copy; a selection that a transformer passes in stays on the fast path too. `$continuesTag` reuses the previous text sibling that `$exportTextFormat` already looked up. ## Test plan ### After ``` ./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts Test Files 19 passed (19) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 44 ++++++------------- 1 file changed, 13 insertions(+), 31 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 2b283badd30..022457ec5b5 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -19,7 +19,6 @@ import { type ElementNode, type LexicalNode, type LineBreakNode, - type NodeKey, type TextFormatType, type TextNode, } from 'lexical'; @@ -124,7 +123,6 @@ export function createSelectionMarkdownExport( if ($isRangeSelection(selection)) { selection._cachedIsBackward = exportedSelection.isBackward(); } - boundaryTextKeys.set(selection, $getBoundaryTextKeys(selection)); const output = []; // The separator depends on the previously *emitted* block, not on the // previous root child: unselected children produce no output, so keying @@ -575,7 +573,10 @@ function $exportTextFormat( for (let i = 0; i < unclosedTags.length; i++) { const nodeHasFormat = hasFormat(node, unclosedTags[i].format); // prevent adding closing tag if later text with the format will do it - if (nodeHasFormat && $continuesTag(node, unclosedTags[i], selection)) { + if ( + nodeHasFormat && + $continuesTag(node, prevNode, unclosedTags[i], selection) + ) { continue; } @@ -656,15 +657,12 @@ function getTextSibling(node: TextNode, backward: boolean): TextNode | null { */ function $continuesTag( node: TextNode, + prevNode: TextNode | null, tag: OpenTag, selection: BaseSelection | null, ): boolean { const {continues} = tag; - if ( - continues && - continues.from.is(node.getPreviousSibling()) && - !node.is(continues.until) - ) { + if (continues && continues.from.is(prevNode) && !node.is(continues.until)) { continues.from = node; return continues.result; } @@ -690,33 +688,17 @@ function $continuesTag( return result; } -/** - * The text nodes at the ends of each exported selection, the only ones whose - * selected slice can differ from their whole text. - */ -const boundaryTextKeys = new WeakMap>(); - -function $getBoundaryTextKeys(selection: BaseSelection): Set { - const keys = new Set(); - for (const point of selection.getStartEndPoints() || []) { - keys.add(point.key); - } - return keys; -} - /** * The text of `node` that the export emits: its selected slice, if any. - * Slicing works out the selection's range, so it is only done for the nodes - * at its ends, which keeps a selection export linear. + * Only the node of a text point can be cut short, and slicing works out the + * selection's range, so only those nodes are sliced, keeping export linear. */ function $exportedText(node: TextNode, selection: BaseSelection | null) { - if (selection === null) { - return node.getTextContent(); - } - const boundary = boundaryTextKeys.get(selection); - return boundary && !boundary.has(node.getKey()) - ? node.getTextContent() - : $sliceSelectedTextNodeContent(selection, node, 'clone').getTextContent(); + const key = node.getKey(); + return selection && + (selection.getStartEndPoints() || []).some(point => point.key === key) + ? $sliceSelectedTextNodeContent(selection, node, 'clone').getTextContent() + : node.getTextContent(); } function hasFormat( From 5f47c76cc5f3a512441556affabdc3c597304621 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:21:10 +0000 Subject: [PATCH 24/27] [lexical-markdown] Refactor: Remember look-ahead answers per node, independent of call order ## Description The look-ahead memo only held while the same tag was asked about every node of a run in order; when an earlier unclosable tag cut the close loop short, a later tag's memo went stale and the run was walked again from each node. Every whitespace node a walk passes over shares its answer, so the tag now records the answer for each of them by key, and a run is walked once whatever order its nodes are asked in. ## Test plan ### After ``` ./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts Test Files 19 passed (19) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 022457ec5b5..e6c23ec6103 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -19,6 +19,7 @@ import { type ElementNode, type LexicalNode, type LineBreakNode, + type NodeKey, type TextFormatType, type TextNode, } from 'lexical'; @@ -226,11 +227,8 @@ function $processNodeForSelection( type OpenTag = { format: TextFormatType; tag: string; - /** - * See `$continuesTag`: the last node it answered for, its answer, and the - * sibling its walk stopped at, which every node in between shares. - */ - continues?: {from: TextNode; result: boolean; until: TextNode | null}; + /** `$continuesTag`'s answers so far, by node key. */ + answers?: Map; }; function $exportChildrenForSelection( @@ -573,10 +571,7 @@ function $exportTextFormat( for (let i = 0; i < unclosedTags.length; i++) { const nodeHasFormat = hasFormat(node, unclosedTags[i].format); // prevent adding closing tag if later text with the format will do it - if ( - nodeHasFormat && - $continuesTag(node, prevNode, unclosedTags[i], selection) - ) { + if (nodeHasFormat && $continuesTag(node, unclosedTags[i], selection)) { continue; } @@ -651,21 +646,21 @@ function getTextSibling(node: TextNode, backward: boolean): TextNode | null { * Whether `tag` carries on past `node`, which has its format, to exported * text that can hold it. Whitespace-only siblings cannot (the tag must close * before the space), so they are looked past while they keep the format. - * Text outside the exported selection does not count. The answer and where - * the walk stopped are remembered on the tag, so the rest of a whitespace run - * reuses it instead of walking the run again. + * Text outside the exported selection does not count. Every whitespace node + * the walk passes over shares its answer, so that is remembered on the tag + * and each run is walked once, whatever order its nodes are asked in. */ function $continuesTag( node: TextNode, - prevNode: TextNode | null, tag: OpenTag, selection: BaseSelection | null, ): boolean { - const {continues} = tag; - if (continues && continues.from.is(prevNode) && !node.is(continues.until)) { - continues.from = node; - return continues.result; + const answers = tag.answers || (tag.answers = new Map()); + const known = answers.get(node.getKey()); + if (known !== undefined) { + return known; } + const passed = [node]; let next = getTextSibling(node, false); let result = false; while ( @@ -682,9 +677,12 @@ function $continuesTag( if (text.includes('\n')) { break; } + passed.push(next); next = getTextSibling(next, false); } - tag.continues = {from: node, result, until: next}; + for (const passedNode of passed) { + answers.set(passedNode.getKey(), result); + } return result; } From 8ea5b97feac91a814e64df529e12d7d15a0bdd04 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:27:51 +0000 Subject: [PATCH 25/27] [lexical-markdown] Refactor: Stop the look-ahead at a whitespace node that already has an answer ## Description A walk now stops at a whitespace sibling the tag already answered for and takes its answer, so a run is walked once even when its nodes are asked right to left. The answers map is created with the open tag instead of lazily, and a walk that passes no whitespace stores its one answer without an intermediate array. ## Test plan ### After ``` ./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts Test Files 19 passed (19) ``` Co-Authored-By: Claude Opus 5.5 --- packages/lexical-markdown/src/MarkdownExport.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index e6c23ec6103..bc35856008e 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -228,7 +228,7 @@ type OpenTag = { format: TextFormatType; tag: string; /** `$continuesTag`'s answers so far, by node key. */ - answers?: Map; + answers: Map; }; function $exportChildrenForSelection( @@ -561,7 +561,7 @@ function $exportTextFormat( !hasFormat(prevNode, format) || !unclosedTags.find(element => element.tag === tag) ) { - unclosedTags.push({format, tag}); + unclosedTags.push({answers: new Map(), format, tag}); openingTags += tag; } } @@ -655,12 +655,12 @@ function $continuesTag( tag: OpenTag, selection: BaseSelection | null, ): boolean { - const answers = tag.answers || (tag.answers = new Map()); + const {answers} = tag; const known = answers.get(node.getKey()); if (known !== undefined) { return known; } - const passed = [node]; + const passed: TextNode[] = []; let next = getTextSibling(node, false); let result = false; while ( @@ -677,9 +677,16 @@ function $continuesTag( if (text.includes('\n')) { break; } + // A whitespace node already answered for shares the answer. + const passedAnswer = answers.get(next.getKey()); + if (passedAnswer !== undefined) { + result = passedAnswer; + break; + } passed.push(next); next = getTextSibling(next, false); } + answers.set(node.getKey(), result); for (const passedNode of passed) { answers.set(passedNode.getKey(), result); } From 4ef14ef8ade0b3cf22ffe18d9b91c3620744d6a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:36:15 +0000 Subject: [PATCH 26/27] [lexical-selection][lexical-markdown] Refactor: Return early from $sliceSelectedTextNodeContent for nodes no text point is in ## Description Only the node of a text point can be cut short by a selection (element points never shorten a text node, since only text points produce text slices). `$sliceSelectedTextNodeContent` now checks that before `isSelected` and the slice computation, returning the node itself as it already did for those nodes, so any caller that slices every selected node (HTML and clipboard export as well as Markdown) no longer works out the selection for each one. Markdown's own copy of that rule is removed, and the changeset lists `@lexical/selection`. ## Test plan ### After ``` ./node_modules/.bin/vitest run packages/lexical/src packages/lexical-selection packages/lexical-html packages/lexical-markdown packages/lexical-clipboard packages/lexical-mdast packages/lexical-table packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts scripts/__tests__/unit/treeShakingSource.test.ts Test Files 212 passed (212) ``` Co-Authored-By: Claude Opus 5.5 --- .changeset/markdown-formatted-whitespace-export.md | 3 ++- packages/lexical-markdown/src/MarkdownExport.ts | 10 ++-------- packages/lexical-selection/src/lexical-node.ts | 4 ++++ 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.changeset/markdown-formatted-whitespace-export.md b/.changeset/markdown-formatted-whitespace-export.md index 5700f890a38..951acf2b463 100644 --- a/.changeset/markdown-formatted-whitespace-export.md +++ b/.changeset/markdown-formatted-whitespace-export.md @@ -1,10 +1,11 @@ --- 'lexical': patch '@lexical/markdown': patch +'@lexical/selection': patch --- [fix] Markdown export keeps format markers balanced around a space whose formats differ from the text beside it -Closing markers such as `**`, `*` or `~~` were dropped, repeated or placed after the space, so `**_a_ [b](u)**` exported as `***a* [**b**](u)`. `$convertSelectionToMarkdownString` also closes every marker when the selection ends inside formatted text. Empty inline code, such as inline code a selection only touches, is left out. Export stays linear in the length of a run of formatted spaces, and `$convertSelectionToMarkdownString` no longer works out the selection's nodes or range again for every node it exports. `LexicalNode.isSelected`, asked repeatedly of a selection that caches its nodes, looks the node up in a cached set of their keys instead of scanning them each time. +Closing markers such as `**`, `*` or `~~` were dropped, repeated or placed after the space, so `**_a_ [b](u)**` exported as `***a* [**b**](u)`. `$convertSelectionToMarkdownString` also closes every marker when the selection ends inside formatted text. Empty inline code, such as inline code a selection only touches, is left out. Export stays linear in the length of a run of formatted spaces, and `$convertSelectionToMarkdownString` no longer works out the selection's nodes or range again for every node it exports. `LexicalNode.isSelected`, asked repeatedly of a selection that caches its nodes, looks the node up in a cached set of their keys instead of scanning them each time. `$sliceSelectedTextNodeContent` returns a node that no text point is in without working out the selection. Verify: Markdown export, including of selections, of text where a space carries different formatting than the words around it. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index bc35856008e..cf74cf2f333 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -693,15 +693,9 @@ function $continuesTag( return result; } -/** - * The text of `node` that the export emits: its selected slice, if any. - * Only the node of a text point can be cut short, and slicing works out the - * selection's range, so only those nodes are sliced, keeping export linear. - */ +/** The text of `node` that the export emits: its selected slice, if any. */ function $exportedText(node: TextNode, selection: BaseSelection | null) { - const key = node.getKey(); - return selection && - (selection.getStartEndPoints() || []).some(point => point.key === key) + return selection ? $sliceSelectedTextNodeContent(selection, node, 'clone').getTextContent() : node.getTextContent(); } diff --git a/packages/lexical-selection/src/lexical-node.ts b/packages/lexical-selection/src/lexical-node.ts index 387e65c7406..5b37f2d8d73 100644 --- a/packages/lexical-selection/src/lexical-node.ts +++ b/packages/lexical-selection/src/lexical-node.ts @@ -48,8 +48,12 @@ export function $sliceSelectedTextNodeContent( mutates: 'clone' | 'self' = 'self', ): T { const points = selection.getStartEndPoints(); + // Only the node of a text point can be cut short; checking that first + // keeps a caller that slices every selected node from working out the + // selection for each of them. if ( points !== null && + (points[0].key === textNode.__key || points[1].key === textNode.__key) && textNode.isSelected(selection) && !$isTokenOrSegmented(textNode) ) { From 2e41cdcbb78cdc6f737d8682ef04abd075bdba51 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:53:17 +0000 Subject: [PATCH 27/27] [lexical-markdown] Chore: Declare OpenTag as an interface Co-Authored-By: Claude Opus 5.5 --- packages/lexical-markdown/src/MarkdownExport.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index cf74cf2f333..09cbaf259c5 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -224,12 +224,12 @@ function $processNodeForSelection( } } -type OpenTag = { +interface OpenTag { format: TextFormatType; tag: string; /** `$continuesTag`'s answers so far, by node key. */ answers: Map; -}; +} function $exportChildrenForSelection( node: ElementNode,