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. diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index 671765810b..df5bf763a7 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,23 @@ 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; +const NESTING_WIDTHS = ["equal", "wide-first", "wide-last", "narrow-first", "narrow-last"] 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"; +} + +function pickNestingWidths(v: unknown): (typeof NESTING_WIDTHS)[number] { + return NESTING_WIDTHS.find((w) => w === v) ?? "equal"; +} + // ProseMirror to Portable Text converter function prosemirrorToPortableText(doc: { type: string; @@ -372,6 +391,40 @@ 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), + widths: pickNestingWidths(attrs.widths), + children: columns, + }; + } + case "image": { const attrs = node.attrs ?? {}; const provider = attrStr(attrs.provider); @@ -880,6 +933,41 @@ function convertPTBlock(block: PortableTextBlock): unknown { }; } + case "nestingBlock": { + const nb = block as { + layout?: unknown; + gap?: unknown; + align?: unknown; + widths?: 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), + widths: pickNestingWidths(nb.widths), + }, + 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 +1335,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 +2661,8 @@ export function PortableTextEditor({ ImageExtension, MarkdownLinkExtension, PluginBlockExtension, + NestingBlockExtension, + NestingColumnExtension, Table.configure({ resizable: true, }), diff --git a/packages/admin/src/components/editor/DragHandleWrapper.tsx b/packages/admin/src/components/editor/DragHandleWrapper.tsx index 74997f2585..081825b591 100644 --- a/packages/admin/src/components/editor/DragHandleWrapper.tsx +++ b/packages/admin/src/components/editor/DragHandleWrapper.tsx @@ -13,6 +13,7 @@ import { offset } from "@floating-ui/react"; import { useLingui } from "@lingui/react/macro"; import { DotsSixVertical, Plus } from "@phosphor-icons/react"; import type { Editor } from "@tiptap/core"; +import type { DragHandleRule } from "@tiptap/extension-drag-handle"; import { DragHandle } from "@tiptap/extension-drag-handle-react"; import type { Node as PMNode } from "@tiptap/pm/model"; import * as React from "react"; @@ -20,6 +21,7 @@ import * as React from "react"; import { cn } from "../../lib/utils"; import { getLocaleDir } from "../../locales/config.js"; import { BlockMenu } from "./BlockMenu"; +import { NESTING_GUTTER_PX } from "./NestingBlockNode"; interface DragHandleWrapperProps { editor: Editor; @@ -35,6 +37,51 @@ export function _getDragHandlePlacement(direction: "ltr" | "rtl") { return direction === "rtl" ? ("right-start" as const) : ("left-start" as const); } +/** + * 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; +} + +/** + * 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; + try { + return editor.state.doc.resolve(pos).parent.type.name === "nestingColumn"; + } catch { + // A stale position between transactions -- treat as top level. + return false; + } +} + +/** + * 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", + evaluate: ({ node, depth, $pos }) => { + const EXCLUDE = 1000; + if (node.type.name === "nestingColumn") return EXCLUDE; + if (depth <= 1) return 0; + return $pos.node(depth - 1).type.name === "nestingColumn" ? 0 : EXCLUDE; + }, +}; + +/** Module level: DragHandle re-registers its plugin if this identity changes. */ +export const _nestedDragOptions = { + rules: [_rowsOnlyRule], + defaultRules: false, + edgeDetection: "none" as const, +}; + /** * DragHandleWrapper - Official TipTap drag handle with BlockMenu integration */ @@ -113,9 +160,13 @@ export function DragHandleWrapper({ editor, onInsertBlock }: DragHandleWrapperPr editor.commands.setMeta("lockDragHandle", false); }, [editor]); + // Set in onNodeChange, read by the offset middleware that runs straight after it. + const insideColumnRef = React.useRef(false); + // Handle node change from drag handle const handleNodeChange = React.useCallback( (data: { node: PMNode | null; editor: Editor; pos: number }) => { + insideColumnRef.current = data.node ? _isInsideNestingColumn(data.editor, data.pos) : false; if (data.node) { setHoveredNode({ node: data.node, pos: data.pos }); } else { @@ -135,7 +186,7 @@ export function DragHandleWrapper({ editor, onInsertBlock }: DragHandleWrapperPr () => ({ placement: _getDragHandlePlacement(direction), strategy: "absolute" as const, - middleware: [offset(4)], + middleware: [offset(() => _dragHandleOffset(insideColumnRef.current))], }), [direction], ); @@ -146,6 +197,7 @@ export function DragHandleWrapper({ editor, onInsertBlock }: DragHandleWrapperPr editor={editor} onNodeChange={handleNodeChange} computePositionConfig={computePositionConfig} + nested={_nestedDragOptions} >
+ )} + + + ); +} + +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 widths: NestingWidths = + typeof node.attrs.widths === "string" && NESTING_WIDTHS.has(node.attrs.widths) + ? (node.attrs.widths as NestingWidths) + : DEFAULTS.widths; + 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; + + // 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); + + const contentId = React.useId(); + + const blockCount = React.useMemo(() => { + let total = 0; + node.forEach((column) => { + total += column.childCount; + }); + return total; + }, [node]); + + 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`} + {collapsed && ( + + {t`${plural(columnCount, { one: "# column", other: "# columns" })}, ${plural(blockCount, { one: "# block", other: "# blocks" })}`} + + )} +
+ +
+ {!collapsed && ( + <> + 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`, + }} + /> + + + )} + +
+
+ {/* Hidden, not unmounted: ProseMirror owns this element as the node's + contentDOM. */} + +
+ ); +} + +export const NestingBlockExtension = Node.create({ + name: "nestingBlock", + group: "block", + content: "nestingColumn+", + defining: true, + isolating: true, + draggable: true, + selectable: true, + + addAttributes() { + return { + layout: { + default: DEFAULTS.layout, + parseHTML: (el: HTMLElement) => + el.getAttribute("data-layout") === "flex" ? "flex" : "grid", + renderHTML: (attrs: Record) => ({ + "data-layout": attrs.layout === "flex" ? "flex" : "grid", + }), + }, + gap: { + default: DEFAULTS.gap, + parseHTML: (el: HTMLElement) => el.getAttribute("data-gap") ?? DEFAULTS.gap, + renderHTML: (attrs: Record) => ({ + "data-gap": typeof attrs.gap === "string" ? attrs.gap : DEFAULTS.gap, + }), + }, + align: { + default: DEFAULTS.align, + parseHTML: (el: HTMLElement) => el.getAttribute("data-align") ?? DEFAULTS.align, + renderHTML: (attrs: Record) => ({ + "data-align": typeof attrs.align === "string" ? attrs.align : DEFAULTS.align, + }), + }, + widths: { + default: DEFAULTS.widths, + parseHTML: (el: HTMLElement) => el.getAttribute("data-widths") ?? DEFAULTS.widths, + renderHTML: (attrs: Record) => ({ + "data-widths": typeof attrs.widths === "string" ? attrs.widths : DEFAULTS.widths, + }), + }, + }; + }, + + parseHTML() { + return [{ tag: "div[data-emdash-nesting-block]" }]; + }, + + renderHTML({ HTMLAttributes }) { + return ["div", mergeAttributes(HTMLAttributes, { "data-emdash-nesting-block": "" }), 0]; + }, + + addNodeView() { + return ReactNodeViewRenderer(NestingBlockNodeView); + }, +}); diff --git a/packages/admin/src/components/editor/PluginBlockNode.tsx b/packages/admin/src/components/editor/PluginBlockNode.tsx index 8ee9e0983d..5792426a6a 100644 --- a/packages/admin/src/components/editor/PluginBlockNode.tsx +++ b/packages/admin/src/components/editor/PluginBlockNode.tsx @@ -15,7 +15,6 @@ import type { MessageDescriptor } from "@lingui/core"; import { msg } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { - DotsSixVertical, Trash, Pencil, X, @@ -266,17 +265,6 @@ function PluginBlockNodeView({ data-drag-handle >
- {/* Drag handle - appears in left gutter */} -
- -
- {/* Main block content */}
- {/* Header with icon, label, and actions */} -
+ {/* Wraps because the action buttons hold their width while hidden, leaving + the label almost none in a narrow column. */} +
{/* Icon */}
{/* Label and ID */} -
+
{label}
{!isEditing && (
{displayId}
diff --git a/packages/admin/tests/editor/DragHandleWrapper.interactions.test.tsx b/packages/admin/tests/editor/DragHandleWrapper.interactions.test.tsx index c66888e7d5..65da8bb542 100644 --- a/packages/admin/tests/editor/DragHandleWrapper.interactions.test.tsx +++ b/packages/admin/tests/editor/DragHandleWrapper.interactions.test.tsx @@ -14,16 +14,19 @@ vi.mock("@tiptap/extension-drag-handle-react", () => ({ children: React.ReactNode; computePositionConfig: { placement: string; - middleware?: Array<{ name: string; options?: [number?] }>; + middleware?: Array<{ name: string; options?: [(number | (() => number))?] }>; }; }) => (
name === "offset")?.options?.[0] ?? "" - } + // Resolve the offset the way floating-ui would: it may be a function. + data-offset={(() => { + const option = computePositionConfig.middleware?.find(({ name }) => name === "offset") + ?.options?.[0]; + return (typeof option === "function" ? option() : option) ?? ""; + })()} > {children}
diff --git a/packages/admin/tests/editor/DragHandleWrapper.test.ts b/packages/admin/tests/editor/DragHandleWrapper.test.ts index d0d38e499c..eb799d805a 100644 --- a/packages/admin/tests/editor/DragHandleWrapper.test.ts +++ b/packages/admin/tests/editor/DragHandleWrapper.test.ts @@ -1,9 +1,24 @@ import { Editor } from "@tiptap/core"; import { DragHandlePlugin, normalizeNestedOptions } from "@tiptap/extension-drag-handle"; +import type { RuleContext } from "@tiptap/extension-drag-handle"; +import { Table } from "@tiptap/extension-table"; +import { TableCell } from "@tiptap/extension-table-cell"; +import { TableHeader } from "@tiptap/extension-table-header"; +import { TableRow } from "@tiptap/extension-table-row"; import StarterKit from "@tiptap/starter-kit"; import { describe, expect, it } from "vitest"; -import { _getDragHandlePlacement } from "../../src/components/editor/DragHandleWrapper"; +import { + _dragHandleOffset, + _getDragHandlePlacement, + _nestedDragOptions, + _rowsOnlyRule, +} from "../../src/components/editor/DragHandleWrapper"; +import { + NESTING_GUTTER_PX, + NestingBlockExtension, + NestingColumnExtension, +} from "../../src/components/editor/NestingBlockNode"; describe("DragHandleWrapper", () => { it("places controls at the admin UI's logical start edge", () => { @@ -44,3 +59,134 @@ describe("DragHandleWrapper", () => { } }); }); + +describe("rows are the draggable unit", () => { + // Node views are React renderers and are not needed to exercise the schema. + const Block = NestingBlockExtension.extend({ addNodeView: undefined }); + const Column = NestingColumnExtension.extend({ addNodeView: undefined }); + + function withEditor(content: string, run: (editor: Editor) => void) { + const host = document.createElement("div"); + document.body.append(host); + const editor = new Editor({ + element: host, + extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Block, Column], + content, + }); + try { + run(editor); + } finally { + editor.destroy(); + host.remove(); + } + } + + /** First position inside the first text node matching `text`. */ + function posInText(editor: Editor, text: string): number { + let found = -1; + editor.state.doc.descendants((node, pos) => { + if (found === -1 && node.isText && node.text === text) found = pos + 1; + return found === -1; + }); + if (found === -1) throw new Error(`no text node matching ${text}`); + return found; + } + + function scoreAt( + $pos: ReturnType, + depth: number, + view: unknown, + ) { + const parent = $pos.node(depth - 1); + const index = $pos.index(depth - 1); + const context = { + node: $pos.node(depth), + pos: $pos.before(depth), + depth, + parent, + index, + isFirst: index === 0, + isLast: index === parent.childCount - 1, + $pos, + view, + } as unknown as RuleContext; + return 1000 - _rowsOnlyRule.evaluate(context); + } + + /** The node the handle targets: highest score, then deepest. */ + function targetFor(editor: Editor, text: string): string | null { + const $pos = editor.state.doc.resolve(posInText(editor, text)); + const candidates = []; + for (let depth = $pos.depth; depth >= 1; depth -= 1) { + const score = scoreAt($pos, depth, editor.view); + if (score > 0) candidates.push({ name: $pos.node(depth).type.name, depth, score }); + } + candidates.sort((a, b) => b.score - a.score || b.depth - a.depth); + return candidates[0]?.name ?? null; + } + + const inColumn = (inner: string) => + `
${inner}
`; + + it("targets a list as one row, not its items, in the body", () => { + withEditor("
  • Top item

", (editor) => { + expect(targetFor(editor, "Top item")).toBe("bulletList"); + }); + }); + + it("targets a list as one row inside a column too", () => { + withEditor(inColumn("
  1. Column item

"), (editor) => { + expect(targetFor(editor, "Column item")).toBe("orderedList"); + }); + }); + + it("targets a plain block in the body and in a column alike", () => { + withEditor("

Loose

", (editor) => { + expect(targetFor(editor, "Loose")).toBe("paragraph"); + }); + withEditor(inColumn("

In a column

"), (editor) => { + expect(targetFor(editor, "In a column")).toBe("paragraph"); + }); + }); + + it("targets a quote as one row, not the paragraph inside it", () => { + withEditor("

Quoted

", (editor) => { + expect(targetFor(editor, "Quoted")).toBe("blockquote"); + }); + }); + + it("never targets table internals", () => { + withEditor("

Cell

", (editor) => { + // The table is the row; everything inside it is the row's structure. + expect(targetFor(editor, "Cell")).toBe("table"); + }); + }); + + it("never targets a column", () => { + withEditor(inColumn("

In a column

"), (editor) => { + const $pos = editor.state.doc.resolve(posInText(editor, "In a column")); + // doc > nestingBlock(1) > nestingColumn(2) > paragraph(3) + expect(scoreAt($pos, 2, editor.view)).toBeLessThanOrEqual(0); + expect(scoreAt($pos, 1, editor.view)).toBeGreaterThan(0); + }); + }); + + it("offsets the handle into the row's own gutter when nested", () => { + expect(_dragHandleOffset(false)).toBe(4); + expect(_dragHandleOffset(true)).toBe(-(NESTING_GUTTER_PX - 4)); + expect(_dragHandleOffset(true) + NESTING_GUTTER_PX).toBe(4); + }); +}); + +describe("nested drag options", () => { + const normalized = normalizeNestedOptions(_nestedDragOptions); + + it("replaces the default rules rather than joining them", () => { + expect(normalized.defaultRules).toBe(false); + expect(normalized.rules.map((rule) => rule.id)).toEqual(["emdashRowsOnly"]); + }); + + it("turns edge detection off", () => { + expect(normalized.edgeDetection.edges).toEqual([]); + }); +}); diff --git a/packages/admin/tests/editor/nesting-block-conversion.test.ts b/packages/admin/tests/editor/nesting-block-conversion.test.ts new file mode 100644 index 0000000000..50387f35cb --- /dev/null +++ b/packages/admin/tests/editor/nesting-block-conversion.test.ts @@ -0,0 +1,113 @@ +/** + * The admin editor carries its own Portable Text converters, separate from the ones + * in @emdash-cms/core, and a save goes through these. Core's round-trip tests do not + * cover them: an attribute can round-trip in core and still be dropped on every save. + */ + +import { describe, it, expect } from "vitest"; + +import { + _prosemirrorToPortableText as prosemirrorToPortableText, + _portableTextToProsemirror as portableTextToProsemirror, +} from "../../src/components/PortableTextEditor"; + +interface NestingPT { + _type: string; + layout?: string; + gap?: string; + align?: string; + widths?: string; + children?: Array<{ _type: string; children?: unknown[] }>; +} + +function column(text: string) { + return { + type: "nestingColumn", + content: [{ type: "paragraph", content: [{ type: "text", text }] }], + }; +} + +describe("nesting block round-trip (admin editor seam)", () => { + it("keeps every layout attribute through PM to PT", () => { + const [block] = prosemirrorToPortableText({ + type: "doc", + content: [ + { + type: "nestingBlock", + attrs: { layout: "grid", gap: "lg", align: "center", widths: "wide-first" }, + content: [column("left"), column("right")], + }, + ], + }) as unknown as NestingPT[]; + + expect(block).toMatchObject({ + _type: "nestingBlock", + layout: "grid", + gap: "lg", + align: "center", + widths: "wide-first", + }); + expect(block.children).toHaveLength(2); + }); + + it("keeps every layout attribute through PT to PM", () => { + const doc = portableTextToProsemirror([ + { + _type: "nestingBlock", + _key: "n1", + layout: "flex", + gap: "sm", + align: "end", + widths: "narrow-last", + children: [ + { _type: "nestingColumn", _key: "c1", children: [] }, + { _type: "nestingColumn", _key: "c2", children: [] }, + ], + }, + ] as never); + + expect(doc.content?.[0]).toMatchObject({ + type: "nestingBlock", + attrs: { layout: "flex", gap: "sm", align: "end", widths: "narrow-last" }, + }); + }); + + it("survives a full PT to PM to PT cycle, which is what a save does", () => { + const original = { + _type: "nestingBlock", + _key: "n1", + layout: "grid", + gap: "md", + align: "start", + widths: "wide-last", + children: [ + { _type: "nestingColumn", _key: "c1", children: [] }, + { _type: "nestingColumn", _key: "c2", children: [] }, + ], + }; + + const roundTripped = prosemirrorToPortableText( + portableTextToProsemirror([original] as never) as never, + ) as unknown as NestingPT[]; + + expect(roundTripped[0]).toMatchObject({ + layout: "grid", + gap: "md", + align: "start", + widths: "wide-last", + }); + }); + + it("falls back to equal for a missing or unrecognised widths value", () => { + const doc = portableTextToProsemirror([ + { + _type: "nestingBlock", + _key: "n1", + widths: "sideways", + children: [{ _type: "nestingColumn", _key: "c1", children: [] }], + }, + ] as never); + + expect(doc.content?.[0]).toMatchObject({ attrs: { widths: "equal" } }); + }); +}); diff --git a/packages/core/src/components/NestingBlock.astro b/packages/core/src/components/NestingBlock.astro new file mode 100644 index 0000000000..f10881c485 --- /dev/null +++ b/packages/core/src/components/NestingBlock.astro @@ -0,0 +1,116 @@ +--- +/** + * Portable Text nesting block component + * + * Renders a `nestingBlock`: a grid or flex layout container made of explicit + * columns. Each column's blocks recurse through the bare `astro-portabletext` + * renderer with the EmDash component set passed in, so nested content + * (including further nesting blocks) renders with the same components. + * + * `children` (columns) and each column's `children` (blocks) are remapped to + * `content` by remapNestingBlocks before render. See portable-text-nesting.ts. + */ + +import { PortableText } from "astro-portabletext"; + +import { emdashComponents } from "./index.js"; +import { nestingTemplateColumns, normalizeNestingAttrs } from "../content/converters/nesting.js"; +import type { NestingWidths } from "../content/converters/types.js"; + +type NestingLayout = "grid" | "flex"; +type NestingGap = "none" | "sm" | "md" | "lg"; +type NestingAlign = "start" | "center" | "end" | "stretch"; + +interface NestingColumn { + content?: unknown[]; +} + +export interface Props { + node: { + _type: "nestingBlock"; + _key: string; + layout?: NestingLayout; + gap?: NestingGap; + align?: NestingAlign; + widths?: NestingWidths; + // `children` (the columns) is remapped to `content` before render. + content?: NestingColumn[]; + }; +} + +const GAP_TO_CSS: Record = { + none: "0", + sm: "0.5rem", + md: "1rem", + lg: "2rem", +}; + +const { node } = Astro.props; +const columns = Array.isArray(node?.content) ? node.content : []; + +const layout: NestingLayout = node?.layout === "flex" ? "flex" : "grid"; +const gap: NestingGap = (["none", "sm", "md", "lg"] as const).includes(node?.gap as NestingGap) + ? (node!.gap as NestingGap) + : "md"; +const align: NestingAlign = (["start", "center", "end", "stretch"] as const).includes( + node?.align as NestingAlign, +) + ? (node!.align as NestingAlign) + : "start"; + +if (!columns.length) { + return null; +} + +const widths = normalizeNestingAttrs({ widths: node?.widths }).widths; +const style = [ + `--nesting-gap: ${GAP_TO_CSS[gap]}`, + `--nesting-align: ${align}`, + `--nesting-columns: ${columns.length}`, + `--nesting-template: ${nestingTemplateColumns(widths, columns.length)}`, +].join("; "); +--- + +
+ { + columns.map((column) => ( +
+ +
+ )) + } +
+ + diff --git a/packages/core/src/components/PortableText.astro b/packages/core/src/components/PortableText.astro index 59c1a0e369..e84e6a8ad7 100644 --- a/packages/core/src/components/PortableText.astro +++ b/packages/core/src/components/PortableText.astro @@ -23,6 +23,7 @@ import { pluginBlockComponents } from "virtual:emdash/block-components"; import { emdashComponents } from "./index.js"; import InlineEditor from "./InlineEditor.astro"; import { groupBlockquoteRuns } from "./portable-text-blockquote-group.js"; +import { remapNestingBlocks } from "./portable-text-nesting.js"; export interface Props extends Omit { value: PortableTextProps["value"]; @@ -42,7 +43,10 @@ const mergedComponents = userComponents // A multi-paragraph quote is stored as consecutive blockquote-styled blocks // (Portable Text is flat); merge each run into one blockquoteGroup node so // it renders as a single
instead of several (#1884). -const renderValue = Array.isArray(value) ? groupBlockquoteRuns(value) : value; + +const renderValue = Array.isArray(value) + ? remapNestingBlocks(groupBlockquoteRuns(value)) + : value; --- {editMeta ? ( diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index dd765470b8..4145119335 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -44,6 +44,7 @@ export { default as Gallery } from "./Gallery.astro"; export { default as Columns } from "./Columns.astro"; export { default as Break } from "./Break.astro"; export { default as HtmlBlock } from "./HtmlBlock.astro"; +export { default as NestingBlock } from "./NestingBlock.astro"; export { default as Table } from "./Table.astro"; export { default as Button } from "./Button.astro"; export { default as Buttons } from "./Buttons.astro"; @@ -73,6 +74,7 @@ import HtmlBlockComponent from "./HtmlBlock.astro"; // Pre-configured components object for PortableText import ImageComponent from "./Image.astro"; import { emdashMarkComponents } from "./marks.js"; +import NestingBlockComponent from "./NestingBlock.astro"; import PullquoteComponent from "./Pullquote.astro"; import TableComponent from "./Table.astro"; @@ -95,6 +97,7 @@ export const emdashComponents = { embed: EmbedComponent, gallery: GalleryComponent, columns: ColumnsComponent, + nestingBlock: NestingBlockComponent, break: BreakComponent, htmlBlock: HtmlBlockComponent, table: TableComponent, diff --git a/packages/core/src/components/portable-text-nesting.ts b/packages/core/src/components/portable-text-nesting.ts new file mode 100644 index 0000000000..b0c986bd23 --- /dev/null +++ b/packages/core/src/components/portable-text-nesting.ts @@ -0,0 +1,49 @@ +/** + * Render-time adaptation for `nestingBlock` layout containers. + * + * A nesting block stores its columns under `children`, and each `nestingColumn` + * stores its blocks under `children` too. That key is reserved + * in Portable Text for a text block's inline spans, and `@portabletext/toolkit` + * treats any node with a `children` array of typed objects as a text block, so + * a container would be misrendered as a paragraph and never reach its + * component. This remaps `children` to `content` (the key EmDash's own + * container blocks use) at every level so the renderer routes correctly. + * Storage is untouched; this is render-only. + */ + +function isObject(node: unknown): node is Record { + return typeof node === "object" && node !== null; +} + +function typeOf(node: unknown): string | undefined { + if (!isObject(node)) return undefined; + return typeof node._type === "string" ? node._type : undefined; +} + +function childrenOf(node: unknown): unknown[] { + if (!isObject(node)) return []; + return Array.isArray(node.children) ? node.children : []; +} + +/** Recursively remap `nestingBlock.children` (columns) to `content`. */ +export function remapNestingBlocks(blocks: unknown[]): unknown[] { + return blocks.map((block) => { + if (typeOf(block) !== "nestingBlock" || !isObject(block)) return block; + const rest = { ...block }; + delete rest.children; + return { ...rest, content: remapNestingColumns(childrenOf(block)) }; + }); +} + +function remapNestingColumns(columns: unknown[]): unknown[] { + return columns.map((column) => { + if (typeOf(column) !== "nestingColumn" || !isObject(column)) { + // Legacy/loose block stored directly under a nesting block — wrap it + // as a single-block column so it still renders. + return { _type: "nestingColumn", content: remapNestingBlocks([column]) }; + } + const rest = { ...column }; + delete rest.children; + return { ...rest, content: remapNestingBlocks(childrenOf(column)) }; + }); +} diff --git a/packages/core/src/content/converters/nesting.ts b/packages/core/src/content/converters/nesting.ts new file mode 100644 index 0000000000..9af62c982a --- /dev/null +++ b/packages/core/src/content/converters/nesting.ts @@ -0,0 +1,78 @@ +/** + * Nesting block helpers + * + * Shared defaults and normalization for the `nestingBlock` layout container, + * used by the PT to/from PM converters and the site renderer so a block that was + * hand-authored or imported with missing/invalid layout fields still renders + * predictably. + */ + +import type { NestingAlign, NestingGap, NestingLayout, NestingWidths } from "./types.js"; + +export const NESTING_LAYOUTS: readonly NestingLayout[] = ["grid", "flex"]; +export const NESTING_GAPS: readonly NestingGap[] = ["none", "sm", "md", "lg"]; +export const NESTING_ALIGNS: readonly NestingAlign[] = ["start", "center", "end", "stretch"]; +export const NESTING_WIDTHS: readonly NestingWidths[] = [ + "equal", + "wide-first", + "wide-last", + "narrow-first", + "narrow-last", +]; + +/** + * `grid-template-columns` for a width preset. The weighted column takes 2fr against + * 1fr for the others, which gives the familiar two-thirds/one-third split at two + * columns and stays sensible above that. + */ +export function nestingTemplateColumns(widths: NestingWidths, columns: number): string { + const n = Math.max(NESTING_MIN_COLUMNS, Math.min(NESTING_MAX_COLUMNS, columns)); + if (widths === "equal" || n < 2) return `repeat(${n}, minmax(0, 1fr))`; + const wide = "minmax(0, 2fr)"; + const rest = "minmax(0, 1fr)"; + const weightedIndex = widths === "wide-first" || widths === "narrow-last" ? 0 : n - 1; + return Array.from({ length: n }, (_, i) => (i === weightedIndex ? wide : rest)).join(" "); +} + +/** Bounds for the grid column count. */ +export const NESTING_MIN_COLUMNS = 1; +export const NESTING_MAX_COLUMNS = 6; + +export interface NestingAttrs { + layout: NestingLayout; + columns: number; + gap: NestingGap; + align: NestingAlign; + widths: NestingWidths; +} + +export const NESTING_DEFAULTS: NestingAttrs = { + layout: "grid", + columns: 2, + gap: "md", + align: "start", + widths: "equal", +}; + +function coerceEnum(value: unknown, allowed: readonly T[], fallback: T): T { + return allowed.find((candidate) => candidate === value) ?? fallback; +} + +function coerceColumns(value: unknown): number { + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n)) return NESTING_DEFAULTS.columns; + return Math.min(NESTING_MAX_COLUMNS, Math.max(NESTING_MIN_COLUMNS, Math.round(n))); +} + +/** Coerce arbitrary layout fields into a valid, complete `NestingAttrs`. */ +export function normalizeNestingAttrs( + source: Partial>, +): NestingAttrs { + return { + layout: coerceEnum(source.layout, NESTING_LAYOUTS, NESTING_DEFAULTS.layout), + columns: coerceColumns(source.columns), + gap: coerceEnum(source.gap, NESTING_GAPS, NESTING_DEFAULTS.gap), + align: coerceEnum(source.align, NESTING_ALIGNS, NESTING_DEFAULTS.align), + widths: coerceEnum(source.widths, NESTING_WIDTHS, NESTING_DEFAULTS.widths), + }; +} diff --git a/packages/core/src/content/converters/portable-text-to-prosemirror.ts b/packages/core/src/content/converters/portable-text-to-prosemirror.ts index 7f85899c5c..57c8f0e906 100644 --- a/packages/core/src/content/converters/portable-text-to-prosemirror.ts +++ b/packages/core/src/content/converters/portable-text-to-prosemirror.ts @@ -5,6 +5,7 @@ */ import { sanitizeGalleryImages } from "./gallery.js"; +import { normalizeNestingAttrs } from "./nesting.js"; import type { ProseMirrorDocument, ProseMirrorNode, @@ -16,17 +17,30 @@ import type { PortableTextImageBlock, PortableTextGalleryBlock, PortableTextCodeBlock, + PortableTextNestingBlock, + PortableTextNestingColumn, } from "./types.js"; /** * Convert Portable Text to ProseMirror document */ export function portableTextToProsemirror(blocks: PortableTextBlock[]): ProseMirrorDocument { + const content = convertBlocks(blocks); + return { + type: "doc", + content: content.length > 0 ? content : [{ type: "paragraph" }], + }; +} + +/** + * Convert an array of Portable Text blocks to ProseMirror content. + * + * Shared by the document root and by container blocks (e.g. `nestingBlock`) + * so that list and blockquote runs regroup correctly at every nesting level. + */ +function convertBlocks(blocks: PortableTextBlock[]): ProseMirrorNode[] { if (!blocks || blocks.length === 0) { - return { - type: "doc", - content: [{ type: "paragraph" }], - }; + return []; } const content: ProseMirrorNode[] = []; @@ -102,10 +116,7 @@ export function portableTextToProsemirror(blocks: PortableTextBlock[]): ProseMir } } - return { - type: "doc", - content: content.length > 0 ? content : [{ type: "paragraph" }], - }; + return content; } /** @@ -145,6 +156,13 @@ function isCodeBlock(block: PortableTextBlock): block is PortableTextCodeBlock { return block._type === "code"; } +/** + * Type guard for nesting blocks + */ +function isNestingBlock(block: PortableTextBlock): block is PortableTextNestingBlock { + return block._type === "nestingBlock"; +} + /** * Convert a single Portable Text block to ProseMirror node */ @@ -171,6 +189,9 @@ function convertBlock(block: PortableTextBlock): ProseMirrorNode | null { if (isCodeBlock(block)) { return convertCodeBlock(block); } + if (isNestingBlock(block)) { + return convertNestingBlock(block); + } if (block._type === "htmlBlock") { const hb = block as PortableTextBlock & { html?: string }; return { @@ -504,6 +525,36 @@ function convertMalformedImage(block: PortableTextBlock): ProseMirrorNode { }; } +/** + * Convert nesting block (grid/flex container) to ProseMirror. + * + * A nesting block holds `nestingColumn+`, each column holds `block+`. Column + * blocks recurse through `convertBlocks` so nested lists, quotes, and further + * nesting blocks regroup correctly. (The admin editor and render layer also + * tolerate legacy loose children, this canonical converter takes the typed + * column shape.) + */ +function convertNestingBlock(block: PortableTextNestingBlock): ProseMirrorNode { + const columns: ProseMirrorNode[] = (Array.isArray(block.children) ? block.children : []).map( + (column) => convertNestingColumn(column), + ); + const attrs = normalizeNestingAttrs(block); + return { + type: "nestingBlock", + attrs: { ...attrs, columns: Math.max(1, columns.length) }, + content: + columns.length > 0 ? columns : [{ type: "nestingColumn", content: [{ type: "paragraph" }] }], + }; +} + +function convertNestingColumn(column: PortableTextNestingColumn): ProseMirrorNode { + const content = convertBlocks(Array.isArray(column.children) ? column.children : []); + return { + type: "nestingColumn", + content: content.length > 0 ? content : [{ type: "paragraph" }], + }; +} + /** * Convert code block to ProseMirror */ diff --git a/packages/core/src/content/converters/prosemirror-to-portable-text.ts b/packages/core/src/content/converters/prosemirror-to-portable-text.ts index 49bf82513c..7abc27f35c 100644 --- a/packages/core/src/content/converters/prosemirror-to-portable-text.ts +++ b/packages/core/src/content/converters/prosemirror-to-portable-text.ts @@ -5,6 +5,7 @@ */ import { sanitizeGalleryImages } from "./gallery.js"; +import { normalizeNestingAttrs } from "./nesting.js"; import type { ProseMirrorDocument, ProseMirrorNode, @@ -17,6 +18,7 @@ import type { PortableTextGalleryBlock, PortableTextCodeBlock, PortableTextHtmlBlock, + PortableTextNestingBlock, } from "./types.js"; /** @@ -34,9 +36,19 @@ export function prosemirrorToPortableText(doc: ProseMirrorDocument): PortableTex return []; } + return convertNodes(doc.content); +} + +/** + * Convert an array of ProseMirror nodes to Portable Text blocks. + * + * Shared by the document root and by container nodes (e.g. `nestingBlock`), + * flattening the block-or-blocks return of `convertNode`. + */ +function convertNodes(nodes: ProseMirrorNode[]): PortableTextBlock[] { const blocks: PortableTextBlock[] = []; - for (const node of doc.content) { + for (const node of nodes) { const converted = convertNode(node); if (converted) { if (Array.isArray(converted)) { @@ -82,6 +94,9 @@ function convertNode(node: ProseMirrorNode): PortableTextBlock | PortableTextBlo case "gallery": return convertGallery(node); + case "nestingBlock": + return convertNestingBlock(node); + case "horizontalRule": return { _type: "break", @@ -297,6 +312,28 @@ function convertHtmlBlock(node: ProseMirrorNode): PortableTextHtmlBlock { }; } +/** + * Convert nesting block (grid/flex container) to Portable Text. + * The container holds `nestingColumn` nodes, each becomes a column object with + * its own `children` blocks. `columns` is derived from the column count. + */ +function convertNestingBlock(node: ProseMirrorNode): PortableTextNestingBlock { + const columns = (node.content ?? []) + .filter((child) => child.type === "nestingColumn") + .map((col) => ({ + _type: "nestingColumn" as const, + _key: generateKey(), + children: convertNodes(col.content ?? []), + })); + return { + _type: "nestingBlock", + _key: generateKey(), + ...normalizeNestingAttrs(node.attrs ?? {}), + columns: Math.max(1, columns.length), + children: columns, + }; +} + /** * Convert image to Portable Text */ diff --git a/packages/core/src/content/converters/types.ts b/packages/core/src/content/converters/types.ts index d1952151ee..e1f64cbb02 100644 --- a/packages/core/src/content/converters/types.ts +++ b/packages/core/src/content/converters/types.ts @@ -125,6 +125,38 @@ export interface PortableTextHtmlBlock { html: string; } +/** + * Nesting block (grid/flex container holding other blocks as children) + */ +export type NestingLayout = "grid" | "flex"; +export type NestingGap = "none" | "sm" | "md" | "lg"; +export type NestingAlign = "start" | "center" | "end" | "stretch"; +/** + * Relative column widths. `equal` sizes every column the same; the others weight the + * first or last column and leave the rest equal, so the value stays meaningful at any + * column count. + */ +export type NestingWidths = "equal" | "wide-first" | "wide-last" | "narrow-first" | "narrow-last"; + +/** A single column (cell) inside a nesting block, holding its own blocks. */ +export interface PortableTextNestingColumn { + _type: "nestingColumn"; + _key: string; + children: PortableTextBlock[]; +} + +export interface PortableTextNestingBlock { + _type: "nestingBlock"; + _key: string; + layout: NestingLayout; + /** Number of columns; kept in sync with `children.length`. */ + columns: number; + gap: NestingGap; + align: NestingAlign; + widths: NestingWidths; + children: PortableTextNestingColumn[]; +} + /** * Unknown/custom block (preserved for plugin compatibility) */ @@ -143,6 +175,7 @@ export type PortableTextBlock = | PortableTextGalleryBlock | PortableTextCodeBlock | PortableTextHtmlBlock + | PortableTextNestingBlock | PortableTextUnknownBlock; /** diff --git a/packages/core/tests/unit/components/nesting-block-remap.test.ts b/packages/core/tests/unit/components/nesting-block-remap.test.ts new file mode 100644 index 0000000000..c37f533ca5 --- /dev/null +++ b/packages/core/tests/unit/components/nesting-block-remap.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; + +import { remapNestingBlocks } from "../../../src/components/portable-text-nesting.js"; + +describe("remapNestingBlocks (render adaptation)", () => { + it("remaps nestingBlock and its columns' `children` to `content`", () => { + const input = [ + { _type: "block", _key: "p1", children: [{ _type: "span", _key: "s", text: "hi" }] }, + { + _type: "nestingBlock", + _key: "n1", + layout: "grid", + columns: 2, + children: [ + { + _type: "nestingColumn", + _key: "c1", + children: [ + { _type: "block", _key: "b1", children: [{ _type: "span", _key: "s1", text: "a" }] }, + ], + }, + ], + }, + ]; + + const out = remapNestingBlocks(input) as Array>; + + // Regular block keeps reserved `children` (spans). + expect("content" in out[0]).toBe(false); + expect(out[0].children).toBeDefined(); + + // Container: children -> content (columns). + expect("children" in out[1]).toBe(false); + const cols = out[1].content as Array>; + expect(cols).toHaveLength(1); + + // Column: children -> content (blocks). + expect(cols[0]._type).toBe("nestingColumn"); + expect("children" in cols[0]).toBe(false); + expect(Array.isArray(cols[0].content)).toBe(true); + }); + + it("wraps legacy loose blocks under a nesting block into columns", () => { + const input = [ + { + _type: "nestingBlock", + _key: "n", + children: [{ _type: "block", _key: "b", children: [] }], + }, + ]; + const out = remapNestingBlocks(input) as Array>; + const cols = out[0].content as Array>; + expect(cols[0]._type).toBe("nestingColumn"); + expect(Array.isArray(cols[0].content)).toBe(true); + }); +}); diff --git a/packages/core/tests/unit/converters/nesting-block-round-trip.test.ts b/packages/core/tests/unit/converters/nesting-block-round-trip.test.ts new file mode 100644 index 0000000000..5697efeb06 --- /dev/null +++ b/packages/core/tests/unit/converters/nesting-block-round-trip.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from "vitest"; + +import { portableTextToProsemirror } from "../../../src/content/converters/portable-text-to-prosemirror.js"; +import { prosemirrorToPortableText } from "../../../src/content/converters/prosemirror-to-portable-text.js"; +import type { + PortableTextNestingBlock, + PortableTextNestingColumn, + PortableTextTextBlock, +} from "../../../src/content/converters/types.js"; + +function paragraph(key: string, text: string): PortableTextTextBlock { + return { + _type: "block", + _key: key, + style: "normal", + children: [{ _type: "span", _key: `${key}-s`, text }], + }; +} + +function column(key: string, ...blocks: PortableTextTextBlock[]): PortableTextNestingColumn { + return { _type: "nestingColumn", _key: key, children: blocks }; +} + +describe("Nesting block round-trip (core converters)", () => { + it("preserves layout attrs and columns through PT → PM → PT", () => { + const nesting: PortableTextNestingBlock = { + _type: "nestingBlock", + _key: "nest001", + layout: "grid", + columns: 2, + gap: "lg", + align: "center", + children: [column("col1", paragraph("c1", "Left")), column("col2", paragraph("c2", "Right"))], + }; + + const pm = portableTextToProsemirror([nesting]); + const node = pm.content[0]; + + expect(node.type).toBe("nestingBlock"); + expect(node.attrs).toMatchObject({ layout: "grid", columns: 2, gap: "lg", align: "center" }); + expect(node.content).toHaveLength(2); + expect(node.content?.[0].type).toBe("nestingColumn"); + expect(node.content?.[0].content?.[0].type).toBe("paragraph"); + + const pt = prosemirrorToPortableText(pm); + const restored = pt[0] as PortableTextNestingBlock; + + expect(restored._type).toBe("nestingBlock"); + expect(restored).toMatchObject({ layout: "grid", columns: 2, gap: "lg", align: "center" }); + expect(restored.children).toHaveLength(2); + expect(restored.children[0]._type).toBe("nestingColumn"); + const firstBlock = restored.children[0].children[0] as PortableTextTextBlock; + expect(firstBlock.children[0].text).toBe("Left"); + }); + + it("derives `columns` from the column count", () => { + const nesting: PortableTextNestingBlock = { + _type: "nestingBlock", + _key: "n", + layout: "grid", + columns: 99, // stale/wrong on purpose + gap: "md", + align: "start", + children: [ + column("a", paragraph("a1", "x")), + column("b", paragraph("b1", "y")), + column("c", paragraph("c1", "z")), + ], + }; + + const restored = prosemirrorToPortableText( + portableTextToProsemirror([nesting]), + )[0] as PortableTextNestingBlock; + expect(restored.columns).toBe(3); + }); + + it("round-trips a nesting block nested inside a column", () => { + const inner: PortableTextNestingBlock = { + _type: "nestingBlock", + _key: "inner", + layout: "flex", + columns: 1, + gap: "sm", + align: "stretch", + children: [column("ic", paragraph("i1", "Deep"))], + }; + const outer: PortableTextNestingBlock = { + _type: "nestingBlock", + _key: "outer", + layout: "grid", + columns: 1, + gap: "md", + align: "start", + children: [ + column("oc", paragraph("o1", "Shallow"), inner as unknown as PortableTextTextBlock), + ], + }; + + const pt = prosemirrorToPortableText(portableTextToProsemirror([outer])); + const restoredOuter = pt[0] as PortableTextNestingBlock; + const outerCol = restoredOuter.children[0]; + const restoredInner = outerCol.children[1] as unknown as PortableTextNestingBlock; + + expect(restoredInner._type).toBe("nestingBlock"); + expect(restoredInner.layout).toBe("flex"); + const deep = restoredInner.children[0].children[0] as PortableTextTextBlock; + expect(deep.children[0].text).toBe("Deep"); + }); + + it("gives an empty container a column so it stays valid", () => { + const empty = { + _type: "nestingBlock", + _key: "nest-empty", + layout: "grid", + columns: 2, + gap: "md", + align: "start", + children: [], + } as unknown as PortableTextNestingBlock; + + const pm = portableTextToProsemirror([empty]); + expect(pm.content[0].content).toHaveLength(1); + expect(pm.content[0].content?.[0].type).toBe("nestingColumn"); + }); +});