diff --git a/.lane/attic/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
new file mode 100644
index 000000000..d3991adac
--- /dev/null
+++ b/.lane/attic/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/.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.
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 db571f1a2..c62c923a2 100644
--- a/e2e/toolbar.e2e.ts
+++ b/e2e/toolbar.e2e.ts
@@ -265,6 +265,38 @@ 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 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
new file mode 100644
index 000000000..df58aa825
--- /dev/null
+++ b/packages/editor/src/toolbar/block-target.test.ts
@@ -0,0 +1,298 @@
+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,
+ $isElementNode,
+ $isRangeSelection,
+ COMMAND_PRIORITY_LOW,
+} from "lexical";
+
+import { COMPONENTS, exportPlan, importPlan, parse, registry, validate } from "@chopin/dialect";
+
+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";
+
+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);
+ registerRichText(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 |");
+ } 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);
+ }
+ });
+});
+
+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
new file mode 100644
index 000000000..fb8f770d6
--- /dev/null
+++ b/packages/editor/src/toolbar/block-target.ts
@@ -0,0 +1,206 @@
+/**
+ * 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 Lexical's own insertions treat them as inline-only.
+ */
+
+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,
+ $getSelection,
+ $insertNodes,
+ $isElementNode,
+ $isParagraphNode,
+ $isRangeSelection,
+ $isRootNode,
+} from "lexical";
+import { CalloutNode, ColumnNode, ColumnsNode, lookup, TabNode, TabsNode } from "@chopin/dialect";
+
+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 {
+ 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 };
+}
+
+/**
+ * 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();
+ 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 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 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, 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 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;
+ }
+ }
+}
+
+/**
+ * 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.
+ */
+export function $insertBlock(block: LexicalNode): void {
+ let selection = $getSelection();
+ let host = $isRangeSelection(selection) ? $host(selection.anchor.getNode()) : undefined;
+ if (!host || !$inContainer(host)) {
+ $insertNodes([block]);
+ return;
+ }
+ if ($isParagraphNode(host) && host.getTextContentSize() === 0) host.replace(block);
+ else host.insertAfter(block);
+ block.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 24ec676ba..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 { $createParagraphNode, $getSelection, $isRangeSelection } from "lexical";
+import { $openBlock, insertList } from "./block-target";
import { convertBlock } from "./bubble";
import { availableCommands } from "./slash";
import { nativeSelectionRect } from "./surface";
@@ -57,18 +56,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"]);
@@ -82,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 });
@@ -110,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/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..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,6 +62,7 @@ import {
WrenchIcon,
} from "@chopin/icons";
+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";
@@ -100,6 +100,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 +179,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 +189,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 +199,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 +211,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 +226,7 @@ const COMMANDS: SlashCommand[] = [
if (topLevel && !$isParagraphNode(topLevel)) topLevel.insertAfter(columns);
else $insertNodes([columns]);
first.getFirstChild()?.selectStart();
- }),
+ }), "Columns"),
},
{
id: "tasks",
@@ -223,7 +236,7 @@ const COMMANDS: SlashCommand[] = [
group: "Blocks",
keywords: ["task", "todo", "checklist", "checkbox"],
kind: "insert",
- run: editor => editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined),
+ run: placed(editor => insertList(editor, "check")),
},
{
id: "code",
@@ -233,7 +246,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 +272,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 +282,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 +292,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 +303,8 @@ const COMMANDS: SlashCommand[] = [
tab.append($createParagraphNode().append($createTextNode("")));
tabs.append(tab);
}
- $insertNodes([tabs]);
- }),
+ $insertBlock(tabs);
+ }), "Tabs"),
},
{
id: "diff",
@@ -301,7 +314,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 +535,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("/");
});
diff --git a/scripts/design-contract/exceptions/dynamic-editor.json b/scripts/design-contract/exceptions/dynamic-editor.json
index d2dc6aa5e..a2253cb4e 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": "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",