From bb355585cb09c98c638ff3503ca139430d097b6c Mon Sep 17 00:00:00 2001 From: Louis Escher Date: Wed, 22 Jul 2026 12:11:05 +0200 Subject: [PATCH 1/5] feat: Nesting block prototype --- .../src/components/PortableTextEditor.tsx | 100 ++++++ .../components/editor/NestingBlockNode.tsx | 337 ++++++++++++++++++ .../core/src/components/NestingBlock.astro | 111 ++++++ .../core/src/components/PortableText.astro | 6 +- packages/core/src/components/index.ts | 3 + .../src/components/portable-text-nesting.ts | 49 +++ .../core/src/content/converters/nesting.ts | 54 +++ .../portable-text-to-prosemirror.ts | 67 +++- .../prosemirror-to-portable-text.ts | 39 +- packages/core/src/content/converters/types.ts | 26 ++ .../components/nesting-block-remap.test.ts | 56 +++ .../nesting-block-round-trip.test.ts | 125 +++++++ 12 files changed, 963 insertions(+), 10 deletions(-) create mode 100644 packages/admin/src/components/editor/NestingBlockNode.tsx create mode 100644 packages/core/src/components/NestingBlock.astro create mode 100644 packages/core/src/components/portable-text-nesting.ts create mode 100644 packages/core/src/content/converters/nesting.ts create mode 100644 packages/core/tests/unit/components/nesting-block-remap.test.ts create mode 100644 packages/core/tests/unit/converters/nesting-block-round-trip.test.ts diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index 671765810b..77d409a4ca 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -85,6 +85,7 @@ import { DotsSixVertical, CaretDown, type Icon, + ColumnsIcon, } from "@phosphor-icons/react"; import { X } from "@phosphor-icons/react"; import { Extension, type Range } from "@tiptap/core"; @@ -119,6 +120,7 @@ import { HeadingDropdownMenu } from "./editor/HeadingDropdownMenu"; import { HtmlBlockExtension } from "./editor/HtmlBlockNode"; import { ImageExtension } from "./editor/ImageNode"; import { MarkdownLinkExtension } from "./editor/MarkdownLinkExtension"; +import { NestingBlockExtension, NestingColumnExtension } from "./editor/NestingBlockNode"; import { type PluginBlockDef, PluginBlockExtension, @@ -244,6 +246,18 @@ function sanitizeGalleryImages(value: unknown, withKeys = false): GalleryImage[] const attrStr = (v: unknown): string | undefined => (typeof v === "string" && v ? v : undefined); const attrNum = (v: unknown): number | undefined => (typeof v === "number" && v ? v : undefined); +// Nesting block layout coercion +const NESTING_GAPS = ["none", "sm", "md", "lg"] as const; +const NESTING_ALIGNS = ["start", "center", "end", "stretch"] as const; + +function pickNestingGap(v: unknown): (typeof NESTING_GAPS)[number] { + return NESTING_GAPS.find((g) => g === v) ?? "md"; +} + +function pickNestingAlign(v: unknown): (typeof NESTING_ALIGNS)[number] { + return NESTING_ALIGNS.find((a) => a === v) ?? "start"; +} + // ProseMirror to Portable Text converter function prosemirrorToPortableText(doc: { type: string; @@ -372,6 +386,39 @@ function convertPMNode(node: { }; } + case "nestingBlock": { + const attrs = node.attrs ?? {}; + const columnNodes = (node.content || []) as Array[0]>; + const columns: PortableTextBlock[] = []; + + for (const col of columnNodes) { + if (col.type !== "nestingColumn") continue; + + const colChildren: PortableTextBlock[] = []; + + for (const child of (col.content || []) as Array[0]>) { + const converted = convertPMNode(child); + + if (converted) { + if (Array.isArray(converted)) colChildren.push(...converted); + else colChildren.push(converted); + } + } + + columns.push({ _type: "nestingColumn", _key: generateKey(), children: colChildren }); + } + + return { + _type: "nestingBlock", + _key: generateKey(), + layout: attrs.layout === "flex" ? "flex" : "grid", + columns: Math.max(1, columns.length), + gap: pickNestingGap(attrs.gap), + align: pickNestingAlign(attrs.align), + children: columns, + }; + } + case "image": { const attrs = node.attrs ?? {}; const provider = attrStr(attrs.provider); @@ -880,6 +927,34 @@ function convertPTBlock(block: PortableTextBlock): unknown { }; } + case "nestingBlock": { + const nb = block as { layout?: unknown; gap?: unknown; align?: unknown; children?: unknown }; + const rawChildren = Array.isArray(nb.children) ? nb.children : []; + + const columns = rawChildren.map((child) => { + const c = child as { _type?: unknown; children?: unknown }; + const colBlocks = + c._type === "nestingColumn" && Array.isArray(c.children) + ? (c.children as PortableTextBlock[]) + : [child as PortableTextBlock]; + + return { type: "nestingColumn", content: portableTextToProsemirror(colBlocks).content }; + }); + + return { + type: "nestingBlock", + attrs: { + layout: nb.layout === "flex" ? "flex" : "grid", + gap: pickNestingGap(nb.gap), + align: pickNestingAlign(nb.align), + }, + content: + columns.length > 0 + ? columns + : [{ type: "nestingColumn", content: [{ type: "paragraph" }] }], + }; + } + default: { // Treat unknown block types as plugin blocks (embeds) // These have an id field (or url for backwards compat) for the embed source, @@ -1247,6 +1322,29 @@ const defaultSlashCommands: SlashCommandItem[] = [ .run(); }, }, + { + id: "nestingBlock", + title: msg`Nesting container`, + description: msg`Grid or flex layout holding other blocks`, + icon: ColumnsIcon, + category: msg`Layout`, + aliases: ["nest", "container", "layout", "grid", "flex", "columns"], + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .insertContent({ + type: "nestingBlock", + attrs: { layout: "grid", gap: "md", align: "start" }, + content: [ + { type: "nestingColumn", content: [{ type: "paragraph" }] }, + { type: "nestingColumn", content: [{ type: "paragraph" }] }, + ], + }) + .run(); + }, + }, ]; /** @@ -2550,6 +2648,8 @@ export function PortableTextEditor({ ImageExtension, MarkdownLinkExtension, PluginBlockExtension, + NestingBlockExtension, + NestingColumnExtension, Table.configure({ resizable: true, }), diff --git a/packages/admin/src/components/editor/NestingBlockNode.tsx b/packages/admin/src/components/editor/NestingBlockNode.tsx new file mode 100644 index 0000000000..1a27b12d31 --- /dev/null +++ b/packages/admin/src/components/editor/NestingBlockNode.tsx @@ -0,0 +1,337 @@ +/** + * Nesting Block Node for TipTap + * + * A grid/flex layout container built from explicit `nestingColumn` cells. The + * container (`nestingBlock`) holds `nestingColumn+`, each column holds `block+` + * (its own editable blocks). Columns pre-exist as bordered drop zones, editors + * add/remove columns with toolbar controls and type into each independently + * rather than relying on Enter to spawn cells. + * + * Serializes to a Portable Text `nestingBlock` whose `children` is an array of + * `nestingColumn` objects (each with its own `children` blocks); the PT to/from PM + * converters in @emdash-cms/core and the admin editor handle the round-trip. + */ + +import { Button, Select } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import { DotsSixVertical, Plus, Rows, SquaresFour, Trash, X } from "@phosphor-icons/react"; +import { Node, mergeAttributes } from "@tiptap/core"; +import type { NodeViewProps } from "@tiptap/react"; +import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react"; +import * as React from "react"; + +import { cn } from "../../lib/utils"; + +type NestingLayout = "grid" | "flex"; +type NestingGap = "none" | "sm" | "md" | "lg"; +type NestingAlign = "start" | "center" | "end" | "stretch"; + +const DEFAULTS = { + layout: "grid" as NestingLayout, + gap: "md" as NestingGap, + align: "start" as NestingAlign, +}; + +const MIN_COLUMNS = 1; +const MAX_COLUMNS = 6; + +/** CSS gap value per named size. */ +const GAP_TO_CSS: Record = { + none: "0", + sm: "0.5rem", + md: "1rem", + lg: "2rem", +}; + +/** + * TipTap's React `NodeViewContent` nests children one level deeper inside a + * `[data-node-view-content-react]` wrapper, so the layout must target that + * wrapper, not the element we render. Layout is passed as CSS variables and + * applied here, column cells get the border and sizing. + */ +const STYLE_ID = "emdash-nesting-block-style"; +const NESTING_STYLES = ` +.nesting-block-content > [data-node-view-content-react] { + display: var(--nesting-display, grid); + grid-template-columns: var(--nesting-cols, repeat(2, minmax(0, 1fr))); + gap: var(--nesting-gap, 1rem); + align-items: var(--nesting-align, start); + flex-wrap: wrap; +} +.nesting-column { + flex: 1 1 12rem; + min-width: 0; +} +.nesting-column-content { + min-height: 2.5rem; +} +.nesting-column-content > [data-node-view-content-react] > *:first-child { + margin-top: 0; +} +.nesting-column-content > [data-node-view-content-react] > *:last-child { + margin-bottom: 0; +} +`; + +function ensureNestingStyles(): void { + if (typeof document === "undefined" || document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = NESTING_STYLES; + document.head.appendChild(style); +} + +function containerVars( + layout: NestingLayout, + columnCount: number, + gap: NestingGap, + align: NestingAlign, +): React.CSSProperties { + return { + "--nesting-display": layout === "grid" ? "grid" : "flex", + "--nesting-cols": `repeat(${columnCount}, minmax(0, 1fr))`, + "--nesting-gap": GAP_TO_CSS[gap], + "--nesting-align": align, + } as React.CSSProperties; +} + +// Column node + +function NestingColumnNodeView({ editor, getPos, node }: NodeViewProps) { + const { t } = useLingui(); + + const canRemove = React.useMemo(() => { + if (typeof getPos !== "function") return false; + const pos = getPos(); + if (typeof pos !== "number") return false; + try { + return editor.state.doc.resolve(pos).parent.childCount > 1; + } catch { + return false; + } + }, [editor, getPos, node]); + + const removeColumn = () => { + if (typeof getPos !== "function") return; + const pos = getPos(); + if (typeof pos !== "number") return; + editor + .chain() + .focus() + .deleteRange({ from: pos, to: pos + node.nodeSize }) + .run(); + }; + + return ( + + {canRemove && ( + + )} + + + ); +} + +export const NestingColumnExtension = Node.create({ + name: "nestingColumn", + group: "nestingColumn", + content: "block+", + isolating: true, + selectable: false, + + parseHTML() { + return [{ tag: "div[data-emdash-nesting-column]" }]; + }, + + renderHTML({ HTMLAttributes }) { + return ["div", mergeAttributes(HTMLAttributes, { "data-emdash-nesting-column": "" }), 0]; + }, + + addNodeView() { + return ReactNodeViewRenderer(NestingColumnNodeView); + }, +}); + +// Container node + +function NestingBlockNodeView({ + node, + updateAttributes, + selected, + deleteNode, + editor, + getPos, +}: NodeViewProps) { + const { t } = useLingui(); + + React.useEffect(() => { + ensureNestingStyles(); + }, []); + + const layout: NestingLayout = node.attrs.layout === "flex" ? "flex" : "grid"; + const gap: NestingGap = (["none", "sm", "md", "lg"] as const).includes(node.attrs.gap) + ? node.attrs.gap + : DEFAULTS.gap; + const align: NestingAlign = (["start", "center", "end", "stretch"] as const).includes( + node.attrs.align, + ) + ? node.attrs.align + : DEFAULTS.align; + + const columnCount = node.childCount; + + const addColumn = () => { + if (typeof getPos !== "function" || columnCount >= MAX_COLUMNS) return; + const pos = getPos(); + if (typeof pos !== "number") return; + const endInside = pos + node.nodeSize - 1; + editor + .chain() + .focus() + .insertContentAt(endInside, { type: "nestingColumn", content: [{ type: "paragraph" }] }) + .run(); + }; + + return ( + +
+ + +
+ {layout === "grid" ? : } + {t`Nesting container`} +
+ +
+ updateAttributes({ gap: v ?? DEFAULTS.gap })} + items={{ none: t`None`, sm: t`Small`, md: t`Medium`, lg: t`Large` }} + /> + updateAttributes({ widths: v ?? DEFAULTS.widths })} + items={{ + equal: t`Equal`, + "wide-first": t`Wide first`, + "wide-last": t`Wide last`, + "narrow-first": t`Narrow first`, + "narrow-last": t`Narrow last`, + }} + /> +
{layout === "grid" ? : } {t`Nesting container`} + {/* Counts sit outside the translated words rather than inside a plural + message, so the summary reads correctly before catalogs are extracted. + A `` here renders its own ICU source until then, which is worse + to look at than an unagreed plural. */} + {collapsed && ( + + {columnCount} {t`columns`} + {", "} + {blockCount} {t`blocks`} + + )}
+ {/* Layout controls describe content that is not on screen while collapsed, so + they fold away with it. Delete stays: an unwanted container should not have + to be opened first. */}
- updateAttributes({ gap: v ?? DEFAULTS.gap })} - items={{ none: t`None`, sm: t`Small`, md: t`Medium`, lg: t`Large` }} - /> - updateAttributes({ layout: v === "flex" ? "flex" : "grid" })} + items={{ grid: t`Grid`, flex: t`Flex` }} + /> + updateAttributes({ align: v ?? DEFAULTS.align })} + items={{ + start: t`Top`, + center: t`Center`, + end: t`Bottom`, + stretch: t`Stretch`, + }} + /> + {/* Equal columns cannot express a content-plus-sidebar page, which is the most common two-column layout, so the weighted presets exist for that. Grid only: a flex container sizes its columns from their content, and the site renderer ignores widths there too, so it is disabled rather than left to look like it works. */} - updateAttributes({ widths: v ?? DEFAULTS.widths })} + items={{ + equal: t`Equal`, + "wide-first": t`Wide first`, + "wide-last": t`Wide last`, + "narrow-first": t`Narrow first`, + "narrow-last": t`Narrow last`, + }} + /> + + + )}
+ {/* Hidden rather than unmounted: ProseMirror owns this element as the node's + contentDOM, and removing it detaches the container's content from the + document. */} From b075b51ac10eb41034415373e9d0ca856821efa3 Mon Sep 17 00:00:00 2001 From: Zach Bimson Date: Sun, 26 Jul 2026 23:11:41 +0100 Subject: [PATCH 4/5] chore: changeset for the nesting additions --- .changeset/nesting-column-widths-and-rows.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/nesting-column-widths-and-rows.md diff --git a/.changeset/nesting-column-widths-and-rows.md b/.changeset/nesting-column-widths-and-rows.md new file mode 100644 index 0000000000..5ff878886f --- /dev/null +++ b/.changeset/nesting-column-widths-and-rows.md @@ -0,0 +1,6 @@ +--- +"@emdash-cms/admin": minor +"emdash": minor +--- + +Nesting blocks gain column width ratios, so a container can express a content and sidebar layout rather than only equal columns. Blocks inside a column become first-class rows that can be reordered by dragging, and a container can be folded away to a one line summary of what it holds. From da43fcacd02f7162f2b0150e44e813a8ce427806 Mon Sep 17 00:00:00 2001 From: Zach Bimson Date: Mon, 27 Jul 2026 00:57:51 +0100 Subject: [PATCH 5/5] fix(nesting): pluralize the collapsed summary, trim comments The collapsed container summary was built by concatenating counts with translated words and a literal comma, which fixes English word order and punctuation. It is now a single message with plural forms for both counts. The comments added across these changes carried decision rationale and narrative about what was tried. Cut back to the invariants a reader would otherwise get wrong: the handle gutter and its offset have to agree, the admin's grid template mirrors core's, and the node's contentDOM stays mounted while collapsed. --- .../components/editor/DragHandleWrapper.tsx | 68 +++-------------- .../src/components/editor/HtmlBlockNode.tsx | 3 - .../components/editor/NestingBlockNode.tsx | 74 +++++-------------- .../src/components/editor/PluginBlockNode.tsx | 12 +-- .../DragHandleWrapper.interactions.test.tsx | 5 +- .../tests/editor/DragHandleWrapper.test.ts | 16 +--- .../editor/nesting-block-conversion.test.ts | 10 +-- packages/core/src/content/converters/types.ts | 7 +- 8 files changed, 39 insertions(+), 156 deletions(-) diff --git a/packages/admin/src/components/editor/DragHandleWrapper.tsx b/packages/admin/src/components/editor/DragHandleWrapper.tsx index 90e2e21abb..081825b591 100644 --- a/packages/admin/src/components/editor/DragHandleWrapper.tsx +++ b/packages/admin/src/components/editor/DragHandleWrapper.tsx @@ -38,26 +38,18 @@ export function _getDragHandlePlacement(direction: "ltr" | "rtl") { } /** - * How far to place the handle from the row it belongs to. - * - * A top level row sits inside the editor's own 64px gutter, so its handle goes just - * outside the row. A row inside a nesting column carries the gutter as its own - * leading padding instead (see NESTING_GUTTER_PX), which is what lets a pointer in - * the gutter still resolve to that row -- so the handle has to come *back* across - * the row's edge to land in that padding rather than out over the column beside it. + * A top level row's handle sits outside it, in the editor's own gutter. A row in a + * column carries that gutter as its own leading padding, so the handle moves back + * across the row's edge to land inside it. Must agree with NESTING_GUTTER_PX. */ export function _dragHandleOffset(insideColumn: boolean): number { return insideColumn ? -(NESTING_GUTTER_PX - 4) : 4; } /** - * Is the row at `pos` a child of a nesting column? - * - * Read from the document, because the handle positions against a virtual element - * that carries only a rect with no way back to the node it came from. `pos` is the - * position before the row, so its parent is the column that would hold it -- only - * the immediate parent is checked, because only a direct child of a column is ever - * a drag target. + * Resolved from the document rather than the hovered element, which is virtual and + * carries only a rect. `pos` is the position before the row, so its parent is the + * column that would hold it. */ export function _isInsideNestingColumn(editor: Editor, pos: number): boolean { if (pos < 0) return false; @@ -70,29 +62,8 @@ export function _isInsideNestingColumn(editor: Editor, pos: number): boolean { } /** - * The draggable unit is a row: a child of the document, or a child of a nesting - * column, which is the same thing one level down. - * - * This is what the editor already did. With nested targeting off, TipTap targets - * top level blocks, so a list drags as one block and its items do not drag at all. - * The rule restates that and extends it into columns, which is why behaviour - * outside a container is unchanged by enabling nesting. - * - * It deliberately replaces TipTap's default rules rather than joining them, and - * that is a choice about units, not a claim that they are wrong. Their defaults - * resolve the unit *inside* a structure: for a list, `listItemFirstChild` and - * `listWrapperDeprioritize` between them exclude the paragraph and the wrapper so - * the list item wins. That is right for a plain document and wrong for a page - * built from containers, where a list is one row a page is composed of and its - * items are the row's internals. The two cannot both hold, and picking theirs - * means a list can no longer be moved as a block anywhere in the document. - * - * Nothing the defaults guard is lost. Table internals and inline content are - * never children of the document or of a column, so they are excluded here by - * construction; the tests assert that rather than assuming it. - * - * Columns themselves are never a target either: `selectable: false`, and they are - * added and removed from the container's toolbar. + * Drag unit: direct children of the document or of a nesting column. + * Table internals and inline content are excluded by the schema. */ export const _rowsOnlyRule: DragHandleRule = { id: "emdashRowsOnly", @@ -104,25 +75,7 @@ export const _rowsOnlyRule: DragHandleRule = { }, }; -/** - * Nested targeting, so a block inside a nesting column can be reordered. - * - * Edge detection is off, and follows from the same choice. It resolves ambiguity by - * pointer position, deducting by depth near a node's edge so the parent wins there. - * With rows as the unit there is no ambiguity left to resolve: a column is never a - * target, so the only candidates are the row and its container, and the container - * has an explicit grab point of its own in its header. Leaving it on only breaks - * things, because the deduction excludes a candidate outright at the depth a row - * sits inside a column, and rows are often short enough that the 12px band covers - * half of one. - * - * The handle's placement depends on this too. The gutter it sits in belongs to the - * row, so a target deeper than a row is measured from the wrong box and the handle - * lands over the content instead of beside it. - * - * Module level so the reference is stable: DragHandle's effect depends on it, and a - * fresh object each render re-registers the plugin. - */ +/** Module level: DragHandle re-registers its plugin if this identity changes. */ export const _nestedDragOptions = { rules: [_rowsOnlyRule], defaultRules: false, @@ -207,8 +160,7 @@ export function DragHandleWrapper({ editor, onInsertBlock }: DragHandleWrapperPr editor.commands.setMeta("lockDragHandle", false); }, [editor]); - // Written synchronously here and read by the offset middleware, which the drag - // handle invokes immediately afterwards to reposition. + // Set in onNodeChange, read by the offset middleware that runs straight after it. const insideColumnRef = React.useRef(false); // Handle node change from drag handle diff --git a/packages/admin/src/components/editor/HtmlBlockNode.tsx b/packages/admin/src/components/editor/HtmlBlockNode.tsx index 670024cea9..f30714494c 100644 --- a/packages/admin/src/components/editor/HtmlBlockNode.tsx +++ b/packages/admin/src/components/editor/HtmlBlockNode.tsx @@ -85,9 +85,6 @@ function HtmlBlockNodeView({ node, updateAttributes, selected, deleteNode }: Nod contentEditable={false} data-drag-handle > - {/* No grip of its own -- see PluginBlockNode. The editor's drag handle covers - every block, and -start-8 puts this one in a gutter a nesting column does - not have. */}
{/* Main block */}
[data-node-view-content-react] > * { padding-inline-start: var(--nesting-gutter); } -/* - * A row that indents its own content keeps that indent on top of the gutter. - * Setting the gutter alone replaces it, which pulls list markers and a quote's - * rule back into the gutter and leaves them under the drag handle. The added - * values are the editor's own defaults for these elements. - */ +/* A row with its own indent adds it to the gutter; setting the gutter alone + * replaces it and draws markers under the handle. Added values are the + * editor's defaults for these elements. */ .nesting-column-content > [data-node-view-content-react] > :is(ul, ol) { padding-inline-start: calc(var(--nesting-gutter) + 1.625rem); } @@ -125,9 +115,9 @@ function ensureNestingStyles(): void { } /** - * `grid-template-columns` for a width preset. Mirrors `nestingTemplateColumns` - * in @emdash-cms/core so the editor preview matches what the site renders; the - * admin does not depend on core, hence the duplication (as with GAP_TO_CSS). + * `grid-template-columns` for a width preset. Mirrors `nestingTemplateColumns` in + * @emdash-cms/core, which the admin cannot import; they have to stay in step or the + * editor preview and the rendered page disagree. */ function templateColumns(widths: NestingWidths, columnCount: number): string { const n = Math.max(MIN_COLUMNS, Math.min(MAX_COLUMNS, columnCount)); @@ -182,8 +172,7 @@ function NestingColumnNodeView({ editor, getPos, node }: NodeViewProps) { return ( @@ -258,18 +247,12 @@ function NestingBlockNodeView({ const columnCount = node.childCount; - /** - * Collapsed is view state, not content: it is deliberately not a node attribute, - * so folding a container away is never a document change and never lands in a - * revision. It resets on reload, which matches how editors expect a disclosure to - * behave. - */ + // View state, not a node attribute: folding a container is not a document change + // and must not reach a revision. const [collapsed, setCollapsed] = React.useState(false); - // Ties the disclosure button to the region it shows and hides. const contentId = React.useId(); - // What the container holds, for the summary shown when it is folded away. const blockCount = React.useMemo(() => { let total = 0; node.forEach((column) => { @@ -301,12 +284,6 @@ function NestingBlockNodeView({ className="flex flex-wrap items-center gap-2 border-b border-kumo-line px-3 py-2" contentEditable={false} > - {/* The title is the grab area, the way a window is dragged by its bar. There - was a separate grip here, permanently visible, which nothing else in the - editor has: every other row is dragged from a handle that appears in the - gutter on hover. A container is a row too, but hovering one resolves to - the deepest row inside it, so it still needs somewhere of its own to be - picked up -- its header, rather than an icon that is always on screen. */}
- {/* Layout controls describe content that is not on screen while collapsed, so - they fold away with it. Delete stays: an unwanted container should not have - to be opened first. */}
{!collapsed && ( <> @@ -374,11 +342,8 @@ function NestingBlockNodeView({ stretch: t`Stretch`, }} /> - {/* Equal columns cannot express a content-plus-sidebar page, which is the - most common two-column layout, so the weighted presets exist for that. - Grid only: a flex container sizes its columns from their content, and - the site renderer ignores widths there too, so it is disabled rather - than left to look like it works. */} + {/* Grid only: a flex container sizes columns from their content, and the + site renderer ignores widths there. */}