From 0f674d198065d2569a3c3e96753529daeed4ac3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Tue, 7 Apr 2026 15:43:34 +0400 Subject: [PATCH] Better handle list transformation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Émile Ré --- .../OptionsMenu/OptionsMenuContent.tsx | 183 ++++++++++++------ 1 file changed, 126 insertions(+), 57 deletions(-) diff --git a/packages/ui/src/RichEditor/OptionsMenu/OptionsMenuContent.tsx b/packages/ui/src/RichEditor/OptionsMenu/OptionsMenuContent.tsx index 6709b735e..7cc1348ba 100644 --- a/packages/ui/src/RichEditor/OptionsMenu/OptionsMenuContent.tsx +++ b/packages/ui/src/RichEditor/OptionsMenu/OptionsMenuContent.tsx @@ -3,6 +3,7 @@ // that can be found in the LICENSE file. import { CodeBlockIcon, ListBulletsIcon, ListNumbersIcon, QuotesIcon, TextHFourIcon, TextHOneIcon, TextHThreeIcon, TextHTwoIcon, TextTIcon } from "@phosphor-icons/react"; +import { Fragment, type Node as PmNode } from "@tiptap/pm/model"; import { TextSelection } from "@tiptap/pm/state"; import { type Editor } from "@tiptap/react"; @@ -14,6 +15,59 @@ import { optionsMenuVariants } from "./variants"; const { menu } = optionsMenuVariants(); +type TargetKind = "textblock" | "wrapper"; + +function collectTextBlocks(node: PmNode): PmNode[] { + const result: PmNode[] = []; + node.forEach((child) => { + if (child.isTextblock) { + result.push(child); + } else { + result.push(...collectTextBlocks(child)); + } + }); + return result; +} + +type DecomposeResult = { + targetContent: Fragment | null; + remaining: PmNode[]; +}; + +function decomposeWrapper(node: PmNode): DecomposeResult { + const firstChild = node.firstChild; + if (!firstChild) { + return { targetContent: null, remaining: [] }; + } + + let targetContent: Fragment | null = null; + const promotedSiblings: PmNode[] = []; + + if (firstChild.isTextblock) { + targetContent = firstChild.content; + } else { + firstChild.forEach((child) => { + if (targetContent === null && child.isTextblock) { + targetContent = child.content; + } else { + promotedSiblings.push(child); + } + }); + } + + const remainingChildren: PmNode[] = []; + node.forEach((_child, _offset, index) => { + if (index > 0) remainingChildren.push(_child); + }); + + const remaining: PmNode[] = [...promotedSiblings]; + if (remainingChildren.length > 0) { + remaining.push(node.copy(Fragment.from(remainingChildren))); + } + + return { targetContent, remaining }; +} + type OptionsMenuContentProps = { editor: Editor; hoveredBlock: HTMLElement | null; @@ -35,6 +89,7 @@ export function OptionsMenuContent({ }: OptionsMenuContentProps) { const handleAction = ( applyCommand: (chain: ReturnType) => ReturnType, + targetKind: TargetKind, ) => { if (!hoveredBlock) { setMenuOpen(false); @@ -47,70 +102,84 @@ export function OptionsMenuContent({ } try { - if (!data.node.isTextblock) { - if (!data.node.firstChild) { - const paragraph = editor.state.schema.nodes.paragraph.create(); + const { node, pos } = data; + const schema = editor.state.schema; + + if (node.isTextblock) { + const $near = editor.state.doc.resolve(pos + 1); + const textPos = TextSelection.near($near).from; + + applyCommand( editor.chain() .focus() - .command(({ tr }) => { - tr.replaceWith(data.pos, data.pos + data.node.nodeSize, paragraph); - return true; - }) - .run(); - - const $near = editor.state.doc.resolve(data.pos + 1); - const textPos = TextSelection.near($near).from; - - applyCommand( - editor.chain() - .focus() - .setTextSelection(textPos), - ).run(); - + .setTextSelection(textPos), + ).run(); + } else if (targetKind === "textblock") { + const { targetContent, remaining } = decomposeWrapper(node); + if (!targetContent) { setMenuOpen(false); return; } - let textBlock = data.node.firstChild; - if (!textBlock) return; - while (!textBlock.isTextblock && textBlock.firstChild) { - textBlock = textBlock.firstChild; - } - if (!textBlock.isTextblock) return; + const target = schema.nodes.paragraph.create(null, targetContent); + const replacements = [target, ...remaining]; - const firstChildSize = data.node.firstChild.nodeSize; - const paragraph = editor.state.schema.nodes.paragraph.create( - null, - textBlock.content, + editor.chain() + .focus() + .command(({ tr }) => { + tr.replaceWith( + pos, + pos + node.nodeSize, + Fragment.from(replacements), + ); + return true; + }) + .run(); + + const $near = editor.state.doc.resolve(pos + 1); + const textPos = TextSelection.near($near).from; + + applyCommand( + editor.chain() + .focus() + .setTextSelection(textPos), + ).run(); + } else { + const textBlocks = collectTextBlocks(node); + if (textBlocks.length === 0) { + setMenuOpen(false); + return; + } + + const paragraphs = textBlocks.map(tb => + schema.nodes.paragraph.create(null, tb.content), ); editor.chain() .focus() .command(({ tr }) => { - tr.insert(data.pos, paragraph); - const wrapperPos = data.pos + paragraph.nodeSize; - const wrapperNode = tr.doc.nodeAt(wrapperPos); - if (!wrapperNode) return false; - - if (wrapperNode.childCount <= 1) { - tr.delete(wrapperPos, wrapperPos + wrapperNode.nodeSize); - } else { - tr.delete(wrapperPos + 1, wrapperPos + 1 + firstChildSize); - } - + tr.replaceWith( + pos, + pos + node.nodeSize, + Fragment.from(paragraphs), + ); return true; }) .run(); + + let totalSize = 0; + for (const p of paragraphs) { + totalSize += p.nodeSize; + } + const from = pos + 1; + const to = pos + totalSize - 1; + + applyCommand( + editor.chain() + .focus() + .setTextSelection({ from, to }), + ).run(); } - - const $near = editor.state.doc.resolve(data.pos + 1); - const textPos = TextSelection.near($near).from; - - applyCommand( - editor.chain() - .focus() - .setTextSelection(textPos), - ).run(); } catch { // Block may no longer be in the document } @@ -132,63 +201,63 @@ export function OptionsMenuContent({
Turn into
handleAction(chain => chain.setParagraph())} + onClick={() => handleAction(chain => chain.setParagraph(), "textblock")} > Text handleAction(chain => chain.toggleHeading({ level: 1 }))} + onClick={() => handleAction(chain => chain.toggleHeading({ level: 1 }), "textblock")} > Heading 1 handleAction(chain => chain.toggleHeading({ level: 2 }))} + onClick={() => handleAction(chain => chain.toggleHeading({ level: 2 }), "textblock")} > Heading 2 handleAction(chain => chain.toggleHeading({ level: 3 }))} + onClick={() => handleAction(chain => chain.toggleHeading({ level: 3 }), "textblock")} > Heading 3 handleAction(chain => chain.toggleHeading({ level: 4 }))} + onClick={() => handleAction(chain => chain.toggleHeading({ level: 4 }), "textblock")} > Heading 4 handleAction(chain => chain.toggleBulletList())} + onClick={() => handleAction(chain => chain.toggleBulletList(), "wrapper")} > Bullet List handleAction(chain => chain.toggleOrderedList())} + onClick={() => handleAction(chain => chain.toggleOrderedList(), "wrapper")} > Ordered List handleAction(chain => chain.toggleCodeBlock())} + onClick={() => handleAction(chain => chain.toggleCodeBlock(), "textblock")} > Code Block handleAction(chain => chain.toggleBlockquote())} + onClick={() => handleAction(chain => chain.toggleBlockquote(), "wrapper")} > Blockquote