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![](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); + } + }); +}); + +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",