@drawnagency/primitives 0.1.88 → 0.1.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-75P6HWPN.js → chunk-5BM2Y3NJ.js} +5 -3
- package/dist/{chunk-OEL57NYV.js → chunk-AGWE2LJG.js} +26 -2
- package/dist/{chunk-DRCPDOW3.js → chunk-CZ3YXGK5.js} +59 -0
- package/dist/{chunk-PTVY3Q7P.js → chunk-KBQNJZUX.js} +1 -1
- package/dist/{chunk-XPUBUR64.js → chunk-KMQZYORS.js} +28 -2
- package/dist/{chunk-WA3HFN54.js → chunk-MK7YB2A4.js} +1 -1
- package/dist/{chunk-EO7K45GV.js → chunk-NV4R6VHN.js} +1 -1
- package/dist/{chunk-XDG2K6UT.js → chunk-SIYQ37QY.js} +13 -5
- package/dist/{chunk-NEDOJHHR.js → chunk-XKRQMTE4.js} +11 -2
- package/dist/{chunk-SK6GGTTO.js → chunk-XUIJHYKI.js} +3 -3
- package/dist/{chunk-H2FENAQH.js → chunk-ZLG7S54V.js} +242 -83
- package/dist/components/chat/ChatPanel.d.ts +20 -3
- package/dist/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/components/chat/ChatWidget.d.ts +7 -1
- package/dist/components/chat/ChatWidget.d.ts.map +1 -1
- package/dist/components/chat/chat-history.d.ts +24 -0
- package/dist/components/chat/chat-history.d.ts.map +1 -0
- package/dist/components/editor/AudienceIndicator.d.ts +6 -0
- package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
- package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -1
- package/dist/components/editor/ColSpanControl.d.ts.map +1 -1
- package/dist/components/editor/DragHandle.d.ts +3 -1
- package/dist/components/editor/DragHandle.d.ts.map +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/StatusIndicator.d.ts +7 -0
- package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
- package/dist/components/editor/index.js +100 -78
- package/dist/components/primitives/index.js +5 -5
- package/dist/components/sections/Container/Container.d.ts +3 -2
- package/dist/components/sections/Container/Container.d.ts.map +1 -1
- package/dist/components/sections/Container/ContainerSettingsForm.d.ts +4 -2
- package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -1
- package/dist/components/sections/Container/index.d.ts +8 -2
- package/dist/components/sections/Container/index.d.ts.map +1 -1
- package/dist/components/sections/SectionLayout.d.ts +3 -1
- package/dist/components/sections/SectionLayout.d.ts.map +1 -1
- package/dist/components/sections/ViewRenderer.d.ts +3 -1
- package/dist/components/sections/ViewRenderer.d.ts.map +1 -1
- package/dist/components/sections/all-sections.d.ts +8 -2
- package/dist/components/sections/all-sections.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +5 -5
- package/dist/components/sections/core-sections.d.ts +8 -2
- package/dist/components/sections/core-sections.d.ts.map +1 -1
- package/dist/components/sections/register-schemas.js +8 -8
- package/dist/components/sections/register.js +8 -8
- package/dist/components/shared/Navigation.d.ts +6 -1
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shared/Tooltip.d.ts +8 -1
- package/dist/components/shared/Tooltip.d.ts.map +1 -1
- package/dist/components/shell/AudiencePreviewMenu.d.ts +7 -0
- package/dist/components/shell/AudiencePreviewMenu.d.ts.map +1 -0
- package/dist/components/shell/AuthoringDefaultsMenu.d.ts +10 -0
- package/dist/components/shell/AuthoringDefaultsMenu.d.ts.map +1 -0
- package/dist/components/shell/EditorContext.d.ts +3 -0
- package/dist/components/shell/EditorContext.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryContext.d.ts +3 -1
- package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
- package/dist/hooks/useBuildStatus.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts +5 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.js +17 -5
- package/dist/lib/authoring-defaults.d.ts +17 -0
- package/dist/lib/authoring-defaults.d.ts.map +1 -0
- package/dist/lib/chatbot.d.ts +10 -0
- package/dist/lib/chatbot.d.ts.map +1 -0
- package/dist/lib/container-grid.d.ts +6 -0
- package/dist/lib/container-grid.d.ts.map +1 -1
- package/dist/lib/contrast.d.ts +5 -0
- package/dist/lib/contrast.d.ts.map +1 -1
- package/dist/lib/index.d.ts +2 -1
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +11 -3
- package/dist/lib/migrate-sections-transform.js +1 -1
- package/dist/media/compose-preview.d.ts +23 -0
- package/dist/media/compose-preview.d.ts.map +1 -0
- package/dist/media/index.d.ts +1 -0
- package/dist/media/index.d.ts.map +1 -1
- package/dist/media/index.js +4 -2
- package/dist/media/preview-inputs.d.ts +31 -0
- package/dist/media/preview-inputs.d.ts.map +1 -0
- package/dist/schemas/index.js +4 -2
- package/dist/schemas/media.d.ts +2 -0
- package/dist/schemas/media.d.ts.map +1 -1
- package/dist/schemas/site-config.d.ts +18 -0
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/ChatPanel.tsx +437 -65
- package/src/components/chat/ChatWidget.tsx +90 -32
- package/src/components/chat/chat-history.ts +105 -0
- package/src/components/editor/AudienceIndicator.tsx +50 -37
- package/src/components/editor/ChildBlockWrapper.tsx +8 -7
- package/src/components/editor/ColSpanControl.tsx +14 -11
- package/src/components/editor/DragHandle.tsx +5 -2
- package/src/components/editor/PagesModal.tsx +10 -10
- package/src/components/editor/SectionWrapper.tsx +32 -21
- package/src/components/editor/StatusIndicator.tsx +37 -24
- package/src/components/sections/Container/Container.tsx +185 -43
- package/src/components/sections/Container/ContainerSettingsForm.tsx +64 -24
- package/src/components/sections/Container/index.tsx +45 -20
- package/src/components/sections/SectionLayout.tsx +13 -3
- package/src/components/sections/ViewRenderer.tsx +11 -2
- package/src/components/shared/Navigation.tsx +18 -5
- package/src/components/shared/Tooltip.tsx +18 -2
- package/src/components/shell/AudiencePreviewMenu.tsx +73 -0
- package/src/components/shell/AuthoringDefaultsMenu.tsx +54 -0
- package/src/components/shell/EditorContext.tsx +5 -1
- package/src/components/shell/EditorShell.tsx +130 -13
- package/src/components/shell/MediaLibraryContext.tsx +1 -1
- package/src/components/shell/SiteSettingsChatbot.tsx +49 -1
- package/src/hooks/useBuildStatus.ts +35 -4
- package/src/hooks/useMediaPipeline.ts +7 -3
- package/src/lib/authoring-defaults.ts +42 -0
- package/src/lib/chatbot.ts +30 -0
- package/src/lib/container-grid.ts +14 -0
- package/src/lib/contrast.ts +12 -0
- package/src/lib/index.ts +2 -1
- package/src/lib/migrate-sections-transform.ts +5 -3
- package/src/media/compose-preview.ts +90 -0
- package/src/media/index.ts +1 -0
- package/src/media/preview-inputs.ts +77 -0
- package/src/schemas/media.ts +3 -0
- package/src/schemas/site-config.ts +27 -2
|
@@ -83,6 +83,42 @@ const dotColorClasses: Record<StatusColor, string> = {
|
|
|
83
83
|
|
|
84
84
|
const STATUSES: Status[] = ["draft", "live", "archived"];
|
|
85
85
|
|
|
86
|
+
interface StatusRadioListProps {
|
|
87
|
+
status: Status;
|
|
88
|
+
onChange: (status: Status) => void;
|
|
89
|
+
onClose?: () => void;
|
|
90
|
+
ariaLabel?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function StatusRadioList({ status, onChange, onClose, ariaLabel = "Section status" }: StatusRadioListProps) {
|
|
94
|
+
return (
|
|
95
|
+
<ul role="radiogroup" aria-label={ariaLabel} className="w-full overflow-hidden py-1">
|
|
96
|
+
{STATUSES.map((s) => {
|
|
97
|
+
const checked = s === status;
|
|
98
|
+
return (
|
|
99
|
+
<li key={s}>
|
|
100
|
+
<PopoverItem
|
|
101
|
+
role="radio"
|
|
102
|
+
aria-checked={checked}
|
|
103
|
+
onClick={() => {
|
|
104
|
+
if (s !== status) onChange(s);
|
|
105
|
+
onClose?.();
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
<span
|
|
109
|
+
aria-hidden="true"
|
|
110
|
+
className={cn("h-3 w-3 shrink-0 rounded-full border border-base-200", dotColorClasses[s])}
|
|
111
|
+
/>
|
|
112
|
+
<span className="flex-1 font-medium capitalize text-base-contrast">{s}</span>
|
|
113
|
+
{checked && <Check size={14} strokeWidth={3} className="text-primary" />}
|
|
114
|
+
</PopoverItem>
|
|
115
|
+
</li>
|
|
116
|
+
);
|
|
117
|
+
})}
|
|
118
|
+
</ul>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
86
122
|
interface StatusIndicatorProps {
|
|
87
123
|
mainStatus: string | null;
|
|
88
124
|
savedStatus: string;
|
|
@@ -122,30 +158,7 @@ export function StatusIndicator({
|
|
|
122
158
|
buttonClassName="text-base-contrast-light"
|
|
123
159
|
disabled={disabled}
|
|
124
160
|
clickContent={(onClose) => (
|
|
125
|
-
<
|
|
126
|
-
{STATUSES.map((s) => {
|
|
127
|
-
const checked = s === status;
|
|
128
|
-
return (
|
|
129
|
-
<li key={s}>
|
|
130
|
-
<PopoverItem
|
|
131
|
-
role="radio"
|
|
132
|
-
aria-checked={checked}
|
|
133
|
-
onClick={() => {
|
|
134
|
-
if (s !== status) onChange(s);
|
|
135
|
-
onClose();
|
|
136
|
-
}}
|
|
137
|
-
>
|
|
138
|
-
<span
|
|
139
|
-
aria-hidden="true"
|
|
140
|
-
className={cn("h-3 w-3 shrink-0 rounded-full border border-base-200", dotColorClasses[s])}
|
|
141
|
-
/>
|
|
142
|
-
<span className="flex-1 font-medium capitalize text-base-contrast">{s}</span>
|
|
143
|
-
{checked && <Check size={14} strokeWidth={3} className="text-primary" />}
|
|
144
|
-
</PopoverItem>
|
|
145
|
-
</li>
|
|
146
|
-
);
|
|
147
|
-
})}
|
|
148
|
-
</ul>
|
|
161
|
+
<StatusRadioList status={status} onChange={onChange} onClose={onClose} />
|
|
149
162
|
)}
|
|
150
163
|
/>
|
|
151
164
|
);
|
|
@@ -2,7 +2,7 @@ import { type ComponentType, type ReactNode } from "react";
|
|
|
2
2
|
import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
|
3
3
|
import { getSection } from "../../../lib/registry";
|
|
4
4
|
import { cn } from "../../../lib/cn";
|
|
5
|
-
import { containerGridClass, colSpanClass, spacerHiddenClass } from "../../../lib/container-grid";
|
|
5
|
+
import { containerGridClass, colSpanClass, spacerHiddenClass, masonryColumnsClass } from "../../../lib/container-grid";
|
|
6
6
|
import { ChildBlockWrapper } from "../../editor/ChildBlockWrapper";
|
|
7
7
|
import { SettingsForm, type SettingsFormResult } from "../../editor/SettingsForm";
|
|
8
8
|
import { useBlockDnd, useBlockDropZone } from "../../../hooks/useBlockDnd";
|
|
@@ -19,6 +19,7 @@ import type { Section } from "../../../schemas/sections";
|
|
|
19
19
|
// Stable allowedEdges arrays — must not be re-created per render (the dnd hooks
|
|
20
20
|
// re-subscribe whenever this array identity changes).
|
|
21
21
|
const ROW_EDGES: Edge[] = ["left", "right"];
|
|
22
|
+
// Masonry stacks items vertically (CSS multi-column), so its drag edges are top/bottom.
|
|
22
23
|
const COL_EDGES: Edge[] = ["top", "bottom"];
|
|
23
24
|
|
|
24
25
|
type ChildComponent = ComponentType<{
|
|
@@ -34,7 +35,6 @@ interface ContainerChildProps {
|
|
|
34
35
|
index: number;
|
|
35
36
|
child: Section;
|
|
36
37
|
columns: number;
|
|
37
|
-
flow: string;
|
|
38
38
|
cellClass: string | undefined;
|
|
39
39
|
label: string;
|
|
40
40
|
hasSettings: boolean;
|
|
@@ -49,6 +49,9 @@ interface ContainerChildProps {
|
|
|
49
49
|
onOpenSettings?: () => void;
|
|
50
50
|
onChildContentChange?: (content: unknown) => void;
|
|
51
51
|
openModal?: (title: string, content: ReactNode) => void;
|
|
52
|
+
/** Drag/drop hitbox edges: ["left","right"] in grid (row flow), ["top","bottom"] in masonry
|
|
53
|
+
* (CSS multi-column stacks items vertically). Defaults to ROW_EDGES. */
|
|
54
|
+
allowedEdges?: Edge[];
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
/**
|
|
@@ -61,7 +64,6 @@ function ContainerChild({
|
|
|
61
64
|
index,
|
|
62
65
|
child,
|
|
63
66
|
columns,
|
|
64
|
-
flow,
|
|
65
67
|
cellClass,
|
|
66
68
|
label,
|
|
67
69
|
hasSettings,
|
|
@@ -75,6 +77,7 @@ function ContainerChild({
|
|
|
75
77
|
onOpenSettings,
|
|
76
78
|
onChildContentChange,
|
|
77
79
|
openModal,
|
|
80
|
+
allowedEdges = ROW_EDGES,
|
|
78
81
|
}: ContainerChildProps) {
|
|
79
82
|
const isContainer = Array.isArray((child.content as { children?: unknown })?.children);
|
|
80
83
|
const { dragRef, handleRef, closestEdge, isDragging } = useBlockDnd({
|
|
@@ -82,7 +85,7 @@ function ContainerChild({
|
|
|
82
85
|
containerId,
|
|
83
86
|
index,
|
|
84
87
|
isContainer,
|
|
85
|
-
allowedEdges
|
|
88
|
+
allowedEdges,
|
|
86
89
|
enabled: true,
|
|
87
90
|
});
|
|
88
91
|
|
|
@@ -120,8 +123,9 @@ function ContainerChild({
|
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
interface ContainerContent {
|
|
126
|
+
layout: "grid" | "masonry";
|
|
123
127
|
columns: number;
|
|
124
|
-
|
|
128
|
+
equalizeRowHeight: boolean;
|
|
125
129
|
childDefaults?: Record<string, unknown>;
|
|
126
130
|
children: Section[];
|
|
127
131
|
}
|
|
@@ -175,6 +179,44 @@ function EmptyColumnCell({
|
|
|
175
179
|
);
|
|
176
180
|
}
|
|
177
181
|
|
|
182
|
+
/**
|
|
183
|
+
* A masonry insertion drop target — not draggable itself, just a landing zone so new
|
|
184
|
+
* blocks can be dragged in and an empty masonry container is seedable. Unlike
|
|
185
|
+
* `EmptyColumnCell` (grid column-placement, `toColumn`), this OMITS `toColumn` so the
|
|
186
|
+
* central monitor uses the edge→`resolveDropIndex` insert path (masonry has no columns
|
|
187
|
+
* to target — it's a linear reorder like any other block list).
|
|
188
|
+
*/
|
|
189
|
+
function MasonryDropZone({
|
|
190
|
+
containerId,
|
|
191
|
+
index,
|
|
192
|
+
testId,
|
|
193
|
+
}: {
|
|
194
|
+
containerId: string;
|
|
195
|
+
index: number;
|
|
196
|
+
testId: string;
|
|
197
|
+
}) {
|
|
198
|
+
const { dropRef, closestEdge } = useBlockDropZone({
|
|
199
|
+
containerId,
|
|
200
|
+
index,
|
|
201
|
+
allowedEdges: COL_EDGES,
|
|
202
|
+
enabled: true,
|
|
203
|
+
});
|
|
204
|
+
const active = closestEdge !== null;
|
|
205
|
+
return (
|
|
206
|
+
<div
|
|
207
|
+
ref={dropRef}
|
|
208
|
+
data-masonry-dropzone
|
|
209
|
+
data-testid={testId}
|
|
210
|
+
className={cn(
|
|
211
|
+
"mb-8 flex min-h-20 items-center justify-center rounded-md border-2 border-dashed break-inside-avoid text-xs text-base-contrast-light/50",
|
|
212
|
+
active ? "border-primary" : "border-base-200",
|
|
213
|
+
)}
|
|
214
|
+
>
|
|
215
|
+
{index === 0 ? "Empty container — drag items here or use the + button" : "Drop here to add"}
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
178
220
|
function newId(): string {
|
|
179
221
|
return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
|
180
222
|
? crypto.randomUUID()
|
|
@@ -182,7 +224,7 @@ function newId(): string {
|
|
|
182
224
|
}
|
|
183
225
|
|
|
184
226
|
export function Container({ content, isEditMode, onChange, openModal }: ContainerProps) {
|
|
185
|
-
const { columns,
|
|
227
|
+
const { layout, columns, equalizeRowHeight, childDefaults, children } = content.content;
|
|
186
228
|
|
|
187
229
|
const editable = isEditMode && !!onChange;
|
|
188
230
|
|
|
@@ -218,8 +260,138 @@ export function Container({ content, isEditMode, onChange, openModal }: Containe
|
|
|
218
260
|
"grid",
|
|
219
261
|
"gap-8",
|
|
220
262
|
containerGridClass[columns] ?? "grid-cols-1",
|
|
221
|
-
|
|
263
|
+
equalizeRowHeight && "items-stretch",
|
|
222
264
|
);
|
|
265
|
+
const cellStretch = equalizeRowHeight ? "h-full [&>*]:h-full" : undefined;
|
|
266
|
+
|
|
267
|
+
// Per-child settings-modal opener, shared by the grid and masonry edit paths.
|
|
268
|
+
function buildOnOpenSettings(child: Section, index: number): (() => void) | undefined {
|
|
269
|
+
const def = getSection(child.type);
|
|
270
|
+
if (!def) return undefined;
|
|
271
|
+
const hasSettings = !!(def.settings || def.settingsForm) && !!openModal;
|
|
272
|
+
if (!hasSettings || !openModal) return undefined;
|
|
273
|
+
const childOptions = "options" in child ? (child.options as Record<string, unknown>) : undefined;
|
|
274
|
+
return () => {
|
|
275
|
+
const childOpts = childOptions ?? {};
|
|
276
|
+
const childContent = (child.content ?? {}) as Record<string, unknown>;
|
|
277
|
+
// Seed the form from merged content+options so content-target fields (e.g.
|
|
278
|
+
// media.link, button.link/download) round-trip instead of re-emitting at their
|
|
279
|
+
// default and clobbering the stored value. Mirrors EditorShell's top-level seed.
|
|
280
|
+
const seed = { ...childContent, ...childOpts };
|
|
281
|
+
const applyResult = (result: SettingsFormResult) =>
|
|
282
|
+
updateChild(index, {
|
|
283
|
+
...child,
|
|
284
|
+
content: { ...(child.content as object), ...result.content },
|
|
285
|
+
options: { ...childOpts, ...result.options },
|
|
286
|
+
});
|
|
287
|
+
if (def.settingsForm) {
|
|
288
|
+
const CustomForm = def.settingsForm;
|
|
289
|
+
openModal(`${def.label} Settings`, <CustomForm {...seed} onChange={applyResult} />);
|
|
290
|
+
} else if (def.settings) {
|
|
291
|
+
openModal(
|
|
292
|
+
`${def.label} Settings`,
|
|
293
|
+
<SettingsForm
|
|
294
|
+
schema={def.settings}
|
|
295
|
+
values={seed}
|
|
296
|
+
onChange={applyResult}
|
|
297
|
+
tabs={def.settingsTabs}
|
|
298
|
+
/>,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (layout === "masonry") {
|
|
305
|
+
// Spacers are grid-only bookkeeping (empty-column tracking); masonry has no columns
|
|
306
|
+
// to pad, so exclude them from the layout entirely. `laidOut` carries the TRUE array
|
|
307
|
+
// index alongside each surviving child, so keys/data-child-index/DnD/delete/duplicate
|
|
308
|
+
// all still operate on the real `children` position, not the filtered position.
|
|
309
|
+
const laidOut = children
|
|
310
|
+
.map((child, index) => ({ child, index }))
|
|
311
|
+
.filter(({ child }) => !isSpacer(child));
|
|
312
|
+
const masonryClass = cn("gap-8", masonryColumnsClass[columns] ?? "columns-1");
|
|
313
|
+
|
|
314
|
+
// View mode (zero-JS): real DOM flow via CSS multi-column — items lay out at
|
|
315
|
+
// their natural height, no aspect-ratio math, no absolute positioning.
|
|
316
|
+
if (!editable) {
|
|
317
|
+
if (laidOut.length === 0) return null;
|
|
318
|
+
return (
|
|
319
|
+
<div className="@container">
|
|
320
|
+
<div data-masonry className={masonryClass}>
|
|
321
|
+
{laidOut.map(({ child, index }) => {
|
|
322
|
+
const def = getSection(child.type);
|
|
323
|
+
if (!def) return null;
|
|
324
|
+
const Child = def.component;
|
|
325
|
+
const childOptions =
|
|
326
|
+
"options" in child ? (child.options as Record<string, unknown>) : undefined;
|
|
327
|
+
const mergedOptions = { ...(childDefaults ?? {}), ...(childOptions ?? {}) };
|
|
328
|
+
return (
|
|
329
|
+
<div
|
|
330
|
+
key={child.id ?? index}
|
|
331
|
+
data-masonry-box
|
|
332
|
+
data-child-index={index}
|
|
333
|
+
className="mb-8 break-inside-avoid"
|
|
334
|
+
>
|
|
335
|
+
<Child content={child} options={mergedOptions} isEditMode={isEditMode} />
|
|
336
|
+
</div>
|
|
337
|
+
);
|
|
338
|
+
})}
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
// Editable masonry: same multi-column flow (WYSIWYG), each child cell wrapped in
|
|
344
|
+
// ContainerChild for drag/reorder/delete/duplicate/settings with VERTICAL drag
|
|
345
|
+
// edges (multi-column stacks items vertically), plus insertion drop zones.
|
|
346
|
+
if (laidOut.length === 0) {
|
|
347
|
+
return (
|
|
348
|
+
<div className="@container">
|
|
349
|
+
<div data-masonry className={masonryClass}>
|
|
350
|
+
<MasonryDropZone containerId={container.id} index={0} testId="masonry-empty" />
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
return (
|
|
356
|
+
<div className="@container">
|
|
357
|
+
<div data-masonry className={masonryClass}>
|
|
358
|
+
{laidOut.map(({ child, index }, k) => {
|
|
359
|
+
const def = getSection(child.type);
|
|
360
|
+
if (!def) return null;
|
|
361
|
+
const childOptions =
|
|
362
|
+
"options" in child ? (child.options as Record<string, unknown>) : undefined;
|
|
363
|
+
const mergedOptions = { ...(childDefaults ?? {}), ...(childOptions ?? {}) };
|
|
364
|
+
const hasSettings = !!(def.settings || def.settingsForm) && !!openModal;
|
|
365
|
+
return (
|
|
366
|
+
<div key={child.id ?? index} data-masonry-box className="mb-8 break-inside-avoid">
|
|
367
|
+
<ContainerChild
|
|
368
|
+
containerId={container.id}
|
|
369
|
+
index={index}
|
|
370
|
+
child={child}
|
|
371
|
+
columns={1 /* hides ColSpanControl (its columns<=1 guard) */}
|
|
372
|
+
cellClass={undefined /* mb-8 break-inside-avoid lives on the outer cell above */}
|
|
373
|
+
label={def.label}
|
|
374
|
+
hasSettings={hasSettings}
|
|
375
|
+
mergedOptions={mergedOptions}
|
|
376
|
+
isEditMode={isEditMode}
|
|
377
|
+
clearSectionChrome={k === 0 /* first laid-out cell (render order), not array index */}
|
|
378
|
+
ChildComp={def.component as ChildComponent}
|
|
379
|
+
onDelete={() => emit(removeChildAt(container, index).container)}
|
|
380
|
+
onDuplicate={() => emit(duplicateChildAt(container, index, newId))}
|
|
381
|
+
onColSpanChange={() => {}}
|
|
382
|
+
onOpenSettings={buildOnOpenSettings(child, index)}
|
|
383
|
+
onChildContentChange={(c: unknown) => updateChild(index, c)}
|
|
384
|
+
openModal={openModal}
|
|
385
|
+
allowedEdges={COL_EDGES}
|
|
386
|
+
/>
|
|
387
|
+
</div>
|
|
388
|
+
);
|
|
389
|
+
})}
|
|
390
|
+
<MasonryDropZone containerId={container.id} index={children.length} testId="masonry-append" />
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
223
395
|
|
|
224
396
|
// --- View mode: zero-JS output, identical to the Plan 3 viewer. ---
|
|
225
397
|
if (!editable) {
|
|
@@ -237,6 +409,7 @@ export function Container({ content, isEditMode, onChange, openModal }: Containe
|
|
|
237
409
|
const cellClass = cn(
|
|
238
410
|
colSpanClass[columns]?.[Math.min(span, columns)] || undefined,
|
|
239
411
|
child.type === "spacer" && spacerHiddenClass[columns],
|
|
412
|
+
cellStretch,
|
|
240
413
|
);
|
|
241
414
|
return (
|
|
242
415
|
<div key={child.id ?? index} className={cellClass}>
|
|
@@ -281,43 +454,13 @@ export function Container({ content, isEditMode, onChange, openModal }: Containe
|
|
|
281
454
|
const span = child.layout?.colSpan ?? 1;
|
|
282
455
|
const childOptions = "options" in child ? (child.options as Record<string, unknown>) : undefined;
|
|
283
456
|
const mergedOptions = { ...(childDefaults ?? {}), ...(childOptions ?? {}) };
|
|
284
|
-
const cellClass =
|
|
457
|
+
const cellClass = cn(
|
|
458
|
+
colSpanClass[columns]?.[Math.min(span, columns)] || undefined,
|
|
459
|
+
cellStretch,
|
|
460
|
+
);
|
|
285
461
|
|
|
286
462
|
const hasSettings = !!(def.settings || def.settingsForm) && !!openModal;
|
|
287
|
-
const onOpenSettings =
|
|
288
|
-
hasSettings && openModal
|
|
289
|
-
? () => {
|
|
290
|
-
const childOpts = childOptions ?? {};
|
|
291
|
-
const childContent = (child.content ?? {}) as Record<string, unknown>;
|
|
292
|
-
// Seed the form from merged content+options so content-target fields (e.g.
|
|
293
|
-
// media.link, button.link/download) round-trip instead of re-emitting at their
|
|
294
|
-
// default and clobbering the stored value. Mirrors EditorShell's top-level seed.
|
|
295
|
-
const seed = { ...childContent, ...childOpts };
|
|
296
|
-
const applyResult = (result: SettingsFormResult) =>
|
|
297
|
-
updateChild(index, {
|
|
298
|
-
...child,
|
|
299
|
-
content: { ...(child.content as object), ...result.content },
|
|
300
|
-
options: { ...childOpts, ...result.options },
|
|
301
|
-
});
|
|
302
|
-
if (def.settingsForm) {
|
|
303
|
-
const CustomForm = def.settingsForm;
|
|
304
|
-
openModal(
|
|
305
|
-
`${def.label} Settings`,
|
|
306
|
-
<CustomForm {...seed} onChange={applyResult} />,
|
|
307
|
-
);
|
|
308
|
-
} else if (def.settings) {
|
|
309
|
-
openModal(
|
|
310
|
-
`${def.label} Settings`,
|
|
311
|
-
<SettingsForm
|
|
312
|
-
schema={def.settings}
|
|
313
|
-
values={seed}
|
|
314
|
-
onChange={applyResult}
|
|
315
|
-
tabs={def.settingsTabs}
|
|
316
|
-
/>,
|
|
317
|
-
);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
: undefined;
|
|
463
|
+
const onOpenSettings = buildOnOpenSettings(child, index);
|
|
321
464
|
|
|
322
465
|
return (
|
|
323
466
|
<ContainerChild
|
|
@@ -326,7 +469,6 @@ export function Container({ content, isEditMode, onChange, openModal }: Containe
|
|
|
326
469
|
index={index}
|
|
327
470
|
child={child}
|
|
328
471
|
columns={columns}
|
|
329
|
-
flow={flow}
|
|
330
472
|
cellClass={cellClass}
|
|
331
473
|
label={def.label}
|
|
332
474
|
hasSettings={hasSettings}
|
|
@@ -9,14 +9,23 @@ interface ChildLike {
|
|
|
9
9
|
options?: Record<string, unknown>;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
type ContainerLayout = "grid" | "masonry";
|
|
13
|
+
|
|
12
14
|
interface ContainerSettingsFormProps {
|
|
13
15
|
columns?: number;
|
|
14
|
-
|
|
16
|
+
layout?: ContainerLayout;
|
|
17
|
+
equalizeRowHeight?: boolean;
|
|
15
18
|
childDefaults?: Record<string, unknown>;
|
|
16
19
|
children?: ChildLike[]; // the container's child blocks (DATA, not React nodes)
|
|
17
20
|
onChange: (result: { content: Record<string, unknown>; options: Record<string, unknown> }) => void;
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
interface ContainerLayoutState {
|
|
24
|
+
layout: ContainerLayout;
|
|
25
|
+
columns: number;
|
|
26
|
+
equalizeRowHeight: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
/** Union of inheritable option fields across the child types currently present. */
|
|
21
30
|
function inheritableSchema(children: ChildLike[]): SettingsSchema {
|
|
22
31
|
const out: SettingsSchema = {};
|
|
@@ -33,46 +42,64 @@ function inheritableSchema(children: ChildLike[]): SettingsSchema {
|
|
|
33
42
|
|
|
34
43
|
export function ContainerSettingsForm({
|
|
35
44
|
columns = 1,
|
|
36
|
-
|
|
45
|
+
layout: layoutProp = "grid",
|
|
46
|
+
equalizeRowHeight = false,
|
|
37
47
|
childDefaults = {},
|
|
38
48
|
children = [],
|
|
39
49
|
onChange,
|
|
40
50
|
}: ContainerSettingsFormProps) {
|
|
41
|
-
const [layout, setLayout] = useState({
|
|
51
|
+
const [layout, setLayout] = useState<ContainerLayoutState>({
|
|
52
|
+
layout: layoutProp,
|
|
53
|
+
columns,
|
|
54
|
+
equalizeRowHeight,
|
|
55
|
+
});
|
|
42
56
|
const [defaults, setDefaults] = useState<Record<string, unknown>>(childDefaults);
|
|
43
57
|
|
|
44
58
|
const childSchema = inheritableSchema(children);
|
|
45
59
|
const hasChildDefaults = Object.keys(childSchema).length > 0;
|
|
46
60
|
|
|
47
|
-
function emit(
|
|
48
|
-
nextLayout: { columns: number; flow: string },
|
|
49
|
-
nextDefaults: Record<string, unknown>,
|
|
50
|
-
) {
|
|
61
|
+
function emit(nextLayout: ContainerLayoutState, nextDefaults: Record<string, unknown>) {
|
|
51
62
|
onChange({ content: { ...nextLayout, childDefaults: nextDefaults }, options: {} });
|
|
52
63
|
}
|
|
53
64
|
|
|
54
65
|
const layoutSchema: SettingsSchema = {
|
|
66
|
+
layout: {
|
|
67
|
+
type: "select",
|
|
68
|
+
label: "Layout",
|
|
69
|
+
default: layout.layout,
|
|
70
|
+
target: "content",
|
|
71
|
+
options: [
|
|
72
|
+
{ label: "Grid", value: "grid" },
|
|
73
|
+
{ label: "Masonry (image gallery)", value: "masonry" },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
55
76
|
columns: {
|
|
56
77
|
type: "select",
|
|
57
78
|
label: "Columns",
|
|
58
79
|
default: String(layout.columns),
|
|
59
80
|
target: "content",
|
|
60
81
|
coerce: "number",
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
options: [
|
|
72
|
-
{ label: "Rows (wrap across)", value: "row" },
|
|
73
|
-
{ label: "Columns (fill down)", value: "column" },
|
|
74
|
-
],
|
|
82
|
+
// Masonry's CSS multi-column flow needs at least 2 columns to read as a
|
|
83
|
+
// gallery (1 column would just be a plain stack, same as grid), so floor
|
|
84
|
+
// the options at 2 there; grid still allows 1 (a single-column stack).
|
|
85
|
+
options: Array.from(
|
|
86
|
+
{ length: layout.layout === "masonry" ? MAX_CONTAINER_COLUMNS - 1 : MAX_CONTAINER_COLUMNS },
|
|
87
|
+
(_, i) => {
|
|
88
|
+
const n = layout.layout === "masonry" ? i + 2 : i + 1;
|
|
89
|
+
return { label: String(n), value: String(n) };
|
|
90
|
+
},
|
|
91
|
+
),
|
|
75
92
|
},
|
|
93
|
+
...(layout.layout === "grid"
|
|
94
|
+
? {
|
|
95
|
+
equalizeRowHeight: {
|
|
96
|
+
type: "checkbox" as const,
|
|
97
|
+
label: "Equalize row height",
|
|
98
|
+
default: layout.equalizeRowHeight,
|
|
99
|
+
target: "content" as const,
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
: {}),
|
|
76
103
|
};
|
|
77
104
|
|
|
78
105
|
return (
|
|
@@ -80,13 +107,26 @@ export function ContainerSettingsForm({
|
|
|
80
107
|
<fieldset className="flex flex-col gap-4">
|
|
81
108
|
<legend className="mb-2 text-sm font-semibold text-base-contrast">Layout</legend>
|
|
82
109
|
<SettingsForm
|
|
110
|
+
// Remount on layout-mode switch: grid contributes an extra conditional
|
|
111
|
+
// field (equalizeRowHeight) that masonry doesn't, and SettingsForm only
|
|
112
|
+
// resolves its initial values on mount.
|
|
113
|
+
key={layout.layout}
|
|
83
114
|
schema={layoutSchema}
|
|
84
|
-
values={{
|
|
115
|
+
values={{
|
|
116
|
+
layout: layout.layout,
|
|
117
|
+
columns: String(layout.columns),
|
|
118
|
+
equalizeRowHeight: layout.equalizeRowHeight,
|
|
119
|
+
}}
|
|
85
120
|
onChange={(result) => {
|
|
86
|
-
const next = {
|
|
121
|
+
const next: ContainerLayoutState = {
|
|
122
|
+
layout: (result.content.layout as ContainerLayout | undefined) ?? layout.layout,
|
|
87
123
|
columns: Number(result.content.columns ?? layout.columns),
|
|
88
|
-
|
|
124
|
+
equalizeRowHeight: (result.content.equalizeRowHeight as boolean | undefined) ?? layout.equalizeRowHeight,
|
|
89
125
|
};
|
|
126
|
+
// Masonry's multi-column flow needs ≥2 columns (1 would render a plain
|
|
127
|
+
// stack while the floored Columns select shows "2") — keep state in sync
|
|
128
|
+
// with the select when flipping to masonry from a single-column grid.
|
|
129
|
+
if (next.layout === "masonry" && next.columns < 2) next.columns = 2;
|
|
90
130
|
setLayout(next);
|
|
91
131
|
emit(next, defaults);
|
|
92
132
|
}}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineSection } from "../../../lib/registry";
|
|
1
|
+
import { defineSection, getSection } from "../../../lib/registry";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { LayoutTemplate } from "lucide-react";
|
|
4
4
|
import { getSectionSchema } from "../../../schemas/sections";
|
|
@@ -11,19 +11,27 @@ const schema = z.object({
|
|
|
11
11
|
type: z.literal("container"),
|
|
12
12
|
content: z
|
|
13
13
|
.object({
|
|
14
|
+
layout: z
|
|
15
|
+
.enum(["grid", "masonry"])
|
|
16
|
+
.describe(
|
|
17
|
+
"Layout engine: 'grid' arranges children in fixed columns with per-child column spans; 'masonry' flows children through a CSS multi-column gallery at their natural height (no column spans).",
|
|
18
|
+
)
|
|
19
|
+
.default("grid"),
|
|
14
20
|
columns: z
|
|
15
21
|
.number()
|
|
16
22
|
.int()
|
|
17
23
|
.min(1)
|
|
18
24
|
.max(MAX_CONTAINER_COLUMNS)
|
|
19
|
-
.describe(
|
|
25
|
+
.describe(
|
|
26
|
+
"Grid: number of columns children flow across. Masonry: number of CSS multi-columns children flow through.",
|
|
27
|
+
)
|
|
20
28
|
.default(1),
|
|
21
|
-
|
|
22
|
-
.
|
|
29
|
+
equalizeRowHeight: z
|
|
30
|
+
.boolean()
|
|
23
31
|
.describe(
|
|
24
|
-
"
|
|
32
|
+
"Grid only: stretch every child to the height of the tallest child in its row (best-effort — applies to children whose root element can stretch, e.g. media/cards). Ignored in masonry.",
|
|
25
33
|
)
|
|
26
|
-
.default(
|
|
34
|
+
.default(false),
|
|
27
35
|
childDefaults: z
|
|
28
36
|
.record(z.string(), z.unknown())
|
|
29
37
|
.describe("Optional default option values applied to child sections (inheritable settings only).")
|
|
@@ -32,18 +40,29 @@ const schema = z.object({
|
|
|
32
40
|
// time, by which point every section schema (incl. container) is registered.
|
|
33
41
|
children: z.array(z.lazy(() => getSectionSchema())).default([]),
|
|
34
42
|
})
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
// Grid: clamp each child's colSpan to the available columns (idempotent, non-rejecting).
|
|
44
|
+
// Masonry: strip the per-child layout envelope entirely (masonry has no column spans).
|
|
45
|
+
.transform((c) => {
|
|
46
|
+
if (c.layout === "masonry") {
|
|
47
|
+
return {
|
|
48
|
+
...c,
|
|
49
|
+
children: c.children.map((child) => {
|
|
50
|
+
if (child.layout === undefined) return child;
|
|
51
|
+
const { layout: _drop, ...rest } = child;
|
|
52
|
+
return rest;
|
|
53
|
+
}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
...c,
|
|
58
|
+
children: c.children.map((child) =>
|
|
59
|
+
(child as { layout?: { colSpan?: number } }).layout?.colSpan &&
|
|
60
|
+
(child as { layout?: { colSpan?: number } }).layout!.colSpan! > c.columns
|
|
61
|
+
? { ...child, layout: { ...(child as { layout?: { colSpan?: number } }).layout, colSpan: c.columns } }
|
|
62
|
+
: child,
|
|
63
|
+
),
|
|
64
|
+
};
|
|
65
|
+
}),
|
|
47
66
|
});
|
|
48
67
|
|
|
49
68
|
export default defineSection({
|
|
@@ -52,14 +71,20 @@ export default defineSection({
|
|
|
52
71
|
icon: <LayoutTemplate size={18} />,
|
|
53
72
|
schema,
|
|
54
73
|
authoringNote:
|
|
55
|
-
"A layout wrapper
|
|
74
|
+
"A layout wrapper for child sections. 'grid' arranges children into a fixed number of columns (each child can span multiple columns) with an optional equal-row-height toggle; 'masonry' flows children through a CSS multi-column gallery at their natural height (no column spans). Set layout, columns, and add children.",
|
|
56
75
|
component: ({ content, onChange, isEditMode, openModal }) => (
|
|
57
76
|
<Container content={content as never} isEditMode={!!isEditMode} onChange={onChange as never} openModal={openModal} />
|
|
58
77
|
),
|
|
59
78
|
defaults: () => ({
|
|
60
79
|
type: "container" as const,
|
|
61
|
-
content: {
|
|
80
|
+
content: { layout: "grid" as const, columns: 1, equalizeRowHeight: false, children: [] },
|
|
62
81
|
}),
|
|
63
82
|
getLabel: (content) => `Container (${pluralize(content.content.children.length, "item")})`,
|
|
83
|
+
// Aggregate child thumbnails so the reorder-sections modal shows a stacked media
|
|
84
|
+
// preview for a container (restores the retired media_grid preview). Each child is a
|
|
85
|
+
// full section block; delegate to its own type's getThumbnails. The modal caps the
|
|
86
|
+
// stack at 3 + a "+N" badge and resolves each imageId → src itself.
|
|
87
|
+
getThumbnails: (content) =>
|
|
88
|
+
content.content.children.flatMap((child) => getSection(child.type)?.getThumbnails?.(child) ?? []),
|
|
64
89
|
settingsForm: ContainerSettingsForm,
|
|
65
90
|
});
|
|
@@ -15,14 +15,24 @@ interface Props {
|
|
|
15
15
|
type: string;
|
|
16
16
|
status?: string;
|
|
17
17
|
dimNonPublished?: boolean;
|
|
18
|
+
/** Unconditional dim, e.g. audience preview: the selected audience can't see this section. */
|
|
19
|
+
dimmed?: boolean;
|
|
18
20
|
children: ReactNode;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
export function SectionLayout({ type, status, dimNonPublished, children }: Props) {
|
|
22
|
-
const
|
|
23
|
+
export function SectionLayout({ type, status, dimNonPublished, dimmed, children }: Props) {
|
|
24
|
+
const statusDimmed = dimNonPublished && status && status !== "live";
|
|
23
25
|
|
|
24
26
|
return (
|
|
25
|
-
<div
|
|
27
|
+
<div
|
|
28
|
+
className={cn(
|
|
29
|
+
"section-wrapper group",
|
|
30
|
+
getSectionCategory(type),
|
|
31
|
+
// Audience-preview dim is strong but recovers on hover so the section
|
|
32
|
+
// stays easy to edit; the view-mode status dim is display-only at 50%.
|
|
33
|
+
dimmed ? "opacity-15 transition-opacity duration-200 hover:opacity-100" : statusDimmed && "opacity-50",
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
26
36
|
{children}
|
|
27
37
|
</div>
|
|
28
38
|
);
|