From bace1c9329d958294540b7fd113bfc345f815a78 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:57:47 +0100 Subject: [PATCH 1/5] Put slash-menu blocks where the dialect allows them A table cell is a Lexical shadow root, so `getTopLevelElement` stopped inside it and every slash block (callout, table, columns, task list, code, diagram, formula, tabs, diff) landed in the cell, which holds only inline text. The server refused the batch or saved a corrupted cell. Those blocks now go after the whole table, as Research already did; the inline Image stays in the cell. The same placement helper also moves a callout or tabs out of a container that forbids them, and inserts a table, tabs or task list inside a callout, column or tab instead of `$insertNodes` dropping them or `$insertList` replacing the container. The phone dock's `/` and Insert panel share it. Co-Authored-By: Claude Opus 5.5 --- ...39E4DVKAS9-callout-column-and-tab-are-n.md | 8 + ...DTHW8ZBX2Y-a-table-cell-is-a-shadow-roo.md | 8 + e2e/toolbar.e2e.ts | 16 ++ .../editor/src/toolbar/block-target.test.ts | 183 ++++++++++++++++++ packages/editor/src/toolbar/block-target.ts | 128 ++++++++++++ packages/editor/src/toolbar/dock-panel.tsx | 14 +- packages/editor/src/toolbar/research.tsx | 19 +- packages/editor/src/toolbar/slash.tsx | 58 +++--- 8 files changed, 377 insertions(+), 57 deletions(-) create mode 100644 .lane/memory/packages/editor/src/toolbar/block-target.ts/01M4T48KDXKXYB5B39E4DVKAS9-callout-column-and-tab-are-n.md create mode 100644 .lane/memory/packages/editor/src/toolbar/block-target.ts/01M4T48KFD1RPNQ3DTHW8ZBX2Y-a-table-cell-is-a-shadow-roo.md create mode 100644 packages/editor/src/toolbar/block-target.test.ts create mode 100644 packages/editor/src/toolbar/block-target.ts diff --git a/.lane/memory/packages/editor/src/toolbar/block-target.ts/01M4T48KDXKXYB5B39E4DVKAS9-callout-column-and-tab-are-n.md b/.lane/memory/packages/editor/src/toolbar/block-target.ts/01M4T48KDXKXYB5B39E4DVKAS9-callout-column-and-tab-are-n.md new file mode 100644 index 000000000..d3991adac --- /dev/null +++ b/.lane/memory/packages/editor/src/toolbar/block-target.ts/01M4T48KDXKXYB5B39E4DVKAS9-callout-column-and-tab-are-n.md @@ -0,0 +1,8 @@ +--- +id: 01M4T48KDXKXYB5B39E4DVKAS9 +anchor: function $containedBlock +created: 2026-10-13T07:57:33Z +norm: '1' +--- + +Callout, Column and Tab are not Lexical shadow roots, so in Lexical 0.48 $insertNodes flattens a block inserted in them to inline text (silently dropping a table or tabs) and $insertList turns the whole container into the list. Insert relative to the host block instead. diff --git a/.lane/memory/packages/editor/src/toolbar/block-target.ts/01M4T48KFD1RPNQ3DTHW8ZBX2Y-a-table-cell-is-a-shadow-roo.md b/.lane/memory/packages/editor/src/toolbar/block-target.ts/01M4T48KFD1RPNQ3DTHW8ZBX2Y-a-table-cell-is-a-shadow-roo.md new file mode 100644 index 000000000..7b4202b8a --- /dev/null +++ b/.lane/memory/packages/editor/src/toolbar/block-target.ts/01M4T48KFD1RPNQ3DTHW8ZBX2Y-a-table-cell-is-a-shadow-roo.md @@ -0,0 +1,8 @@ +--- +id: 01M4T48KFD1RPNQ3DTHW8ZBX2Y +anchor: function $leaveForBlock +created: 2026-10-13T07:57:34Z +norm: '1' +--- + +A table cell is a shadow root (getTopLevelElement stops inside it) but holds only inline text in the dialect; every slash block except the inline Image goes after the whole table, matching Research. diff --git a/e2e/toolbar.e2e.ts b/e2e/toolbar.e2e.ts index db571f1a2..d5e1f37be 100644 --- a/e2e/toolbar.e2e.ts +++ b/e2e/toolbar.e2e.ts @@ -265,6 +265,22 @@ test("choosing a table inserts one, with a header", async ({ join }) => { await expect(content(page)).not.toContainText("/table"); }); +test("a block chosen in a table cell goes after the table", async ({ join, room, seed }) => { + // A cell holds only inline text, so a code block written into one would be refused. + await seed("| Item | Note |\n| ---- | ---- |\n| one | |\n\nAfter.\n"); + let page = await join("ana"); + await edit(page); + + await content(page).getByRole("cell").last().click(); + await page.keyboard.type("/code"); + await page.getByRole("listbox", MENU).getByRole("option", { name: "Code block" }).click(); + await page.keyboard.type("let cell = 1;"); + + await written(page, room, /\| one +\| +\|\n\n```\nlet cell = 1;\n```\n\nAfter\./); + await expect(content(page).locator("table code")).toHaveCount(0); + await expect(page.getByText("Edits not saved")).toHaveCount(0); +}); + test("a selection raises exactly one toolbar", async ({ join }) => { let page = await join("ana"); diff --git a/packages/editor/src/toolbar/block-target.test.ts b/packages/editor/src/toolbar/block-target.test.ts new file mode 100644 index 000000000..ea1308dd2 --- /dev/null +++ b/packages/editor/src/toolbar/block-target.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "bun:test"; +import { createHeadlessEditor } from "@lexical/headless"; +import { $insertList, INSERT_CHECK_LIST_COMMAND, registerList } from "@lexical/list"; +import { + $getRoot, + $getSelection, + $isElementNode, + $isRangeSelection, + COMMAND_PRIORITY_LOW, +} from "lexical"; + +import { exportPlan, importPlan, parse, registry, validate } from "@chopin/dialect"; + +import { $openBlock } from "./block-target"; +import { availableCommands } from "./slash"; + +import type { LexicalEditor } from "lexical"; + +const IDS = [ + "01K0N4W3B7P27CBAEC7A8C8WEA", + "01K0N4W3B7P27CBAEC7A8C8WEB", + "01K0N4W3B7P27CBAEC7A8C8WEC", +]; + +const TABLE = "Intro.\n\n| A | B |\n| - | - |\n| one | Caret |\n\nOutro.\n"; +const LIST = "- first\n- Caret\n- last\n"; +const COLUMNS = `\n\n\nCaret\n\n\n` + + `\n\nSecond.\n\n\n\n`; +const CALLOUT = `\n\nCaret\n\n\n`; +const TABS = `\n\n\nCaret\n\n\n\n`; + +/** Every slash insert, and the top-level node type each one leaves behind. */ +const BLOCKS: Record = { + callout: "plan-callout", + table: "table", + columns: "plan-columns", + tasks: "list", + code: "plan-code", + mermaid: "plan-code", + math: "plan-math", + tabs: "plan-tabs", + diff: "plan-code", +}; + +function editorWith(source: string): LexicalEditor { + let editor = createHeadlessEditor({ + nodes: registry().nodes, + onError: error => { + throw error; + }, + }); + registerList(editor); + // The browser registers this with the check-list plugin, which needs a DOM root. + editor.registerCommand(INSERT_CHECK_LIST_COMMAND, () => { + $insertList("check"); + return true; + }, COMMAND_PRIORITY_LOW); + importPlan(editor, source); + editor.update(() => { + $getRoot().getAllTextNodes().find(node => node.getTextContent() === "Caret")?.selectEnd(); + }, { discrete: true }); + return editor; +} + +function run(editor: LexicalEditor, id: string): void { + let command = availableCommands("").find(command => command.id === id); + if (command?.kind !== "insert") throw new Error(`${id} must be an insert command`); + editor.update(() => command.run(editor), { discrete: true }); +} + +/** The exported document, after checking it is valid and survives a round trip. */ +function saved(editor: LexicalEditor): string { + let source = exportPlan(editor); + expect(validate(parse(source))).toEqual({ ok: true }); + let again = createHeadlessEditor({ nodes: registry().nodes }); + importPlan(again, source); + expect(exportPlan(again)).toBe(source); + return source; +} + +function topLevelTypes(editor: LexicalEditor): string[] { + return editor.getEditorState().read(() => $getRoot().getChildren().map(node => node.getType())); +} + +/** The blocks beside the caret's paragraph, inside whatever holds it. */ +function besideCaret(editor: LexicalEditor): string[] { + return editor.getEditorState().read(() => { + let caret = $getRoot().getAllTextNodes().find(node => node.getTextContent() === "Caret"); + let holder = caret?.getParent()?.getParent(); + return $isElementNode(holder) ? holder.getChildren().map(child => child.getType()) : []; + }); +} + +describe("slash blocks from a table cell", () => { + for (let [id, type] of Object.entries(BLOCKS)) { + it(`puts ${id} after the whole table, leaving the cell as text`, () => { + let editor = editorWith(TABLE); + run(editor, id); + let source = saved(editor); + + expect(topLevelTypes(editor)).toEqual(["paragraph", "table", type, "paragraph"]); + expect(source).toContain("| one | Caret |"); + }); + } + + it("keeps an image, which is inline, in the cell", () => { + let prompt = globalThis.window; + globalThis.window = { prompt: () => "https://example.com/a.png", alert() {} } as never; + try { + let editor = editorWith(TABLE); + run(editor, "image"); + expect(saved(editor)).toContain("| one | Caret![](https://example.com/a.png) |"); + } finally { + globalThis.window = prompt; + } + }); + + it("opens a typed-for slash after the table rather than inside its cell", () => { + let editor = editorWith(TABLE); + editor.update($openBlock, { discrete: true }); + expect(topLevelTypes(editor)).toEqual(["paragraph", "table", "paragraph", "paragraph"]); + editor.getEditorState().read(() => { + let selection = $getSelection(); + expect($isRangeSelection(selection)).toBe(true); + if (!$isRangeSelection(selection)) return; + expect(selection.anchor.key).toBe($getRoot().getChildAtIndex(2)!.getKey()); + }); + }); +}); + +describe("slash blocks from other nested places", () => { + for (let id of Object.keys(BLOCKS)) { + for (let [place, source] of Object.entries({ LIST, COLUMNS, CALLOUT, TABS })) { + it(`leaves a valid document for ${id} in ${place.toLowerCase()}`, () => { + let editor = editorWith(source); + run(editor, id); + saved(editor); + }); + } + } + + it("moves a callout out of a callout or column, which forbid one", () => { + for (let source of [CALLOUT, COLUMNS]) { + let editor = editorWith(source); + run(editor, "callout"); + expect(topLevelTypes(editor).at(-1)).toBe("plan-callout"); + expect(saved(editor)).toContain("Caret"); + } + }); + + it("moves tabs out of tabs or a column, which forbid them", () => { + for (let source of [TABS, COLUMNS]) { + let editor = editorWith(source); + run(editor, "tabs"); + expect(topLevelTypes(editor)).toHaveLength(2); + expect(topLevelTypes(editor).at(-1)).toBe("plan-tabs"); + } + }); + + it("keeps a table, tabs or a task list inside a container that allows them", () => { + let cases: [string, string, string[]][] = [ + ["table", CALLOUT, ["paragraph", "table"]], + ["table", COLUMNS, ["paragraph", "table"]], + ["table", TABS, ["paragraph", "table"]], + ["tabs", CALLOUT, ["paragraph", "plan-tabs"]], + ]; + for (let [id, source, inside] of cases) { + let editor = editorWith(source); + run(editor, id); + saved(editor); + expect(topLevelTypes(editor)).toHaveLength(1); + expect(besideCaret(editor)).toEqual(inside); + } + for (let source of [CALLOUT, COLUMNS, TABS]) { + let editor = editorWith(source); + run(editor, "tasks"); + expect(saved(editor)).toContain("- [ ] Caret"); + expect(topLevelTypes(editor)).toHaveLength(1); + } + }); +}); diff --git a/packages/editor/src/toolbar/block-target.ts b/packages/editor/src/toolbar/block-target.ts new file mode 100644 index 000000000..7543cc107 --- /dev/null +++ b/packages/editor/src/toolbar/block-target.ts @@ -0,0 +1,128 @@ +/** + * Where a block chosen at the caret goes. + * + * Not `getTopLevelElement`: a table cell is a shadow root, so it stops inside the + * table, and a cell holds only inline text in the dialect. Callouts, columns and + * tabs are not shadow roots, so `$insertNodes` flattens a block placed in them to + * its inline text, which drops a table or tabs without a trace. + */ + +import { $createListItemNode, $createListNode } from "@lexical/list"; +import { $isTableCellNode } from "@lexical/table"; +import { + $createParagraphNode, + $getSelection, + $insertNodes, + $isElementNode, + $isParagraphNode, + $isRangeSelection, + $isRootNode, + $isRootOrShadowRoot, +} from "lexical"; +import { $isCalloutNode, $isColumnNode, $isTabNode, $isTabsNode, lookup } from "@chopin/dialect"; + +import type { ElementNode, LexicalNode } from "lexical"; + +/** The root's child holding a node, and whether the node sits deeper, in a list item, cell or container. */ +export function $topLevel(node: LexicalNode): { block: LexicalNode; nested: boolean } | undefined { + let block: LexicalNode = node; + for ( + let parent = block.getParent(); + parent && !$isRootNode(parent); + parent = parent.getParent() + ) { + block = parent; + } + if (!$isRootNode(block.getParent())) return undefined; + let host: LexicalNode | null = node; + while (host && !($isElementNode(host) && !host.isInline())) host = host.getParent(); + return { block, nested: host !== block }; +} + +/** A new paragraph after the caret's top-level block, holding the caret. */ +function $openAfterTopLevel(): void { + let selection = $getSelection(); + if (!$isRangeSelection(selection)) return; + let target = $topLevel(selection.anchor.getNode()); + if (!target) return; + let paragraph = $createParagraphNode(); + target.block.insertAfter(paragraph); + paragraph.select(); +} + +/** + * Give a block typed without a `/` its own line: one with words in it gets a new + * paragraph after its top-level block, rather than a slash splitting a word or + * trailing a sentence, or landing inside a table. + */ +export function $openBlock(): void { + let selection = $getSelection(); + if (!$isRangeSelection(selection)) return; + let target = $topLevel(selection.anchor.getNode()); + if (target && target.block.getTextContentSize() > 0) $openAfterTopLevel(); +} + +function componentName(node: LexicalNode): string | undefined { + if ($isCalloutNode(node)) return "Callout"; + if ($isColumnNode(node)) return "Column"; + if ($isTabNode(node)) return "Tab"; + if ($isTabsNode(node)) return "Tabs"; + return undefined; +} + +/** + * Move the caret to a new paragraph after its top-level block when the block about + * to be inserted cannot live where the caret is: anywhere in a table cell, or in a + * container the dialect forbids it in, such as a callout in a callout or a column. + * This is where Research goes from the same places. + */ +export function $leaveForBlock(component?: string): void { + let selection = $getSelection(); + if (!$isRangeSelection(selection)) return; + let node: LexicalNode | null = selection.anchor.getNode(); + for (; node && !$isRootNode(node); node = node.getParent()) { + let forbids = lookup(componentName(node))?.forbids ?? []; + if ($isTableCellNode(node) || (component && forbids.includes(component))) { + $openAfterTopLevel(); + return; + } + } +} + +/** + * The caret's block when it sits in a callout, column or tab, the containers `$insertNodes` + * and `$insertList` treat as inline-only: one flattens a block to its text there, the other + * turns the whole container into the list. + */ +function $containedBlock(): ElementNode | undefined { + let selection = $getSelection(); + if (!$isRangeSelection(selection)) return undefined; + let host: LexicalNode | null = selection.anchor.getNode(); + while (host && !($isElementNode(host) && !host.isInline())) host = host.getParent(); + let parent = host?.getParent(); + if (!$isElementNode(host) || !parent || $isRootOrShadowRoot(parent)) return undefined; + return host.isParentRequired() ? undefined : host; +} + +/** Insert a block node at the caret, in a container too, where `$insertNodes` would drop it. */ +export function $insertBlock(block: LexicalNode): void { + let host = $containedBlock(); + if (!host) { + $insertNodes([block]); + return; + } + if ($isParagraphNode(host) && host.getTextContentSize() === 0) host.replace(block); + else host.insertAfter(block); + block.selectEnd(); +} + +/** Turn the caret's paragraph in a container into a task list. False where `$insertList` works. */ +export function $taskListInContainer(): boolean { + let host = $containedBlock(); + if (!$isParagraphNode(host)) return false; + let item = $createListItemNode(false); + item.append(...host.getChildren()); + host.replace($createListNode("check").append(item)); + item.selectEnd(); + return true; +} diff --git a/packages/editor/src/toolbar/dock-panel.tsx b/packages/editor/src/toolbar/dock-panel.tsx index 24ec676ba..0e5cd2280 100644 --- a/packages/editor/src/toolbar/dock-panel.tsx +++ b/packages/editor/src/toolbar/dock-panel.tsx @@ -8,8 +8,8 @@ import { CheckIcon, ListIcon } from "@chopin/icons"; import { INSERT_CHECK_LIST_COMMAND } from "@lexical/list"; -import { $createParagraphNode, $getSelection, $isRangeSelection } from "lexical"; +import { $openBlock } from "./block-target"; import { convertBlock } from "./bubble"; import { availableCommands } from "./slash"; import { nativeSelectionRect } from "./surface"; @@ -57,18 +57,6 @@ export type PanelChoice = { run: (editor: LexicalEditor) => void; }; -/** A new block goes after one with words in it, as the slash menu's does, never inside them. */ -function $openBlock() { - let selection = $getSelection(); - if (!$isRangeSelection(selection)) return; - let block = selection.anchor.getNode().getTopLevelElement(); - if (block && block.getTextContentSize() > 0) { - let paragraph = $createParagraphNode(); - block.insertAfter(paragraph); - paragraph.select(); - } -} - /** Kinds of list belong with the other text styles, not with the blocks. */ const STYLED = new Set(["tasks"]); diff --git a/packages/editor/src/toolbar/research.tsx b/packages/editor/src/toolbar/research.tsx index ce99ff392..bd9ab8860 100644 --- a/packages/editor/src/toolbar/research.tsx +++ b/packages/editor/src/toolbar/research.tsx @@ -16,7 +16,6 @@ import { $insertNodes, $isElementNode, $isRangeSelection, - $isRootNode, $setSelection, COMMAND_PRIORITY_LOW, createCommand, @@ -33,6 +32,7 @@ import * as Y from "yjs"; import { blockElement } from "../scroll"; import { useTransitionPresence } from "../transition-presence"; import { ResearchComposer } from "../widgets/research"; +import { $topLevel } from "./block-target"; import { $relativePosition } from "./position"; import type { Binding } from "@lexical/yjs"; @@ -193,23 +193,6 @@ export function retainDraftBlock( return resolved ?? previous; } -/** The top-level block holding a node, and whether the node sits deeper, in a list item or cell. */ -function $topLevel(node: LexicalNode): { block: LexicalNode; nested: boolean } | undefined { - // Not `getTopLevelElement`: a table cell is a shadow root, so it stops inside the table. - let block: LexicalNode = node; - for ( - let parent = block.getParent(); - parent && !$isRootNode(parent); - parent = parent.getParent() - ) { - block = parent; - } - if (!$isRootNode(block.getParent())) return undefined; - let host: LexicalNode | null = node; - while (host && !($isElementNode(host) && !host.isInline())) host = host.getParent(); - return { block, nested: host !== block }; -} - /** * Insert a reference at the selection. Like the slash menu, a point in a list * item or table cell puts it after the whole list or table: a cell holds only diff --git a/packages/editor/src/toolbar/slash.tsx b/packages/editor/src/toolbar/slash.tsx index a7034af9e..c4f39c2d7 100644 --- a/packages/editor/src/toolbar/slash.tsx +++ b/packages/editor/src/toolbar/slash.tsx @@ -63,6 +63,7 @@ import { WrenchIcon, } from "@chopin/icons"; +import { $insertBlock, $leaveForBlock, $openBlock, $taskListInContainer } from "./block-target"; import { askForUrl } from "./url"; import { placeSurface } from "./placement"; import { OPEN_INVESTIGATION_COMMAND, OPEN_RESEARCH_COMMAND } from "./research"; @@ -100,6 +101,18 @@ export type SlashCommand = | { kind: "action"; run: (editor: LexicalEditor, action: OpenResearch) => boolean } ); +/** + * Run a block command where its block may live, in the same update so one undo removes both. + * `component` names the dialect component it creates, for the containers that forbid one. + */ +function placed(run: (editor: LexicalEditor) => void, component?: string) { + return (editor: LexicalEditor) => + editor.update(() => { + $leaveForBlock(component); + run(editor); + }); +} + /** Replace the current block, dropping the `/query` the user typed. */ function replace(editor: LexicalEditor, build: () => ElementNode) { editor.update(() => { @@ -167,7 +180,7 @@ const COMMANDS: SlashCommand[] = [ group: "Blocks", keywords: ["note", "warning", "aside", "admonition"], kind: "insert", - run: editor => insertContainer(editor, () => $createCalloutNode(ulid())), + run: placed(editor => insertContainer(editor, () => $createCalloutNode(ulid())), "Callout"), }, { id: "table", @@ -177,7 +190,7 @@ const COMMANDS: SlashCommand[] = [ group: "Blocks", keywords: ["table", "grid", "row", "column", "spreadsheet"], kind: "insert", - run: editor => + run: placed(editor => editor.update(() => { /* * Row headers only. @@ -187,8 +200,9 @@ const COMMANDS: SlashCommand[] = [ * GFM has no way to write one, so the table would claim * something on screen that the saved source does not say. */ - $insertNodes([$createTableNodeWithDimensions(3, 3, { rows: true, columns: false })]); - }), + $insertBlock($createTableNodeWithDimensions(3, 3, { rows: true, columns: false })); + }) + ), }, { id: "columns", @@ -198,7 +212,7 @@ const COMMANDS: SlashCommand[] = [ group: "Blocks", keywords: ["column", "layout", "side by side", "compare"], kind: "insert", - run: editor => + run: placed(editor => editor.update(() => { let selection = $getSelection(); let topLevel = $isRangeSelection(selection) @@ -213,7 +227,8 @@ const COMMANDS: SlashCommand[] = [ if (topLevel && !$isParagraphNode(topLevel)) topLevel.insertAfter(columns); else $insertNodes([columns]); first.getFirstChild()?.selectStart(); - }), + }) + ), }, { id: "tasks", @@ -223,7 +238,9 @@ const COMMANDS: SlashCommand[] = [ group: "Blocks", keywords: ["task", "todo", "checklist", "checkbox"], kind: "insert", - run: editor => editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined), + run: placed(editor => { + if (!$taskListInContainer()) editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined); + }), }, { id: "code", @@ -233,7 +250,7 @@ const COMMANDS: SlashCommand[] = [ group: "Blocks", keywords: ["code", "snippet", "fence"], kind: "insert", - run: editor => replace(editor, () => $createCodeBlockNode("")), + run: placed(editor => replace(editor, () => $createCodeBlockNode(""))), }, { id: "image", @@ -259,7 +276,7 @@ const COMMANDS: SlashCommand[] = [ group: "Blocks", keywords: ["mermaid", "diagram", "flowchart", "graph"], kind: "insert", - run: editor => replace(editor, () => $createCodeBlockNode(MERMAID_LANGUAGE)), + run: placed(editor => replace(editor, () => $createCodeBlockNode(MERMAID_LANGUAGE))), }, { id: "math", @@ -269,7 +286,7 @@ const COMMANDS: SlashCommand[] = [ group: "Blocks", keywords: ["math", "latex", "katex", "equation"], kind: "insert", - run: editor => replace(editor, () => $createMathNode(false)), + run: placed(editor => replace(editor, () => $createMathNode(false))), }, { id: "tabs", @@ -279,7 +296,7 @@ const COMMANDS: SlashCommand[] = [ group: "Blocks", keywords: ["tab", "switch", "panel"], kind: "insert", - run: editor => + run: placed(editor => editor.update(() => { let tabs = $createTabsNode(); tabs.setId(ulid()); @@ -290,8 +307,8 @@ const COMMANDS: SlashCommand[] = [ tab.append($createParagraphNode().append($createTextNode(""))); tabs.append(tab); } - $insertNodes([tabs]); - }), + $insertBlock(tabs); + }), "Tabs"), }, { id: "diff", @@ -301,7 +318,7 @@ const COMMANDS: SlashCommand[] = [ group: "Blocks", keywords: ["diff", "patch", "change"], kind: "insert", - run: editor => replace(editor, () => $createCodeBlockNode(DIFF_LANGUAGE)), + run: placed(editor => replace(editor, () => $createCodeBlockNode(DIFF_LANGUAGE))), }, { id: "research", @@ -522,18 +539,7 @@ export function SlashMenu({ actions = NO_ACTIONS, disabled }: SlashMenuProps) { () => { armed.current = true; editor.update(() => { - let selection = $getSelection(); - if (!$isRangeSelection(selection)) return; - // A block with words in it gets a new paragraph after it, rather than a slash - // splitting a word or trailing a sentence. Deliberately the top-level block: a - // table cell holds only inline text in the dialect, and the inserted components - // cannot live inside a list item, so those go after the whole table or list. - let block = selection.anchor.getNode().getTopLevelElement(); - if (block && block.getTextContentSize() > 0) { - let paragraph = $createParagraphNode(); - block.insertAfter(paragraph); - paragraph.select(); - } + $openBlock(); let next = $getSelection(); if ($isRangeSelection(next)) next.insertText("/"); }); From 23d377bab89ed4f8500d7d77671e60231b730d18 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:58:04 +0100 Subject: [PATCH 2/5] Renew design-contract review for the slash menu placement fix Source hash only: SlashMenu's SHELL, DIVIDER and ROW classes and its position style are unchanged; the edits are in command run functions and the phone slash handler, which carry no styling. Co-Authored-By: Claude Opus 5.5 --- scripts/design-contract/exceptions/dynamic-editor.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/design-contract/exceptions/dynamic-editor.json b/scripts/design-contract/exceptions/dynamic-editor.json index d2dc6aa5e..efc906731 100644 --- a/scripts/design-contract/exceptions/dynamic-editor.json +++ b/scripts/design-contract/exceptions/dynamic-editor.json @@ -102,7 +102,7 @@ 1 ] ], - "sourceHash": "5808e40b60d5785d699d6bd83d3a25e2fffbf657bf434d36eb03e1fd823a9627" + "sourceHash": "326aa36a7e079207307a5376046e3137275f089776b35ad341db80732a4add05" }, { "file": "packages/editor/src/toolbar/bubble.tsx", From 90ecfe1fda0ea5a854753fd213a735bbd7d3cf28 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:00:12 +0100 Subject: [PATCH 3/5] Keep lists and dock inserts inside callouts, columns and tabs Review follow-ups for the slash placement fix: - Columns now go through the same rule. The dialect marks Columns `topLevel`, so the validator and the slash menu read one flag, and a Columns chosen anywhere nested lands after the top-level block. - The phone dock opens its line beside the caret's block, inside its callout, column or tab, and only after the whole table, list or quote. It opens the line and inserts the block in one update, so one undo removes both. - `CONTAINER_COMPONENTS` maps container node types to dialect names; a test checks it covers every component that can hold a caret. - Bulleted, Numbered and Task list conversions from the selection bubble and the dock kept replacing a whole callout, column or tab, because `$insertList` walks up to a root or shadow root. They now build the list in place, joining an adjacent list of the same kind. An imported quote becomes one item per line instead of an item holding a paragraph. Co-Authored-By: Claude Opus 5.5 --- ...KJTB3M7BFPT-list-conversions-go-through.md | 8 + e2e/toolbar.e2e.ts | 16 ++ packages/dialect/src/dialect.ts | 3 + packages/dialect/src/validate.ts | 4 +- .../editor/src/toolbar/block-target.test.ts | 119 +++++++++++- packages/editor/src/toolbar/block-target.ts | 176 +++++++++++++----- packages/editor/src/toolbar/bubble.tsx | 17 +- packages/editor/src/toolbar/dock-panel.tsx | 13 +- packages/editor/src/toolbar/slash.tsx | 10 +- 9 files changed, 289 insertions(+), 77 deletions(-) create mode 100644 .lane/memory/packages/editor/src/toolbar/bubble.tsx/01M4T7V7D6JKGFPKJTB3M7BFPT-list-conversions-go-through.md diff --git a/.lane/memory/packages/editor/src/toolbar/bubble.tsx/01M4T7V7D6JKGFPKJTB3M7BFPT-list-conversions-go-through.md b/.lane/memory/packages/editor/src/toolbar/bubble.tsx/01M4T7V7D6JKGFPKJTB3M7BFPT-list-conversions-go-through.md new file mode 100644 index 000000000..cfd9887a1 --- /dev/null +++ b/.lane/memory/packages/editor/src/toolbar/bubble.tsx/01M4T7V7D6JKGFPKJTB3M7BFPT-list-conversions-go-through.md @@ -0,0 +1,8 @@ +--- +id: 01M4T7V7D6JKGFPKJTB3M7BFPT +anchor: function convertBlock +created: 2026-10-13T09:00:09Z +norm: '1' +--- + +List conversions go through insertList (block-target.ts): $insertList walks to a root or shadow root, so inside a callout, column or tab it turned the whole container into the list. diff --git a/e2e/toolbar.e2e.ts b/e2e/toolbar.e2e.ts index d5e1f37be..c62c923a2 100644 --- a/e2e/toolbar.e2e.ts +++ b/e2e/toolbar.e2e.ts @@ -281,6 +281,22 @@ test("a block chosen in a table cell goes after the table", async ({ join, room, await expect(page.getByText("Edits not saved")).toHaveCount(0); }); +test("a list made from the block type menu stays inside its callout", async ({ join, room, seed }) => { + await seed( + '\n\nKeep me inside\n\n\n', + ); + let page = await join("ana"); + await edit(page); + + await content(page).getByText("Keep me inside").dblclick(); + let bubble = page.getByRole("toolbar", BUBBLE); + await bubble.getByRole("button", { name: "Block type: Text" }).click(); + await bubble.getByRole("button", { name: "Bulleted list" }).click(); + + await written(page, room, /]*>\n {2}- Keep me inside\n<\/Callout>/); + await expect(page.getByText("Edits not saved")).toHaveCount(0); +}); + test("a selection raises exactly one toolbar", async ({ join }) => { let page = await join("ana"); diff --git a/packages/dialect/src/dialect.ts b/packages/dialect/src/dialect.ts index f6f0719be..4dca1e6c7 100644 --- a/packages/dialect/src/dialect.ts +++ b/packages/dialect/src/dialect.ts @@ -44,6 +44,8 @@ export type Component = { forbids?: readonly string[]; /** Only valid as a direct child of these components. */ parent?: readonly string[]; + /** Only valid as a direct child of the document root. */ + topLevel?: true; }; const ID: Attribute = { type: "id", required: true }; @@ -232,6 +234,7 @@ export const COMPONENTS: Readonly> = Object.freeze({ kind: "flow", content: { type: "components", names: ["Column"] }, forbids: ["Columns"], + topLevel: true, }), Column: component({ diff --git a/packages/dialect/src/validate.ts b/packages/dialect/src/validate.ts index 2fce30273..8c4c59299 100644 --- a/packages/dialect/src/validate.ts +++ b/packages/dialect/src/validate.ts @@ -161,8 +161,8 @@ class Validator { this.add("unknown-component", `\`${node.name}\` is not a plan component`, path, node); return; } - if (spec.name === "Columns" && depth !== 1) { - this.add("bad-layout-placement", "`Columns` must be a top-level block", path, node); + if (spec.topLevel && depth !== 1) { + this.add("bad-layout-placement", `\`${spec.name}\` must be a top-level block`, path, node); } let expected = node.type === "mdxJsxFlowElement" ? "flow" : "text"; diff --git a/packages/editor/src/toolbar/block-target.test.ts b/packages/editor/src/toolbar/block-target.test.ts index ea1308dd2..df58aa825 100644 --- a/packages/editor/src/toolbar/block-target.test.ts +++ b/packages/editor/src/toolbar/block-target.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { createHeadlessEditor } from "@lexical/headless"; import { $insertList, INSERT_CHECK_LIST_COMMAND, registerList } from "@lexical/list"; +import { registerRichText } from "@lexical/rich-text"; import { $getRoot, $getSelection, @@ -9,9 +10,11 @@ import { COMMAND_PRIORITY_LOW, } from "lexical"; -import { exportPlan, importPlan, parse, registry, validate } from "@chopin/dialect"; +import { COMPONENTS, exportPlan, importPlan, parse, registry, validate } from "@chopin/dialect"; -import { $openBlock } from "./block-target"; +import { $openBlock, CONTAINER_COMPONENTS } from "./block-target"; +import { $block, convertBlock } from "./bubble"; +import { insertChoices, styleChoices } from "./dock-panel"; import { availableCommands } from "./slash"; import type { LexicalEditor } from "lexical"; @@ -52,6 +55,7 @@ function editorWith(source: string): LexicalEditor { }, }); registerList(editor); + registerRichText(editor); // The browser registers this with the check-list plugin, which needs a DOM root. editor.registerCommand(INSERT_CHECK_LIST_COMMAND, () => { $insertList("check"); @@ -181,3 +185,114 @@ describe("slash blocks from other nested places", () => { } }); }); + +describe("columns from a nested place", () => { + it("go after the top-level block, since the dialect allows them only there", () => { + for (let source of [CALLOUT, TABS, LIST, COLUMNS]) { + let editor = editorWith(source); + run(editor, "columns"); + saved(editor); + expect(topLevelTypes(editor).at(-1)).toBe("plan-columns"); + } + }); +}); + +describe("placement containers", () => { + it("maps every dialect component that can hold a caret to its Lexical node", () => { + let holdsBlocks = (name: string): boolean => { + let content = COMPONENTS[name]!.content; + return content.type === "blocks" + || (content.type === "components" && content.names.some(holdsBlocks)); + }; + let expected = Object.keys(COMPONENTS).filter(holdsBlocks).sort(); + expect(Object.values(CONTAINER_COMPONENTS).sort()).toEqual(expected); + + let registered = createHeadlessEditor({ nodes: registry().nodes })._nodes; + for (let type of Object.keys(CONTAINER_COMPONENTS)) expect(registered.has(type)).toBe(true); + }); +}); + +describe("the phone dock's Insert panel", () => { + /** Choose as the dock does, outside any update, and count the updates it commits. */ + function choose(editor: LexicalEditor, id: string): number { + let commits = 0; + let stop = editor.registerUpdateListener(({ dirtyElements, dirtyLeaves }) => { + if (dirtyElements.size > 0 || dirtyLeaves.size > 0) commits++; + }); + insertChoices(new Set()).find(choice => choice.id === id)!.run(editor); + editor.update(() => {}, { discrete: true }); + stop(); + return commits; + } + + it("keeps a code block in the callout it was chosen from", () => { + let editor = editorWith(CALLOUT); + choose(editor, "code"); + saved(editor); + expect(topLevelTypes(editor)).toEqual(["plan-callout"]); + expect(besideCaret(editor)).toEqual(["paragraph", "plan-code"]); + }); + + it("puts a block chosen in a table cell after the table", () => { + let editor = editorWith(TABLE); + choose(editor, "code"); + expect(saved(editor)).toContain("| one | Caret |"); + expect(topLevelTypes(editor)).toEqual(["paragraph", "table", "plan-code", "paragraph"]); + }); + + it("opens the line and inserts the block in one update, so one undo removes both", () => { + for (let source of [CALLOUT, TABLE]) { + expect(choose(editorWith(source), "code")).toBe(1); + } + }); +}); + +describe("list conversions inside a container", () => { + const STARTS: Record = { paragraph: "Caret", h2: "## Caret", quote: "> Caret" }; + const PLACES: Record string, string]> = { + callout: [text => CALLOUT.replace("Caret", text), " COLUMNS.replace("Caret", text), " TABS.replace("Caret", text), " { + for (let to of ["bullet", "number", "task"] as const) { + let editor = editorWith(wrap(text)); + if (to === "task") { + styleChoices(from as never).find(style => style.id === to)!.run(editor); + } else convertBlock(editor, from as never, to); + editor.update(() => {}, { discrete: true }); + + let source = saved(editor); + expect(source).toContain(tag); + expect(topLevelTypes(editor)).toHaveLength(1); + editor.getEditorState().read(() => { + let caret = $getRoot().getAllTextNodes().find(node => + node.getTextContent() === "Caret" + ); + expect(caret ? $block(caret) : "gone").toBe(to); + }); + } + }); + } + } + + it("joins a list made beside one of the same kind", () => { + let editor = editorWith(CALLOUT.replace("Caret", "- before\n\nCaret")); + convertBlock(editor, "paragraph", "bullet"); + editor.update(() => {}, { discrete: true }); + expect(saved(editor)).toContain(" - before\n - Caret\n"); + }); + + it("makes each line of an imported quote its own item", () => { + let editor = editorWith("> Caret\n"); + convertBlock(editor, "quote", "bullet"); + editor.update(() => {}, { discrete: true }); + expect(saved(editor)).toBe("- Caret\n"); + editor.getEditorState().read(() => { + expect($block($getRoot().getAllTextNodes()[0]!)).toBe("bullet"); + }); + }); +}); diff --git a/packages/editor/src/toolbar/block-target.ts b/packages/editor/src/toolbar/block-target.ts index 7543cc107..fb8f770d6 100644 --- a/packages/editor/src/toolbar/block-target.ts +++ b/packages/editor/src/toolbar/block-target.ts @@ -3,11 +3,18 @@ * * Not `getTopLevelElement`: a table cell is a shadow root, so it stops inside the * table, and a cell holds only inline text in the dialect. Callouts, columns and - * tabs are not shadow roots, so `$insertNodes` flattens a block placed in them to - * its inline text, which drops a table or tabs without a trace. + * tabs are not shadow roots, so Lexical's own insertions treat them as inline-only. */ -import { $createListItemNode, $createListNode } from "@lexical/list"; +import { + $createListItemNode, + $createListNode, + $isListNode, + INSERT_CHECK_LIST_COMMAND, + INSERT_ORDERED_LIST_COMMAND, + INSERT_UNORDERED_LIST_COMMAND, +} from "@lexical/list"; +import { $isQuoteNode } from "@lexical/rich-text"; import { $isTableCellNode } from "@lexical/table"; import { $createParagraphNode, @@ -17,11 +24,11 @@ import { $isParagraphNode, $isRangeSelection, $isRootNode, - $isRootOrShadowRoot, } from "lexical"; -import { $isCalloutNode, $isColumnNode, $isTabNode, $isTabsNode, lookup } from "@chopin/dialect"; +import { CalloutNode, ColumnNode, ColumnsNode, lookup, TabNode, TabsNode } from "@chopin/dialect"; -import type { ElementNode, LexicalNode } from "lexical"; +import type { ListType } from "@lexical/list"; +import type { ElementNode, LexicalEditor, LexicalNode } from "lexical"; /** The root's child holding a node, and whether the node sits deeper, in a list item, cell or container. */ export function $topLevel(node: LexicalNode): { block: LexicalNode; nested: boolean } | undefined { @@ -39,6 +46,30 @@ export function $topLevel(node: LexicalNode): { block: LexicalNode; nested: bool return { block, nested: host !== block }; } +/** + * The dialect component each container node writes out as. A container is not a Lexical + * shadow root, so it is walked like any other ancestor. + */ +export const CONTAINER_COMPONENTS: Readonly> = { + [CalloutNode.getType()]: "Callout", + [ColumnsNode.getType()]: "Columns", + [ColumnNode.getType()]: "Column", + [TabsNode.getType()]: "Tabs", + [TabNode.getType()]: "Tab", +}; + +/** The nearest block element holding a node. */ +function $host(node: LexicalNode | null): ElementNode | undefined { + while (node && !($isElementNode(node) && !node.isInline())) node = node.getParent(); + return node ?? undefined; +} + +/** Whether a block sits directly in a callout, column or tab, rather than the root. */ +function $inContainer(block: LexicalNode): boolean { + let parent = block.getParent(); + return !!parent && Object.hasOwn(CONTAINER_COMPONENTS, parent.getType()); +} + /** A new paragraph after the caret's top-level block, holding the caret. */ function $openAfterTopLevel(): void { let selection = $getSelection(); @@ -51,37 +82,41 @@ function $openAfterTopLevel(): void { } /** - * Give a block typed without a `/` its own line: one with words in it gets a new - * paragraph after its top-level block, rather than a slash splitting a word or - * trailing a sentence, or landing inside a table. + * Give a block chosen without typing a `/` its own line. A block with words in it gets a + * new paragraph after it, rather than a slash splitting a word or trailing a sentence: in + * the same callout, column or tab, but after the whole table, list or quote, which hold + * only inline text. The chosen command then moves on only if its block is not allowed there. */ export function $openBlock(): void { let selection = $getSelection(); if (!$isRangeSelection(selection)) return; - let target = $topLevel(selection.anchor.getNode()); - if (target && target.block.getTextContentSize() > 0) $openAfterTopLevel(); -} - -function componentName(node: LexicalNode): string | undefined { - if ($isCalloutNode(node)) return "Callout"; - if ($isColumnNode(node)) return "Column"; - if ($isTabNode(node)) return "Tab"; - if ($isTabsNode(node)) return "Tabs"; - return undefined; + let host = $host(selection.anchor.getNode()); + if (!host || host.getTextContentSize() === 0) return; + if (!$isRootNode(host.getParent()) && !$inContainer(host)) { + $openAfterTopLevel(); + return; + } + let paragraph = $createParagraphNode(); + host.insertAfter(paragraph); + paragraph.select(); } /** * Move the caret to a new paragraph after its top-level block when the block about - * to be inserted cannot live where the caret is: anywhere in a table cell, or in a - * container the dialect forbids it in, such as a callout in a callout or a column. - * This is where Research goes from the same places. + * to be inserted cannot live where the caret is: anywhere in a table cell, anywhere + * nested for a top-level-only component, or in a container the dialect forbids it + * in, such as a callout in a callout or a column. Research goes to the same place. */ export function $leaveForBlock(component?: string): void { let selection = $getSelection(); if (!$isRangeSelection(selection)) return; - let node: LexicalNode | null = selection.anchor.getNode(); - for (; node && !$isRootNode(node); node = node.getParent()) { - let forbids = lookup(componentName(node))?.forbids ?? []; + let anchor = selection.anchor.getNode(); + if (lookup(component)?.topLevel && $topLevel(anchor)?.nested) { + $openAfterTopLevel(); + return; + } + for (let node: LexicalNode | null = anchor; node && !$isRootNode(node); node = node.getParent()) { + let forbids = lookup(CONTAINER_COMPONENTS[node.getType()])?.forbids ?? []; if ($isTableCellNode(node) || (component && forbids.includes(component))) { $openAfterTopLevel(); return; @@ -90,24 +125,14 @@ export function $leaveForBlock(component?: string): void { } /** - * The caret's block when it sits in a callout, column or tab, the containers `$insertNodes` - * and `$insertList` treat as inline-only: one flattens a block to its text there, the other - * turns the whole container into the list. + * Insert a block node at the caret. In a callout, column or tab it goes beside the caret's + * block, because `$insertNodes` treats those containers as inline-only and would flatten it + * to its text, dropping a table or tabs without a trace. */ -function $containedBlock(): ElementNode | undefined { - let selection = $getSelection(); - if (!$isRangeSelection(selection)) return undefined; - let host: LexicalNode | null = selection.anchor.getNode(); - while (host && !($isElementNode(host) && !host.isInline())) host = host.getParent(); - let parent = host?.getParent(); - if (!$isElementNode(host) || !parent || $isRootOrShadowRoot(parent)) return undefined; - return host.isParentRequired() ? undefined : host; -} - -/** Insert a block node at the caret, in a container too, where `$insertNodes` would drop it. */ export function $insertBlock(block: LexicalNode): void { - let host = $containedBlock(); - if (!host) { + let selection = $getSelection(); + let host = $isRangeSelection(selection) ? $host(selection.anchor.getNode()) : undefined; + if (!host || !$inContainer(host)) { $insertNodes([block]); return; } @@ -116,13 +141,66 @@ export function $insertBlock(block: LexicalNode): void { block.selectEnd(); } -/** Turn the caret's paragraph in a container into a task list. False where `$insertList` works. */ -export function $taskListInContainer(): boolean { - let host = $containedBlock(); - if (!$isParagraphNode(host)) return false; - let item = $createListItemNode(false); - item.append(...host.getChildren()); - host.replace($createListNode("check").append(item)); - item.selectEnd(); +/** The block a list conversion replaces: a quote rather than the paragraph inside it. */ +function $convertible(node: LexicalNode): ElementNode | undefined { + let host = $host(node); + let parent = host?.getParent(); + return $isParagraphNode(host) && $isQuoteNode(parent) ? parent : host; +} + +/** + * Turn the selected blocks in a callout, column or tab into a list there. `$insertList` + * walks up to a root or shadow root, so it would turn the whole container into the list, + * and it moves an imported quote's paragraphs into one item whole, leaving a block in it. + * False where `$insertList` already does the right thing. + */ +function $listInPlace(type: ListType): boolean { + let selection = $getSelection(); + if (!$isRangeSelection(selection)) return false; + let blocks: ElementNode[] = []; + for (let node of selection.getNodes()) { + let block = $convertible(node); + let wrapped = $isQuoteNode(block) && block.getChildren().some($isElementNode); + if (!block || !($inContainer(block) || wrapped)) return false; + if (!blocks.includes(block)) blocks.push(block); + } + let first = blocks[0]; + if (!first || blocks.some(block => !block.getParent()?.is(first.getParent()))) return false; + + let previous = first.getPreviousSibling(); + let list = $isListNode(previous) && previous.getListType() === type + ? previous + : $createListNode(type); + if (list !== previous) first.insertBefore(list); + let item: ElementNode | undefined; + for (let block of blocks) { + // A quote holds paragraphs; each becomes its own item. + let lines = $isQuoteNode(block) ? block.getChildren().filter($isElementNode) : [block]; + for (let line of lines) { + item = $createListItemNode(type === "check" ? false : undefined); + item.append(...line.getChildren()); + list.append(item); + } + block.remove(); + } + let next = list.getNextSibling(); + if ($isListNode(next) && next.getListType() === type) { + list.append(...next.getChildren()); + next.remove(); + } + if (!selection.anchor.getNode().isAttached()) item?.selectEnd(); return true; } + +const LIST_COMMANDS = { + bullet: INSERT_UNORDERED_LIST_COMMAND, + number: INSERT_ORDERED_LIST_COMMAND, + check: INSERT_CHECK_LIST_COMMAND, +} as const; + +/** Convert the selected blocks to a list, keeping them inside their container. */ +export function insertList(editor: LexicalEditor, type: ListType): void { + editor.update(() => { + if (!$listInPlace(type)) editor.dispatchCommand(LIST_COMMANDS[type], undefined); + }); +} diff --git a/packages/editor/src/toolbar/bubble.tsx b/packages/editor/src/toolbar/bubble.tsx index 9aee672ba..582627a8b 100644 --- a/packages/editor/src/toolbar/bubble.tsx +++ b/packages/editor/src/toolbar/bubble.tsx @@ -11,6 +11,7 @@ import type { ReactNode } from "react"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { ChevronIcon, CodeIcon, LinkPlusIcon, ListIcon, MessagePlusIcon } from "@chopin/icons"; +import { insertList } from "./block-target"; import { $linkAt, OPEN_LINK_EDITOR_COMMAND } from "./link"; import { placeSurface, visibleAnchor } from "./placement"; import { shortcut } from "./shortcut"; @@ -27,13 +28,7 @@ import { SEAM, SHELL, } from "./surface"; -import { - $isListItemNode, - $isListNode, - INSERT_ORDERED_LIST_COMMAND, - INSERT_UNORDERED_LIST_COMMAND, - REMOVE_LIST_COMMAND, -} from "@lexical/list"; +import { $isListItemNode, $isListNode, REMOVE_LIST_COMMAND } from "@lexical/list"; import { $createHeadingNode, $createQuoteNode, @@ -153,12 +148,8 @@ export function convertBlock(editor: LexicalEditor, block: Block, next: Block): // Already a quote: converting again would nest one inside the other. if (next === block || next === "task") return; - if (next === "bullet") { - editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); - return; - } - if (next === "number") { - editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); + if (next === "bullet" || next === "number") { + insertList(editor, next); return; } // Containers are unwrapped, not swapped: `$setBlocksType` converts the diff --git a/packages/editor/src/toolbar/dock-panel.tsx b/packages/editor/src/toolbar/dock-panel.tsx index 0e5cd2280..7b0cdaa28 100644 --- a/packages/editor/src/toolbar/dock-panel.tsx +++ b/packages/editor/src/toolbar/dock-panel.tsx @@ -7,9 +7,8 @@ */ import { CheckIcon, ListIcon } from "@chopin/icons"; -import { INSERT_CHECK_LIST_COMMAND } from "@lexical/list"; -import { $openBlock } from "./block-target"; +import { $openBlock, insertList } from "./block-target"; import { convertBlock } from "./bubble"; import { availableCommands } from "./slash"; import { nativeSelectionRect } from "./surface"; @@ -70,8 +69,14 @@ export function insertChoices(actions: ReadonlySet): PanelChoice[] { label: command.label, glyph: , run: editor => { + // One update, so one undo removes the opened line with the block. + if (command.kind === "insert") { + return editor.update(() => { + $openBlock(); + command.run(editor); + }); + } editor.update($openBlock, { discrete: true }); - if (command.kind === "insert") return command.run(editor); // Nothing was typed to start it, so there is no trigger to consume. let anchor = nativeSelectionRect(); if (anchor) command.run(editor, { anchor, consume: () => true }); @@ -98,7 +103,7 @@ export function styleChoices(current: Block): PanelChoice[] { run: editor => { if (style.id === current) return; if (style.id === "task") { - editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined); + insertList(editor, "check"); return; } convertBlock(editor, current, style.id); diff --git a/packages/editor/src/toolbar/slash.tsx b/packages/editor/src/toolbar/slash.tsx index c4f39c2d7..b888399d2 100644 --- a/packages/editor/src/toolbar/slash.tsx +++ b/packages/editor/src/toolbar/slash.tsx @@ -44,7 +44,6 @@ import { ulid, } from "@chopin/dialect"; -import { INSERT_CHECK_LIST_COMMAND } from "@lexical/list"; import { $createTableNodeWithDimensions } from "@lexical/table"; import { $createParagraphNode, $createTextNode, $insertNodes } from "lexical"; @@ -63,7 +62,7 @@ import { WrenchIcon, } from "@chopin/icons"; -import { $insertBlock, $leaveForBlock, $openBlock, $taskListInContainer } from "./block-target"; +import { $insertBlock, $leaveForBlock, $openBlock, insertList } from "./block-target"; import { askForUrl } from "./url"; import { placeSurface } from "./placement"; import { OPEN_INVESTIGATION_COMMAND, OPEN_RESEARCH_COMMAND } from "./research"; @@ -227,8 +226,7 @@ const COMMANDS: SlashCommand[] = [ if (topLevel && !$isParagraphNode(topLevel)) topLevel.insertAfter(columns); else $insertNodes([columns]); first.getFirstChild()?.selectStart(); - }) - ), + }), "Columns"), }, { id: "tasks", @@ -238,9 +236,7 @@ const COMMANDS: SlashCommand[] = [ group: "Blocks", keywords: ["task", "todo", "checklist", "checkbox"], kind: "insert", - run: placed(editor => { - if (!$taskListInContainer()) editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined); - }), + run: placed(editor => insertList(editor, "check")), }, { id: "code", From 0d98d6a40152642a83a416180894caad21358182 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:01:30 +0100 Subject: [PATCH 4/5] Renew design-contract review for the list placement follow-ups Source hashes only: in bubble.tsx and slash.tsx the edits are in convertBlock and command run functions; the toolbar and menu classes and position styles are unchanged. Co-Authored-By: Claude Opus 5.5 --- scripts/design-contract/exceptions/dynamic-editor.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/design-contract/exceptions/dynamic-editor.json b/scripts/design-contract/exceptions/dynamic-editor.json index efc906731..a2253cb4e 100644 --- a/scripts/design-contract/exceptions/dynamic-editor.json +++ b/scripts/design-contract/exceptions/dynamic-editor.json @@ -102,7 +102,7 @@ 1 ] ], - "sourceHash": "326aa36a7e079207307a5376046e3137275f089776b35ad341db80732a4add05" + "sourceHash": "ee755d675aab961554c2afbbc08de932bd1ba2ece56fbe81c015aee10d5a0011" }, { "file": "packages/editor/src/toolbar/bubble.tsx", @@ -172,7 +172,7 @@ 4 ] ], - "sourceHash": "e661834fae291b0f83fcfd6ddfa49012435a546b0d298fbed09d1d6ab3e6738d" + "sourceHash": "289fc341575dd4b9600351d5fba3c79b6fe31782d22c1d6671306849be785397" }, { "file": "packages/editor/src/widgets/research-motion.tsx", From 9557f8714c2e9b69562d9919ef2cecc9e8b6c110 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:26:05 +0100 Subject: [PATCH 5/5] Keep the container insertion constraint on its current helper --- ...48KDXKXYB5B39E4DVKAS9-callout-column-and-tab-are-n.md | 0 ...99RT0F5811PPFSYD3JAMA-callout-column-and-tab-are-n.md | 9 +++++++++ 2 files changed, 9 insertions(+) rename .lane/{memory => attic}/packages/editor/src/toolbar/block-target.ts/01M4T48KDXKXYB5B39E4DVKAS9-callout-column-and-tab-are-n.md (100%) create mode 100644 .lane/memory/packages/editor/src/toolbar/block-target.ts/01M4T99RT0F5811PPFSYD3JAMA-callout-column-and-tab-are-n.md diff --git a/.lane/memory/packages/editor/src/toolbar/block-target.ts/01M4T48KDXKXYB5B39E4DVKAS9-callout-column-and-tab-are-n.md b/.lane/attic/packages/editor/src/toolbar/block-target.ts/01M4T48KDXKXYB5B39E4DVKAS9-callout-column-and-tab-are-n.md similarity index 100% rename from .lane/memory/packages/editor/src/toolbar/block-target.ts/01M4T48KDXKXYB5B39E4DVKAS9-callout-column-and-tab-are-n.md rename to .lane/attic/packages/editor/src/toolbar/block-target.ts/01M4T48KDXKXYB5B39E4DVKAS9-callout-column-and-tab-are-n.md diff --git a/.lane/memory/packages/editor/src/toolbar/block-target.ts/01M4T99RT0F5811PPFSYD3JAMA-callout-column-and-tab-are-n.md b/.lane/memory/packages/editor/src/toolbar/block-target.ts/01M4T99RT0F5811PPFSYD3JAMA-callout-column-and-tab-are-n.md new file mode 100644 index 000000000..d0d97fdb1 --- /dev/null +++ b/.lane/memory/packages/editor/src/toolbar/block-target.ts/01M4T99RT0F5811PPFSYD3JAMA-callout-column-and-tab-are-n.md @@ -0,0 +1,9 @@ +--- +id: 01M4T99RT0F5811PPFSYD3JAMA +anchor: function $inContainer +created: 2026-10-13T09:25:35Z +norm: '1' +supersedes: 01M4T48KDXKXYB5B39E4DVKAS9 +--- + +Callout, Column and Tab are not Lexical shadow roots, so in Lexical 0.48 $insertNodes flattens a block inserted in them to inline text (silently dropping a table or tabs) and $insertList turns the whole container into the list. Insert relative to the host block instead.