Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/nesting-column-widths-and-rows.md
Original file line number Diff line number Diff line change
@@ -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.
113 changes: 113 additions & 0 deletions packages/admin/src/components/PortableTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -372,6 +391,40 @@ function convertPMNode(node: {
};
}

case "nestingBlock": {
const attrs = node.attrs ?? {};
const columnNodes = (node.content || []) as Array<Parameters<typeof convertPMNode>[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<Parameters<typeof convertPMNode>[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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
},
},
];

/**
Expand Down Expand Up @@ -2550,6 +2661,8 @@ export function PortableTextEditor({
ImageExtension,
MarkdownLinkExtension,
PluginBlockExtension,
NestingBlockExtension,
NestingColumnExtension,
Table.configure({
resizable: true,
}),
Expand Down
54 changes: 53 additions & 1 deletion packages/admin/src/components/editor/DragHandleWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ 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";

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;
Expand All @@ -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;
},
};
Comment on lines +59 to +76

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] This oversized doc block for _rowsOnlyRule is addressed to the reviewer (‘that is a choice about units, not a claim that they are wrong’, ‘If you would rather keep your unit…’, ‘Nothing the defaults guard is lost…’). Per AGENTS.md, comments should not justify decisions, narrate rejected alternatives, or address whoever is reviewing. The behavior is already asserted by the unit tests.

Replace it with a short rationale for future readers:

Suggested change
// A stale position between transactions -- treat as top level.
return false;
}
}
/**
* 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.
*/
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;
},
};
/**
* 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 = {


/** 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
*/
Expand Down Expand Up @@ -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 {
Expand All @@ -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],
);
Expand All @@ -146,6 +197,7 @@ export function DragHandleWrapper({ editor, onInsertBlock }: DragHandleWrapperPr
editor={editor}
onNodeChange={handleNodeChange}
computePositionConfig={computePositionConfig}
nested={_nestedDragOptions}
>
<div className="flex translate-y-0.5 items-center gap-0 rtl:flex-row-reverse">
<Button
Expand Down
13 changes: 1 addition & 12 deletions packages/admin/src/components/editor/HtmlBlockNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import { Button } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { BracketsAngle, DotsSixVertical, Trash } from "@phosphor-icons/react";
import { BracketsAngle, Trash } from "@phosphor-icons/react";
import { Node, mergeAttributes } from "@tiptap/core";
import type { NodeViewProps } from "@tiptap/react";
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
Expand Down Expand Up @@ -86,17 +86,6 @@ function HtmlBlockNodeView({ node, updateAttributes, selected, deleteNode }: Nod
data-drag-handle
>
<div className="relative group">
Comment on lines 87 to 88

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] This JSX comment explains why a duplicate grip was removed. That context belongs in the commit message/PR description, not in a code comment. Delete the block.

{/* Drag handle */}
<div
className={cn(
"absolute -start-8 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing",
selected && "opacity-100",
)}
data-drag-handle
>
<DotsSixVertical className="h-5 w-5 text-kumo-subtle/50" />
</div>

{/* Main block */}
<div
className={cn(
Expand Down
Loading
Loading