Skip to content

Commit d89cf74

Browse files
committed
fix(editor): merge live-typed nested quote with adjacent QuoteNode sibling
QuoteNestingShortcutPlugin used to unconditionally replace the typed paragraph with a fresh nested chain, so typing `> ccc` on an outer-quote line below an existing `> > bbb` produced two adjacent same-depth QuoteNodes that rendered as separate blocks until the file was reopened. Mirror the QUOTE transformer's previous-is-quote branch by splicing the new tail into the existing sibling via $mergeIntoQuoteTree and absorbing a trailing sibling QuoteNode so live typing and the import path converge on the same tree shape.
1 parent db756ad commit d89cf74

3 files changed

Lines changed: 128 additions & 12 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { $isQuoteNode } from "@lexical/rich-text";
2+
import { $createParagraphNode, $getRoot, $isParagraphNode } from "lexical";
3+
import { describe, expect, test } from "vitest";
4+
5+
import { renderTestEditor } from "./__tests__/utils";
6+
import { QuoteNestingShortcutPlugin } from "./QuoteNestingShortcutPlugin";
7+
8+
describe("QuoteNestingShortcutPlugin (live typing)", () => {
9+
// Production "open `> aaa\n> > bbb`, hit Enter on `bbb` to exit one level,
10+
// then type `> ccc`" should merge `ccc` into the existing nested QuoteNode
11+
// — the on-disk shape (`> aaa\n> > bbb\n> > ccc`) round-trips as a single
12+
// inner QuoteNode with two paragraphs, so the live editor must converge to
13+
// the same tree. Without the previous-is-quote merge, the typed `> ccc`
14+
// would spawn a sibling QuoteNode adjacent to the existing one and the
15+
// user would see two separate quote blocks until the file is reopened.
16+
//
17+
// Before:
18+
// > aaa
19+
// > > bbb
20+
// > |
21+
//
22+
// After typing `> ccc`:
23+
// > aaa
24+
// > > bbb
25+
// > > ccc|
26+
test("typing `> ccc` on an outer-quote line below a nested quote merges into the nested quote", async () => {
27+
const { editor, screen, user } = await renderTestEditor({
28+
initialValue: "> aaa\n> > bbb",
29+
plugins: <QuoteNestingShortcutPlugin />,
30+
});
31+
32+
const textbox = screen.getByRole("textbox");
33+
await user.click(textbox);
34+
35+
// Append an empty trailing paragraph inside the outer QuoteNode and
36+
// place the cursor there — this is the state the user is in after
37+
// pressing Enter on `bbb` (exiting one level of nesting). Built manually
38+
// so the test isolates the plugin under test instead of relying on the
39+
// Enter-exit pipeline.
40+
editor.update(
41+
() => {
42+
const root = $getRoot();
43+
const outerQuote = root.getFirstChild();
44+
if (!$isQuoteNode(outerQuote)) throw new Error("expected QuoteNode at root[0]");
45+
const trailing = $createParagraphNode();
46+
outerQuote.append(trailing);
47+
trailing.selectStart();
48+
},
49+
{ discrete: true },
50+
);
51+
52+
await user.keyboard("> ccc");
53+
54+
editor.getEditorState().read(() => {
55+
const root = $getRoot();
56+
expect(root.getChildrenSize()).toBe(1);
57+
58+
const outerQuote = root.getFirstChild();
59+
if (!$isQuoteNode(outerQuote)) throw new Error("expected QuoteNode at root[0]");
60+
expect(outerQuote.getChildrenSize()).toBe(2);
61+
62+
const leading = outerQuote.getChildAtIndex(0);
63+
if (!$isParagraphNode(leading)) throw new Error("expected ParagraphNode at outer[0]");
64+
expect(leading.getTextContent()).toBe("aaa");
65+
66+
const innerQuote = outerQuote.getChildAtIndex(1);
67+
if (!$isQuoteNode(innerQuote)) throw new Error("expected nested QuoteNode at outer[1]");
68+
expect(innerQuote.getChildrenSize()).toBe(2);
69+
70+
const bbb = innerQuote.getChildAtIndex(0);
71+
if (!$isParagraphNode(bbb)) throw new Error("expected ParagraphNode at inner[0]");
72+
expect(bbb.getTextContent()).toBe("bbb");
73+
74+
const ccc = innerQuote.getChildAtIndex(1);
75+
if (!$isParagraphNode(ccc)) throw new Error("expected ParagraphNode at inner[1]");
76+
expect(ccc.getTextContent()).toBe("ccc");
77+
});
78+
});
79+
});

src/components/molecules/MarkdownEditor/QuoteNestingShortcutPlugin.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import {
1616
} from "lexical";
1717
import { useEffect } from "react";
1818

19-
import { $createNestedQuoteChain } from "./transformers";
19+
import {
20+
$absorbTrailingQuoteSibling,
21+
$createNestedQuoteChain,
22+
$mergeIntoQuoteTree,
23+
} from "./transformers";
2024

2125
/**
2226
* Live conversion of `> ` (or `> > `, `> > > `, ...) typed at the start of a
@@ -190,7 +194,29 @@ export function QuoteNestingShortcutPlugin(): null {
190194
}
191195
}
192196

193-
paragraph.replace($createNestedQuoteChain(depthIncrement, tailParagraph));
197+
// Mirror of the root-level QUOTE transformer's previous-is-quote
198+
// branch: when the typed paragraph already has a QuoteNode above it
199+
// inside the same parent, splice the new tail into that existing
200+
// QuoteNode (via `$mergeIntoQuoteTree`) instead of spawning a
201+
// sibling one. Without this the live editor would render two
202+
// adjacent same-depth blockquotes, while reopening the saved
203+
// Markdown (which round-trips through `$mergeIntoQuoteTree` on the
204+
// import side) would collapse them into a single one — see the
205+
// `> > bbb` + `> > ccc` case in `QuoteNestingShortcutPlugin.spec`.
206+
// `$absorbTrailingQuoteSibling` covers the symmetric case where a
207+
// QuoteNode also follows the typed paragraph.
208+
const previous = paragraph.getPreviousSibling();
209+
if ($isQuoteNode(previous)) {
210+
$mergeIntoQuoteTree(previous, tailParagraph, depthIncrement);
211+
paragraph.remove();
212+
$absorbTrailingQuoteSibling(previous);
213+
tailParagraph.selectStart();
214+
return;
215+
}
216+
217+
const newChain = $createNestedQuoteChain(depthIncrement, tailParagraph);
218+
paragraph.replace(newChain);
219+
$absorbTrailingQuoteSibling(newChain);
194220
tailParagraph.selectStart();
195221
},
196222
{ tag: HISTORY_MERGE_TAG, discrete: true },

src/components/molecules/MarkdownEditor/transformers.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -484,11 +484,18 @@ const QUOTE: ElementTransformer = {
484484

485485
// If `quote`'s next sibling is also a QuoteNode at the same parent level,
486486
// concatenate that sibling's children onto `quote` and drop the sibling.
487-
// Used by the QUOTE transformer's live-typing path to bridge a
488-
// `> ` typed on a spacer paragraph into both surrounding QuoteNodes —
489-
// without it the merged-into-previous QuoteNode would sit adjacent to the
490-
// untouched trailing one, requiring a second user action to fuse them.
491-
function $absorbTrailingQuoteSibling(quote: QuoteNode): void {
487+
// Two callers, both with the same shape problem (a freshly-created or
488+
// freshly-merged QuoteNode left sitting next to an untouched same-level
489+
// QuoteNode that would render as two separate blocks):
490+
//
491+
// - QUOTE transformer's live-typing path: `> ` typed on a spacer paragraph
492+
// between two QuoteNodes — fuses both surrounding QuoteNodes into the
493+
// merged structure instead of leaving the trailing one dangling.
494+
// - QuoteNestingShortcutPlugin: `> ` typed inside an existing QuoteNode
495+
// where the original paragraph also had a QuoteNode AFTER it (the
496+
// mirror of the previous-is-quote merge), so the new nested chain
497+
// doesn't sit adjacent to an untouched trailing nested QuoteNode.
498+
export function $absorbTrailingQuoteSibling(quote: QuoteNode): void {
492499
const next = quote.getNextSibling();
493500
if ($isQuoteNode(next)) {
494501
quote.append(...next.getChildren());
@@ -610,18 +617,22 @@ function $exportNestedQuote(
610617
return lines;
611618
}
612619

613-
// Splice a freshly-imported `> ...` line into the previously-imported
614-
// QuoteNode tree at its `targetDepth`. The previous QuoteNode's tail is found
615-
// by walking `getLastChild()` down through nested QuoteNodes; that's the
616-
// current depth at which subsequent lines would naturally continue.
620+
// Splice a `> ...` line into an existing QuoteNode tree at `targetDepth`,
621+
// relative to `outer` (so `outer` itself is depth 1). The tail is found by
622+
// walking `getLastChild()` down through nested QuoteNodes; that's the current
623+
// depth at which subsequent lines would naturally continue. Used both by the
624+
// QUOTE transformer's import path and by `QuoteNestingShortcutPlugin`'s
625+
// previous-is-quote merge (live typing of `> ` on an outer-quote line below
626+
// an existing nested QuoteNode must converge to the same tree shape as
627+
// reloading the saved Markdown).
617628
//
618629
// target === tail → append paragraph at the same depth (a new quote line)
619630
// target > tail → open `target - tail` more nested QuoteNodes via
620631
// `$createNestedQuoteChain`, attach the chain at tail
621632
// target < tail → re-descend the OUTER quote's last-child path only to
622633
// `target`, append paragraph there (the line returned to
623634
// a shallower level)
624-
function $mergeIntoQuoteTree(
635+
export function $mergeIntoQuoteTree(
625636
outer: QuoteNode,
626637
newParagraph: ParagraphNode,
627638
targetDepth: number,

0 commit comments

Comments
 (0)