Skip to content

Commit da43fca

Browse files
committed
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.
1 parent b075b51 commit da43fca

8 files changed

Lines changed: 39 additions & 156 deletions

File tree

packages/admin/src/components/editor/DragHandleWrapper.tsx

Lines changed: 10 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -38,26 +38,18 @@ export function _getDragHandlePlacement(direction: "ltr" | "rtl") {
3838
}
3939

4040
/**
41-
* How far to place the handle from the row it belongs to.
42-
*
43-
* A top level row sits inside the editor's own 64px gutter, so its handle goes just
44-
* outside the row. A row inside a nesting column carries the gutter as its own
45-
* leading padding instead (see NESTING_GUTTER_PX), which is what lets a pointer in
46-
* the gutter still resolve to that row -- so the handle has to come *back* across
47-
* the row's edge to land in that padding rather than out over the column beside it.
41+
* A top level row's handle sits outside it, in the editor's own gutter. A row in a
42+
* column carries that gutter as its own leading padding, so the handle moves back
43+
* across the row's edge to land inside it. Must agree with NESTING_GUTTER_PX.
4844
*/
4945
export function _dragHandleOffset(insideColumn: boolean): number {
5046
return insideColumn ? -(NESTING_GUTTER_PX - 4) : 4;
5147
}
5248

5349
/**
54-
* Is the row at `pos` a child of a nesting column?
55-
*
56-
* Read from the document, because the handle positions against a virtual element
57-
* that carries only a rect with no way back to the node it came from. `pos` is the
58-
* position before the row, so its parent is the column that would hold it -- only
59-
* the immediate parent is checked, because only a direct child of a column is ever
60-
* a drag target.
50+
* Resolved from the document rather than the hovered element, which is virtual and
51+
* carries only a rect. `pos` is the position before the row, so its parent is the
52+
* column that would hold it.
6153
*/
6254
export function _isInsideNestingColumn(editor: Editor, pos: number): boolean {
6355
if (pos < 0) return false;
@@ -70,29 +62,8 @@ export function _isInsideNestingColumn(editor: Editor, pos: number): boolean {
7062
}
7163

7264
/**
73-
* The draggable unit is a row: a child of the document, or a child of a nesting
74-
* column, which is the same thing one level down.
75-
*
76-
* This is what the editor already did. With nested targeting off, TipTap targets
77-
* top level blocks, so a list drags as one block and its items do not drag at all.
78-
* The rule restates that and extends it into columns, which is why behaviour
79-
* outside a container is unchanged by enabling nesting.
80-
*
81-
* It deliberately replaces TipTap's default rules rather than joining them, and
82-
* that is a choice about units, not a claim that they are wrong. Their defaults
83-
* resolve the unit *inside* a structure: for a list, `listItemFirstChild` and
84-
* `listWrapperDeprioritize` between them exclude the paragraph and the wrapper so
85-
* the list item wins. That is right for a plain document and wrong for a page
86-
* built from containers, where a list is one row a page is composed of and its
87-
* items are the row's internals. The two cannot both hold, and picking theirs
88-
* means a list can no longer be moved as a block anywhere in the document.
89-
*
90-
* Nothing the defaults guard is lost. Table internals and inline content are
91-
* never children of the document or of a column, so they are excluded here by
92-
* construction; the tests assert that rather than assuming it.
93-
*
94-
* Columns themselves are never a target either: `selectable: false`, and they are
95-
* added and removed from the container's toolbar.
65+
* Drag unit: direct children of the document or of a nesting column.
66+
* Table internals and inline content are excluded by the schema.
9667
*/
9768
export const _rowsOnlyRule: DragHandleRule = {
9869
id: "emdashRowsOnly",
@@ -104,25 +75,7 @@ export const _rowsOnlyRule: DragHandleRule = {
10475
},
10576
};
10677

107-
/**
108-
* Nested targeting, so a block inside a nesting column can be reordered.
109-
*
110-
* Edge detection is off, and follows from the same choice. It resolves ambiguity by
111-
* pointer position, deducting by depth near a node's edge so the parent wins there.
112-
* With rows as the unit there is no ambiguity left to resolve: a column is never a
113-
* target, so the only candidates are the row and its container, and the container
114-
* has an explicit grab point of its own in its header. Leaving it on only breaks
115-
* things, because the deduction excludes a candidate outright at the depth a row
116-
* sits inside a column, and rows are often short enough that the 12px band covers
117-
* half of one.
118-
*
119-
* The handle's placement depends on this too. The gutter it sits in belongs to the
120-
* row, so a target deeper than a row is measured from the wrong box and the handle
121-
* lands over the content instead of beside it.
122-
*
123-
* Module level so the reference is stable: DragHandle's effect depends on it, and a
124-
* fresh object each render re-registers the plugin.
125-
*/
78+
/** Module level: DragHandle re-registers its plugin if this identity changes. */
12679
export const _nestedDragOptions = {
12780
rules: [_rowsOnlyRule],
12881
defaultRules: false,
@@ -207,8 +160,7 @@ export function DragHandleWrapper({ editor, onInsertBlock }: DragHandleWrapperPr
207160
editor.commands.setMeta("lockDragHandle", false);
208161
}, [editor]);
209162

210-
// Written synchronously here and read by the offset middleware, which the drag
211-
// handle invokes immediately afterwards to reposition.
163+
// Set in onNodeChange, read by the offset middleware that runs straight after it.
212164
const insideColumnRef = React.useRef(false);
213165

214166
// Handle node change from drag handle

packages/admin/src/components/editor/HtmlBlockNode.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,6 @@ function HtmlBlockNodeView({ node, updateAttributes, selected, deleteNode }: Nod
8585
contentEditable={false}
8686
data-drag-handle
8787
>
88-
{/* No grip of its own -- see PluginBlockNode. The editor's drag handle covers
89-
every block, and -start-8 puts this one in a gutter a nesting column does
90-
not have. */}
9188
<div className="relative group">
9289
{/* Main block */}
9390
<div

packages/admin/src/components/editor/NestingBlockNode.tsx

Lines changed: 19 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
*/
1414

1515
import { Button, Select } from "@cloudflare/kumo";
16+
import { plural } from "@lingui/core/macro";
1617
import { useLingui } from "@lingui/react/macro";
1718
import { CaretDown, CaretRight, Plus, Rows, SquaresFour, Trash, X } from "@phosphor-icons/react";
1819
import { Node, mergeAttributes } from "@tiptap/core";
@@ -45,9 +46,8 @@ const MIN_COLUMNS = 1;
4546
const MAX_COLUMNS = 6;
4647

4748
/**
48-
* Width reserved inside every row of a column for its drag handle, wide enough for
49-
* the handle cluster plus a small gap. Exported so the drag handle can offset itself
50-
* into it -- the two numbers have to agree or the handle lands on the row's content.
49+
* Gutter reserved inside each row of a column for its drag handle. Must agree with
50+
* `_dragHandleOffset` or the handle lands on the row's content.
5151
*/
5252
export const NESTING_GUTTER_PX = 52;
5353

@@ -84,24 +84,14 @@ const NESTING_STYLES = `
8484
.nesting-column-content {
8585
min-height: 2.5rem;
8686
}
87-
/*
88-
* The drag handle's gutter belongs to each row, not to the column.
89-
*
90-
* Padding the column instead would put the gutter outside every row's box, so a
91-
* pointer there resolves to the column, which is never a drag target, and the
92-
* container wins instead. In use that reads as the handle jumping away exactly
93-
* as you reach for it. Owning the padding keeps a pointer in the gutter inside
94-
* the row it belongs to.
95-
*/
87+
/* Padded onto each row, not the column, so a pointer in the gutter still
88+
* resolves to the row it belongs to. */
9689
.nesting-column-content > [data-node-view-content-react] > * {
9790
padding-inline-start: var(--nesting-gutter);
9891
}
99-
/*
100-
* A row that indents its own content keeps that indent on top of the gutter.
101-
* Setting the gutter alone replaces it, which pulls list markers and a quote's
102-
* rule back into the gutter and leaves them under the drag handle. The added
103-
* values are the editor's own defaults for these elements.
104-
*/
92+
/* A row with its own indent adds it to the gutter; setting the gutter alone
93+
* replaces it and draws markers under the handle. Added values are the
94+
* editor's defaults for these elements. */
10595
.nesting-column-content > [data-node-view-content-react] > :is(ul, ol) {
10696
padding-inline-start: calc(var(--nesting-gutter) + 1.625rem);
10797
}
@@ -125,9 +115,9 @@ function ensureNestingStyles(): void {
125115
}
126116

127117
/**
128-
* `grid-template-columns` for a width preset. Mirrors `nestingTemplateColumns`
129-
* in @emdash-cms/core so the editor preview matches what the site renders; the
130-
* admin does not depend on core, hence the duplication (as with GAP_TO_CSS).
118+
* `grid-template-columns` for a width preset. Mirrors `nestingTemplateColumns` in
119+
* @emdash-cms/core, which the admin cannot import; they have to stay in step or the
120+
* editor preview and the rendered page disagree.
131121
*/
132122
function templateColumns(widths: NestingWidths, columnCount: number): string {
133123
const n = Math.max(MIN_COLUMNS, Math.min(MAX_COLUMNS, columnCount));
@@ -182,8 +172,7 @@ function NestingColumnNodeView({ editor, getPos, node }: NodeViewProps) {
182172

183173
return (
184174
<NodeViewWrapper
185-
// The drag handle's gutter is padded onto each row rather than onto the column
186-
// -- see NESTING_STYLES for why.
175+
// The handle's gutter is padded onto each row, not here. See NESTING_STYLES.
187176
className="nesting-column group/col relative rounded-md border border-kumo-line p-2"
188177
data-emdash-nesting-column
189178
>
@@ -258,18 +247,12 @@ function NestingBlockNodeView({
258247

259248
const columnCount = node.childCount;
260249

261-
/**
262-
* Collapsed is view state, not content: it is deliberately not a node attribute,
263-
* so folding a container away is never a document change and never lands in a
264-
* revision. It resets on reload, which matches how editors expect a disclosure to
265-
* behave.
266-
*/
250+
// View state, not a node attribute: folding a container is not a document change
251+
// and must not reach a revision.
267252
const [collapsed, setCollapsed] = React.useState(false);
268253

269-
// Ties the disclosure button to the region it shows and hides.
270254
const contentId = React.useId();
271255

272-
// What the container holds, for the summary shown when it is folded away.
273256
const blockCount = React.useMemo(() => {
274257
let total = 0;
275258
node.forEach((column) => {
@@ -301,12 +284,6 @@ function NestingBlockNodeView({
301284
className="flex flex-wrap items-center gap-2 border-b border-kumo-line px-3 py-2"
302285
contentEditable={false}
303286
>
304-
{/* The title is the grab area, the way a window is dragged by its bar. There
305-
was a separate grip here, permanently visible, which nothing else in the
306-
editor has: every other row is dragged from a handle that appears in the
307-
gutter on hover. A container is a row too, but hovering one resolves to
308-
the deepest row inside it, so it still needs somewhere of its own to be
309-
picked up -- its header, rather than an icon that is always on screen. */}
310287
<Button
311288
type="button"
312289
variant="ghost"
@@ -332,22 +309,13 @@ function NestingBlockNodeView({
332309
>
333310
{layout === "grid" ? <SquaresFour className="h-4 w-4" /> : <Rows className="h-4 w-4" />}
334311
<span className="text-sm font-medium">{t`Nesting container`}</span>
335-
{/* Counts sit outside the translated words rather than inside a plural
336-
message, so the summary reads correctly before catalogs are extracted.
337-
A `<Plural>` here renders its own ICU source until then, which is worse
338-
to look at than an unagreed plural. */}
339312
{collapsed && (
340313
<span className="text-kumo-subtle/70 text-sm">
341-
{columnCount} {t`columns`}
342-
{", "}
343-
{blockCount} {t`blocks`}
314+
{t`${plural(columnCount, { one: "# column", other: "# columns" })}, ${plural(blockCount, { one: "# block", other: "# blocks" })}`}
344315
</span>
345316
)}
346317
</div>
347318

348-
{/* Layout controls describe content that is not on screen while collapsed, so
349-
they fold away with it. Delete stays: an unwanted container should not have
350-
to be opened first. */}
351319
<div className="ms-auto flex flex-wrap items-center gap-2">
352320
{!collapsed && (
353321
<>
@@ -374,11 +342,8 @@ function NestingBlockNodeView({
374342
stretch: t`Stretch`,
375343
}}
376344
/>
377-
{/* Equal columns cannot express a content-plus-sidebar page, which is the
378-
most common two-column layout, so the weighted presets exist for that.
379-
Grid only: a flex container sizes its columns from their content, and
380-
the site renderer ignores widths there too, so it is disabled rather
381-
than left to look like it works. */}
345+
{/* Grid only: a flex container sizes columns from their content, and the
346+
site renderer ignores widths there. */}
382347
<Select
383348
label={t`Widths`}
384349
value={widths}
@@ -419,9 +384,8 @@ function NestingBlockNodeView({
419384
</Button>
420385
</div>
421386
</div>
422-
{/* Hidden rather than unmounted: ProseMirror owns this element as the node's
423-
contentDOM, and removing it detaches the container's content from the
424-
document. */}
387+
{/* Hidden, not unmounted: ProseMirror owns this element as the node's
388+
contentDOM. */}
425389
<NodeViewContent
426390
id={contentId}
427391
className={cn("nesting-block-content p-3", collapsed && "hidden")}

packages/admin/src/components/editor/PluginBlockNode.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -264,12 +264,6 @@ function PluginBlockNodeView({
264264
contentEditable={false}
265265
data-drag-handle
266266
>
267-
{/* No grip of its own: the editor's drag handle already offers one for every
268-
block, and the wrapper above carries data-drag-handle, so a third one only
269-
ever sat underneath the other two. It was positioned at -start-8, in the
270-
editor's left gutter, which does not exist inside a nesting column -- there
271-
the grip hung outside the column, over its neighbour or outside the
272-
container entirely. */}
273267
<div className="relative group">
274268
{/* Main block content */}
275269
<div
@@ -278,10 +272,8 @@ function PluginBlockNodeView({
278272
selected ? "border-kumo-brand/50 bg-kumo-tint/30" : "hover:border-kumo-line",
279273
)}
280274
>
281-
{/* Header with icon, label, and actions.
282-
Wraps because the action buttons hold their width even while hidden: in a
283-
narrow container (a sidebar column of a nesting block) they left the label
284-
a few pixels and it broke one character per line. */}
275+
{/* Wraps because the action buttons hold their width while hidden, leaving
276+
the label almost none in a narrow column. */}
285277
<div className="flex flex-wrap items-center gap-3 px-4 py-3">
286278
{/* Icon */}
287279
<div

packages/admin/tests/editor/DragHandleWrapper.interactions.test.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,7 @@ vi.mock("@tiptap/extension-drag-handle-react", () => ({
2121
className="drag-handle"
2222
draggable="true"
2323
data-placement={computePositionConfig.placement}
24-
// The offset is a function so it can differ for a row inside a nesting
25-
// column, which carries the handle's gutter as its own padding. Resolve it
26-
// the way floating-ui would; with nothing hovered it reports the top level
27-
// value. `_dragHandleOffset` is unit tested directly for both cases.
24+
// Resolve the offset the way floating-ui would: it may be a function.
2825
data-offset={(() => {
2926
const option = computePositionConfig.middleware?.find(({ name }) => name === "offset")
3027
?.options?.[0];

packages/admin/tests/editor/DragHandleWrapper.test.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,6 @@ describe("DragHandleWrapper", () => {
6060
});
6161
});
6262

63-
/**
64-
* The draggable unit is a row, and these assert both halves of that claim.
65-
*
66-
* The rule replaces TipTap's default rules rather than joining them, so the tests
67-
* have to show that nothing the defaults guarded is lost: table internals and
68-
* inline content must still be excluded, by the rule rather than by assumption.
69-
* They also pin the behaviour outside a container, which enabling nested targeting
70-
* must not change.
71-
*/
7263
describe("rows are the draggable unit", () => {
7364
// Node views are React renderers and are not needed to exercise the schema.
7465
const Block = NestingBlockExtension.extend({ addNodeView: undefined });
@@ -138,9 +129,6 @@ describe("rows are the draggable unit", () => {
138129
`<div data-emdash-nesting-block><div data-emdash-nesting-column>${inner}</div></div>`;
139130

140131
it("targets a list as one row, not its items, in the body", () => {
141-
// This is what the editor did before nested targeting was enabled. Picking
142-
// TipTap's defaults instead would target the item and make a list impossible
143-
// to move as a block anywhere in the document.
144132
withEditor("<ul><li><p>Top item</p></li></ul>", (editor) => {
145133
expect(targetFor(editor, "Top item")).toBe("bulletList");
146134
});
@@ -167,7 +155,7 @@ describe("rows are the draggable unit", () => {
167155
});
168156
});
169157

170-
it("never targets table internals, which the default rules used to guard", () => {
158+
it("never targets table internals", () => {
171159
withEditor("<table><tbody><tr><td><p>Cell</p></td></tr></tbody></table>", (editor) => {
172160
// The table is the row; everything inside it is the row's structure.
173161
expect(targetFor(editor, "Cell")).toBe("table");
@@ -194,8 +182,6 @@ describe("nested drag options", () => {
194182
const normalized = normalizeNestedOptions(_nestedDragOptions);
195183

196184
it("replaces the default rules rather than joining them", () => {
197-
// Deliberate: the defaults resolve the unit inside a structure, this resolves
198-
// which structures are units. See _rowsOnlyRule for why both cannot hold.
199185
expect(normalized.defaultRules).toBe(false);
200186
expect(normalized.rules.map((rule) => rule.id)).toEqual(["emdashRowsOnly"]);
201187
});

packages/admin/tests/editor/nesting-block-conversion.test.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
/**
2-
* Nesting Block Conversion Tests (admin editor seam)
3-
*
4-
* The admin editor carries its own Portable Text converters, separate from the
5-
* ones in @emdash-cms/core. Core's round-trip tests therefore say nothing about
6-
* what an editor actually saves: a nesting attribute can be handled correctly in
7-
* core, pass its tests, and still be dropped on every real save. That is what
8-
* happened to `widths` -- the container reverted to equal columns on reload.
2+
* The admin editor carries its own Portable Text converters, separate from the ones
3+
* in @emdash-cms/core, and a save goes through these. Core's round-trip tests do not
4+
* cover them: an attribute can round-trip in core and still be dropped on every save.
95
*/
106

117
import { describe, it, expect } from "vitest";

packages/core/src/content/converters/types.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,9 @@ export type NestingLayout = "grid" | "flex";
132132
export type NestingGap = "none" | "sm" | "md" | "lg";
133133
export type NestingAlign = "start" | "center" | "end" | "stretch";
134134
/**
135-
* Relative column widths. `equal` keeps every column the same size; the others
136-
* weight the first or last column, which is what a content-plus-sidebar page needs.
137-
* Ratios are applied to the first/last column and the rest stay equal, so the value
138-
* stays meaningful at any column count.
135+
* Relative column widths. `equal` sizes every column the same; the others weight the
136+
* first or last column and leave the rest equal, so the value stays meaningful at any
137+
* column count.
139138
*/
140139
export type NestingWidths = "equal" | "wide-first" | "wide-last" | "narrow-first" | "narrow-last";
141140

0 commit comments

Comments
 (0)