Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 32 additions & 0 deletions e2e/toolbar.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<Callout id="01K0N4W3B7P27CBAEC7A8C8WEA" type="note">\n\nKeep me inside\n\n</Callout>\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, /<Callout [^>]*>\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");

Expand Down
3 changes: 3 additions & 0 deletions packages/dialect/src/dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -232,6 +234,7 @@ export const COMPONENTS: Readonly<Record<string, Component>> = Object.freeze({
kind: "flow",
content: { type: "components", names: ["Column"] },
forbids: ["Columns"],
topLevel: true,
}),

Column: component({
Expand Down
4 changes: 2 additions & 2 deletions packages/dialect/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading