@drawnagency/primitives 0.1.55 → 0.1.57
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/auth/cookies.d.ts.map +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/{chunk-24SUF2BC.js → chunk-ICLXLWQ5.js} +13 -74
- package/dist/chunk-NSCT3AMV.js +32 -0
- package/dist/{chunk-KDGYHU36.js → chunk-PRKUXM7E.js} +35 -10
- package/dist/{chunk-PUNXQK4M.js → chunk-PYWS3MOJ.js} +12 -2
- package/dist/chunk-TG43X7JO.js +123 -0
- package/dist/chunk-VKAGMEKE.js +90 -0
- package/dist/{chunk-B5VYSTPB.js → chunk-XTK4BR27.js} +1 -1
- package/dist/components/editor/ChildBlockWrapper.d.ts +19 -0
- package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -0
- package/dist/components/editor/ColSpanControl.d.ts +9 -0
- package/dist/components/editor/ColSpanControl.d.ts.map +1 -0
- package/dist/components/editor/SectionWrapper.d.ts +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/SettingsForm.d.ts +5 -1
- package/dist/components/editor/SettingsForm.d.ts.map +1 -1
- package/dist/components/primitives/EditableGrid.d.ts.map +1 -1
- package/dist/components/primitives/IconPicker.d.ts +7 -1
- package/dist/components/primitives/IconPicker.d.ts.map +1 -1
- package/dist/components/sections/Container/Container.d.ts +20 -0
- package/dist/components/sections/Container/Container.d.ts.map +1 -0
- package/dist/components/sections/Container/ContainerSettingsForm.d.ts +17 -0
- package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -0
- package/dist/components/sections/Container/index.d.ts +11 -0
- package/dist/components/sections/Container/index.d.ts.map +1 -0
- package/dist/components/sections/IconList/IconList.d.ts +1 -0
- package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
- package/dist/components/sections/IconList/IconListSettings.d.ts +3 -4
- package/dist/components/sections/IconList/IconListSettings.d.ts.map +1 -1
- package/dist/components/sections/IconList/index.d.ts +1 -0
- package/dist/components/sections/IconList/index.d.ts.map +1 -1
- package/dist/components/sections/Media/MediaBlock.d.ts +19 -0
- package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -0
- package/dist/components/sections/{MediaGrid → Media}/index.d.ts +15 -25
- package/dist/components/sections/Media/index.d.ts.map +1 -0
- package/dist/components/sections/Prose/index.d.ts.map +1 -1
- package/dist/components/sections/Spacer/Spacer.d.ts +2 -0
- package/dist/components/sections/Spacer/Spacer.d.ts.map +1 -0
- package/dist/components/sections/Spacer/index.d.ts +6 -0
- package/dist/components/sections/Spacer/index.d.ts.map +1 -0
- package/dist/components/sections/all-sections.d.ts +140 -0
- package/dist/components/sections/all-sections.d.ts.map +1 -0
- package/dist/components/sections/register-schemas.d.ts.map +1 -1
- package/dist/components/sections/register.d.ts.map +1 -1
- package/dist/components/shared/Tabs.d.ts +24 -0
- package/dist/components/shared/Tabs.d.ts.map +1 -0
- package/dist/components/shell/EditorShell.d.ts +2 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
- package/dist/components/shell/blockMoveDispatch.d.ts +21 -0
- package/dist/components/shell/blockMoveDispatch.d.ts.map +1 -0
- package/dist/hooks/useBlockDnd.d.ts +48 -0
- package/dist/hooks/useBlockDnd.d.ts.map +1 -0
- package/dist/hooks/useEditorPublish.d.ts +2 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/index.js +69 -48
- package/dist/lib/block-dnd.d.ts +42 -0
- package/dist/lib/block-dnd.d.ts.map +1 -0
- package/dist/lib/block-move.d.ts +31 -0
- package/dist/lib/block-move.d.ts.map +1 -0
- package/dist/lib/container-grid.d.ts +29 -0
- package/dist/lib/container-grid.d.ts.map +1 -0
- package/dist/lib/container-ops.d.ts +44 -0
- package/dist/lib/container-ops.d.ts.map +1 -0
- package/dist/lib/dexie.d.ts +12 -1
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +28 -3
- package/dist/lib/index.js +10 -7
- package/dist/lib/loader.d.ts.map +1 -1
- package/dist/lib/migrate-sections-transform.d.ts +12 -0
- package/dist/lib/migrate-sections-transform.d.ts.map +1 -0
- package/dist/lib/migrate-sections-transform.js +6 -0
- package/dist/lib/registry.d.ts +39 -2
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/lib/registry.js +26 -0
- package/dist/lib/sanitize.d.ts.map +1 -1
- package/dist/schemas/block.d.ts +20 -0
- package/dist/schemas/block.d.ts.map +1 -0
- package/dist/schemas/block.js +14 -0
- package/dist/schemas/index.js +10 -2
- package/dist/schemas/link.d.ts +7 -0
- package/dist/schemas/link.d.ts.map +1 -1
- package/dist/schemas/rich-text.d.ts +9 -0
- package/dist/schemas/rich-text.d.ts.map +1 -0
- package/dist/schemas/sections.d.ts +2 -0
- package/dist/schemas/sections.d.ts.map +1 -1
- package/dist/schemas/shared.d.ts +31 -0
- package/dist/schemas/shared.d.ts.map +1 -1
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/types.d.ts +13 -1
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +13 -1
- package/src/auth/cookies.ts +6 -1
- package/src/components/brandguide/Colors.tsx +35 -33
- package/src/components/editor/ChildBlockWrapper.tsx +108 -0
- package/src/components/editor/ColSpanControl.tsx +56 -0
- package/src/components/editor/SectionWrapper.tsx +44 -20
- package/src/components/editor/SettingsForm.tsx +100 -73
- package/src/components/primitives/EditableGrid.tsx +40 -36
- package/src/components/primitives/IconPicker.tsx +116 -26
- package/src/components/sections/Container/Container.tsx +354 -0
- package/src/components/sections/Container/ContainerSettingsForm.tsx +113 -0
- package/src/components/sections/Container/index.tsx +51 -0
- package/src/components/sections/IconList/IconList.tsx +113 -43
- package/src/components/sections/IconList/IconListSettings.tsx +2 -2
- package/src/components/sections/IconList/index.tsx +1 -1
- package/src/components/sections/Media/MediaBlock.tsx +103 -0
- package/src/components/sections/Media/index.tsx +85 -0
- package/src/components/sections/Prose/index.tsx +1 -0
- package/src/components/sections/Spacer/Spacer.tsx +6 -0
- package/src/components/sections/Spacer/index.tsx +18 -0
- package/src/components/sections/all-sections.ts +40 -0
- package/src/components/sections/register-schemas.ts +13 -18
- package/src/components/sections/register.ts +3 -17
- package/src/components/shared/Tabs.tsx +63 -0
- package/src/components/shell/EditorShell.tsx +147 -18
- package/src/components/shell/SiteSettingsModal.tsx +41 -51
- package/src/components/shell/blockMoveDispatch.ts +40 -0
- package/src/hooks/useBlockDnd.ts +144 -0
- package/src/hooks/useEditorPublish.ts +17 -4
- package/src/lib/block-dnd.ts +58 -0
- package/src/lib/block-move.ts +236 -0
- package/src/lib/container-grid.ts +58 -0
- package/src/lib/container-ops.ts +159 -0
- package/src/lib/dexie.ts +47 -0
- package/src/lib/loader.ts +16 -4
- package/src/lib/migrate-sections-transform.ts +147 -0
- package/src/lib/registry.ts +48 -2
- package/src/lib/sanitize.ts +22 -1
- package/src/schemas/block.ts +40 -0
- package/src/schemas/link.ts +19 -1
- package/src/schemas/rich-text.ts +11 -0
- package/src/schemas/sections.ts +5 -1
- package/src/schemas/shared.ts +16 -0
- package/src/schemas/site-config.ts +3 -3
- package/src/storage/index.ts +1 -0
- package/src/storage/types.ts +17 -0
- package/dist/components/brandguide/DoDontList.d.ts +0 -16
- package/dist/components/brandguide/DoDontList.d.ts.map +0 -1
- package/dist/components/brandguide/DoDontMediaGrid.d.ts +0 -16
- package/dist/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
- package/dist/components/primitives/MediaSettingsForms.d.ts +0 -23
- package/dist/components/primitives/MediaSettingsForms.d.ts.map +0 -1
- package/dist/components/sections/DoDontList/index.d.ts +0 -21
- package/dist/components/sections/DoDontList/index.d.ts.map +0 -1
- package/dist/components/sections/DoDontMediaGrid/index.d.ts +0 -55
- package/dist/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
- package/dist/components/sections/MediaGrid/MediaGrid.d.ts +0 -17
- package/dist/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
- package/dist/components/sections/MediaGrid/index.d.ts.map +0 -1
- package/dist/components/sections/SplitContent/SplitContent.d.ts +0 -14
- package/dist/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
- package/dist/components/sections/SplitContent/index.d.ts +0 -13
- package/dist/components/sections/SplitContent/index.d.ts.map +0 -1
- package/src/components/brandguide/DoDontList.d.ts.map +0 -1
- package/src/components/brandguide/DoDontList.tsx +0 -67
- package/src/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
- package/src/components/brandguide/DoDontMediaGrid.tsx +0 -19
- package/src/components/primitives/MediaSettingsForms.tsx +0 -128
- package/src/components/sections/DoDontList/index.d.ts.map +0 -1
- package/src/components/sections/DoDontList/index.tsx +0 -45
- package/src/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
- package/src/components/sections/DoDontMediaGrid/index.tsx +0 -63
- package/src/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
- package/src/components/sections/MediaGrid/MediaGrid.tsx +0 -239
- package/src/components/sections/MediaGrid/index.d.ts.map +0 -1
- package/src/components/sections/MediaGrid/index.tsx +0 -57
- package/src/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
- package/src/components/sections/SplitContent/SplitContent.tsx +0 -84
- package/src/components/sections/SplitContent/index.d.ts.map +0 -1
- package/src/components/sections/SplitContent/index.tsx +0 -55
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { type ComponentType, type ReactNode } from "react";
|
|
2
|
+
import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
|
3
|
+
import { getSection } from "../../../lib/registry";
|
|
4
|
+
import { cn } from "../../../lib/cn";
|
|
5
|
+
import { containerGridClass, colSpanClass, spacerHiddenClass } from "../../../lib/container-grid";
|
|
6
|
+
import { ChildBlockWrapper } from "../../editor/ChildBlockWrapper";
|
|
7
|
+
import { SettingsForm, type SettingsFormResult } from "../../editor/SettingsForm";
|
|
8
|
+
import { useBlockDnd, useBlockDropZone } from "../../../hooks/useBlockDnd";
|
|
9
|
+
import {
|
|
10
|
+
removeChildAt,
|
|
11
|
+
duplicateChildAt,
|
|
12
|
+
setChildColSpan,
|
|
13
|
+
isSpacer,
|
|
14
|
+
occupiedColumns,
|
|
15
|
+
trimTrailingSpacers,
|
|
16
|
+
} from "../../../lib/container-ops";
|
|
17
|
+
import type { Section } from "../../../schemas/sections";
|
|
18
|
+
|
|
19
|
+
// Stable allowedEdges arrays — must not be re-created per render (the dnd hooks
|
|
20
|
+
// re-subscribe whenever this array identity changes).
|
|
21
|
+
const ROW_EDGES: Edge[] = ["left", "right"];
|
|
22
|
+
const COL_EDGES: Edge[] = ["top", "bottom"];
|
|
23
|
+
|
|
24
|
+
type ChildComponent = ComponentType<{
|
|
25
|
+
content: Section;
|
|
26
|
+
options: Record<string, unknown>;
|
|
27
|
+
isEditMode: boolean;
|
|
28
|
+
onChange?: (content: unknown) => void;
|
|
29
|
+
openModal?: (title: string, content: ReactNode) => void;
|
|
30
|
+
}>;
|
|
31
|
+
|
|
32
|
+
interface ContainerChildProps {
|
|
33
|
+
containerId: string;
|
|
34
|
+
index: number;
|
|
35
|
+
child: Section;
|
|
36
|
+
columns: number;
|
|
37
|
+
flow: string;
|
|
38
|
+
cellClass: string | undefined;
|
|
39
|
+
label: string;
|
|
40
|
+
hasSettings: boolean;
|
|
41
|
+
mergedOptions: Record<string, unknown>;
|
|
42
|
+
isEditMode: boolean;
|
|
43
|
+
ChildComp: ChildComponent;
|
|
44
|
+
onDelete: () => void;
|
|
45
|
+
onDuplicate: () => void;
|
|
46
|
+
onColSpanChange: (span: number) => void;
|
|
47
|
+
onOpenSettings?: () => void;
|
|
48
|
+
onChildContentChange?: (content: unknown) => void;
|
|
49
|
+
openModal?: (title: string, content: ReactNode) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One per child. Calls useBlockDnd (a hook) so it cannot live inside children.map().
|
|
54
|
+
* Makes the child cell both a drag source and a drop target advertising
|
|
55
|
+
* { containerId, index }; the central EditorContent monitor performs the actual move.
|
|
56
|
+
*/
|
|
57
|
+
function ContainerChild({
|
|
58
|
+
containerId,
|
|
59
|
+
index,
|
|
60
|
+
child,
|
|
61
|
+
columns,
|
|
62
|
+
flow,
|
|
63
|
+
cellClass,
|
|
64
|
+
label,
|
|
65
|
+
hasSettings,
|
|
66
|
+
mergedOptions,
|
|
67
|
+
isEditMode,
|
|
68
|
+
ChildComp,
|
|
69
|
+
onDelete,
|
|
70
|
+
onDuplicate,
|
|
71
|
+
onColSpanChange,
|
|
72
|
+
onOpenSettings,
|
|
73
|
+
onChildContentChange,
|
|
74
|
+
openModal,
|
|
75
|
+
}: ContainerChildProps) {
|
|
76
|
+
const isContainer = Array.isArray((child.content as { children?: unknown })?.children);
|
|
77
|
+
const { dragRef, handleRef, closestEdge, isDragging } = useBlockDnd({
|
|
78
|
+
blockId: child.id ?? `child-${index}`,
|
|
79
|
+
containerId,
|
|
80
|
+
index,
|
|
81
|
+
isContainer,
|
|
82
|
+
allowedEdges: flow === "column" ? COL_EDGES : ROW_EDGES,
|
|
83
|
+
enabled: true,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div
|
|
88
|
+
ref={dragRef}
|
|
89
|
+
className={cellClass}
|
|
90
|
+
data-child-index={index}
|
|
91
|
+
data-child-id={child.id}
|
|
92
|
+
>
|
|
93
|
+
<ChildBlockWrapper
|
|
94
|
+
label={label}
|
|
95
|
+
columns={columns}
|
|
96
|
+
colSpan={Math.min(child.layout?.colSpan ?? 1, columns)}
|
|
97
|
+
closestEdge={closestEdge}
|
|
98
|
+
isDragging={isDragging}
|
|
99
|
+
hasSettings={hasSettings}
|
|
100
|
+
dragHandleRef={handleRef}
|
|
101
|
+
onDelete={onDelete}
|
|
102
|
+
onDuplicate={onDuplicate}
|
|
103
|
+
onColSpanChange={onColSpanChange}
|
|
104
|
+
onOpenSettings={onOpenSettings}
|
|
105
|
+
>
|
|
106
|
+
<ChildComp
|
|
107
|
+
content={child}
|
|
108
|
+
options={mergedOptions}
|
|
109
|
+
isEditMode={isEditMode}
|
|
110
|
+
onChange={onChildContentChange}
|
|
111
|
+
openModal={openModal}
|
|
112
|
+
/>
|
|
113
|
+
</ChildBlockWrapper>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface ContainerContent {
|
|
119
|
+
columns: number;
|
|
120
|
+
flow: string;
|
|
121
|
+
childDefaults?: Record<string, unknown>;
|
|
122
|
+
children: Section[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ContainerProps {
|
|
126
|
+
content: { id?: string; content: ContainerContent };
|
|
127
|
+
isEditMode: boolean;
|
|
128
|
+
onChange?: (content: unknown) => void;
|
|
129
|
+
openModal?: (title: string, content: ReactNode) => void;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* An EMPTY-COLUMN cell in edit mode — used for BOTH render-only trailing ghosts and persisted
|
|
134
|
+
* interior spacer children. They are visually identical (a spacer should just look like an empty
|
|
135
|
+
* column), so this is one component; only the `testId` differs. Each is a direct grid item that
|
|
136
|
+
* stretches to the row height (so it matches the size of neighbouring content cells) and advertises
|
|
137
|
+
* an absolute 1-based target column, so a drop pads the intervening tracks with spacers — or, when a
|
|
138
|
+
* spacer already holds that column, replaces it. There is no per-cell delete: an interior gap is
|
|
139
|
+
* closed by dropping/dragging a block onto it, or by deleting the block after it (trailing trim).
|
|
140
|
+
*/
|
|
141
|
+
function EmptyColumnCell({
|
|
142
|
+
containerId,
|
|
143
|
+
targetColumn,
|
|
144
|
+
testId,
|
|
145
|
+
}: {
|
|
146
|
+
containerId: string;
|
|
147
|
+
targetColumn: number;
|
|
148
|
+
testId: string;
|
|
149
|
+
}) {
|
|
150
|
+
const { dropRef, closestEdge } = useBlockDropZone({
|
|
151
|
+
containerId,
|
|
152
|
+
index: 0,
|
|
153
|
+
toColumn: targetColumn,
|
|
154
|
+
allowedEdges: ROW_EDGES,
|
|
155
|
+
enabled: true,
|
|
156
|
+
});
|
|
157
|
+
const active = closestEdge !== null;
|
|
158
|
+
return (
|
|
159
|
+
<div
|
|
160
|
+
ref={dropRef}
|
|
161
|
+
data-empty-dropzone
|
|
162
|
+
data-testid={testId}
|
|
163
|
+
data-target-column={targetColumn}
|
|
164
|
+
className={cn(
|
|
165
|
+
"flex min-h-20 items-center justify-center rounded-md border-2 border-dashed text-xs text-base-contrast-light/50",
|
|
166
|
+
active ? "border-primary" : "border-base-200",
|
|
167
|
+
)}
|
|
168
|
+
>
|
|
169
|
+
Empty
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function newId(): string {
|
|
175
|
+
return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
|
176
|
+
? crypto.randomUUID()
|
|
177
|
+
: `child-${Math.random().toString(36).slice(2)}-${Date.now()}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function Container({ content, isEditMode, onChange, openModal }: ContainerProps) {
|
|
181
|
+
const { columns, flow, childDefaults, children } = content.content;
|
|
182
|
+
|
|
183
|
+
const editable = isEditMode && !!onChange;
|
|
184
|
+
|
|
185
|
+
// container-ops operate on a proper Section whose content is the ContainerContent.
|
|
186
|
+
const container: Section = {
|
|
187
|
+
id: content.id ?? "container",
|
|
188
|
+
type: "container",
|
|
189
|
+
content: content.content as unknown as Record<string, unknown>,
|
|
190
|
+
};
|
|
191
|
+
const emit = (nextSection: Section) =>
|
|
192
|
+
onChange?.({ ...content, content: nextSection.content });
|
|
193
|
+
|
|
194
|
+
// Per-child inline content edits (e.g. prose/media bubble their own onChange).
|
|
195
|
+
const updateChild = (index: number, childContent: unknown) => {
|
|
196
|
+
const next = children.map((c, i) => (i === index ? (childContent as Section) : c));
|
|
197
|
+
onChange?.({ ...content, content: { ...content.content, children: next } });
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// Fixed 32px gap on BOTH axes, in view AND edit. The gap is no longer user-
|
|
201
|
+
// configurable; 32px gives the left-rail child controls room while keeping the
|
|
202
|
+
// published grid identical to the editor (true WYSIWYG).
|
|
203
|
+
const gridClass = cn(
|
|
204
|
+
"grid",
|
|
205
|
+
"gap-8",
|
|
206
|
+
containerGridClass[columns] ?? "grid-cols-1",
|
|
207
|
+
flow === "column" && "grid-flow-col",
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// --- View mode: zero-JS output, identical to the Plan 3 viewer. ---
|
|
211
|
+
if (!editable) {
|
|
212
|
+
return (
|
|
213
|
+
<div className="@container">
|
|
214
|
+
<div className={gridClass}>
|
|
215
|
+
{children.map((child, index) => {
|
|
216
|
+
const def = getSection(child.type);
|
|
217
|
+
if (!def) return null;
|
|
218
|
+
const Child = def.component;
|
|
219
|
+
const span = child.layout?.colSpan ?? 1;
|
|
220
|
+
const childOptions =
|
|
221
|
+
"options" in child ? (child.options as Record<string, unknown>) : undefined;
|
|
222
|
+
const mergedOptions = { ...(childDefaults ?? {}), ...(childOptions ?? {}) };
|
|
223
|
+
const cellClass = cn(
|
|
224
|
+
colSpanClass[columns]?.[Math.min(span, columns)] || undefined,
|
|
225
|
+
child.type === "spacer" && spacerHiddenClass[columns],
|
|
226
|
+
);
|
|
227
|
+
return (
|
|
228
|
+
<div key={child.id ?? index} className={cellClass}>
|
|
229
|
+
<Child content={child} options={mergedOptions} isEditMode={isEditMode} />
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
})}
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// A child's 1-based start column (spacer = 1 track; real child = clamped colSpan).
|
|
239
|
+
function columnStartOf(index: number): number {
|
|
240
|
+
let c = 0;
|
|
241
|
+
for (let i = 0; i < index; i++) {
|
|
242
|
+
c += isSpacer(children[i]) ? 1 : Math.min(children[i].layout?.colSpan ?? 1, columns);
|
|
243
|
+
}
|
|
244
|
+
return c + 1;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- Edit-mode child rendering. Each child is wrapped in <ContainerChild> so it can
|
|
248
|
+
// call the useBlockDnd hook (rules of hooks → one component per child). ---
|
|
249
|
+
function renderEditChild(child: Section, index: number): ReactNode {
|
|
250
|
+
const def = getSection(child.type);
|
|
251
|
+
if (!def) return null;
|
|
252
|
+
|
|
253
|
+
if (isSpacer(child)) {
|
|
254
|
+
// A persisted spacer renders as a plain empty-column cell (a direct grid item, like a
|
|
255
|
+
// ghost) — no drag handle, no delete. It's a drop target that replaces itself when a block
|
|
256
|
+
// is dropped onto its column.
|
|
257
|
+
return (
|
|
258
|
+
<EmptyColumnCell
|
|
259
|
+
key={child.id ?? index}
|
|
260
|
+
containerId={container.id}
|
|
261
|
+
targetColumn={columnStartOf(index)}
|
|
262
|
+
testId="spacer-cell"
|
|
263
|
+
/>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const span = child.layout?.colSpan ?? 1;
|
|
268
|
+
const childOptions = "options" in child ? (child.options as Record<string, unknown>) : undefined;
|
|
269
|
+
const mergedOptions = { ...(childDefaults ?? {}), ...(childOptions ?? {}) };
|
|
270
|
+
const cellClass = colSpanClass[columns]?.[Math.min(span, columns)] || undefined;
|
|
271
|
+
|
|
272
|
+
const hasSettings = !!(def.settings || def.settingsForm) && !!openModal;
|
|
273
|
+
const onOpenSettings =
|
|
274
|
+
hasSettings && openModal
|
|
275
|
+
? () => {
|
|
276
|
+
const childOpts = childOptions ?? {};
|
|
277
|
+
const childContent = (child.content ?? {}) as Record<string, unknown>;
|
|
278
|
+
// Seed the form from merged content+options so content-target fields (e.g.
|
|
279
|
+
// media.link, button.link/download) round-trip instead of re-emitting at their
|
|
280
|
+
// default and clobbering the stored value. Mirrors EditorShell's top-level seed.
|
|
281
|
+
const seed = { ...childContent, ...childOpts };
|
|
282
|
+
const applyResult = (result: SettingsFormResult) =>
|
|
283
|
+
updateChild(index, {
|
|
284
|
+
...child,
|
|
285
|
+
content: { ...(child.content as object), ...result.content },
|
|
286
|
+
options: { ...childOpts, ...result.options },
|
|
287
|
+
});
|
|
288
|
+
if (def.settingsForm) {
|
|
289
|
+
const CustomForm = def.settingsForm;
|
|
290
|
+
openModal(
|
|
291
|
+
`${def.label} Settings`,
|
|
292
|
+
<CustomForm {...seed} onChange={applyResult} />,
|
|
293
|
+
);
|
|
294
|
+
} else if (def.settings) {
|
|
295
|
+
openModal(
|
|
296
|
+
`${def.label} Settings`,
|
|
297
|
+
<SettingsForm
|
|
298
|
+
schema={def.settings}
|
|
299
|
+
values={seed}
|
|
300
|
+
onChange={applyResult}
|
|
301
|
+
tabs={def.settingsTabs}
|
|
302
|
+
/>,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
: undefined;
|
|
307
|
+
|
|
308
|
+
return (
|
|
309
|
+
<ContainerChild
|
|
310
|
+
key={child.id ?? index}
|
|
311
|
+
containerId={container.id}
|
|
312
|
+
index={index}
|
|
313
|
+
child={child}
|
|
314
|
+
columns={columns}
|
|
315
|
+
flow={flow}
|
|
316
|
+
cellClass={cellClass}
|
|
317
|
+
label={def.label}
|
|
318
|
+
hasSettings={hasSettings}
|
|
319
|
+
mergedOptions={mergedOptions}
|
|
320
|
+
isEditMode={isEditMode}
|
|
321
|
+
ChildComp={def.component as ChildComponent}
|
|
322
|
+
onDelete={() => emit(trimTrailingSpacers(removeChildAt(container, index).container))}
|
|
323
|
+
onDuplicate={() => emit(duplicateChildAt(container, index, newId))}
|
|
324
|
+
onColSpanChange={(s) => emit(setChildColSpan(container, index, s))}
|
|
325
|
+
onOpenSettings={onOpenSettings}
|
|
326
|
+
onChildContentChange={(c: unknown) => updateChild(index, c)}
|
|
327
|
+
openModal={openModal}
|
|
328
|
+
/>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// --- Edit mode --- (insertion is surfaced via the section toolbar's "+" in
|
|
333
|
+
// EditorContent, not a Container-owned affordance.) Children render first, then a
|
|
334
|
+
// ghost drop cell for each empty trailing column. Each ghost advertises its absolute
|
|
335
|
+
// 1-based target column so a drop pads intervening tracks with spacers (the empty
|
|
336
|
+
// container is the all-columns-empty case, with ghosts for columns 1..columns).
|
|
337
|
+
const occupied = occupiedColumns(children, columns);
|
|
338
|
+
const emptyCols = occupied < columns ? columns - occupied : 0;
|
|
339
|
+
return (
|
|
340
|
+
<div className="@container">
|
|
341
|
+
<div className={gridClass}>
|
|
342
|
+
{children.map(renderEditChild)}
|
|
343
|
+
{Array.from({ length: emptyCols }, (_, i) => (
|
|
344
|
+
<EmptyColumnCell
|
|
345
|
+
key={`ghost-${i}`}
|
|
346
|
+
containerId={container.id}
|
|
347
|
+
targetColumn={occupied + i + 1}
|
|
348
|
+
testId="ghost-cell"
|
|
349
|
+
/>
|
|
350
|
+
))}
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { getSection } from "../../../lib/registry";
|
|
3
|
+
import { SettingsForm } from "../../editor/SettingsForm";
|
|
4
|
+
import { MAX_CONTAINER_COLUMNS } from "../../../lib/container-grid";
|
|
5
|
+
import type { SettingsSchema } from "../../../lib/registry";
|
|
6
|
+
|
|
7
|
+
interface ChildLike {
|
|
8
|
+
type: string;
|
|
9
|
+
options?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ContainerSettingsFormProps {
|
|
13
|
+
columns?: number;
|
|
14
|
+
flow?: string;
|
|
15
|
+
childDefaults?: Record<string, unknown>;
|
|
16
|
+
children?: ChildLike[]; // the container's child blocks (DATA, not React nodes)
|
|
17
|
+
onChange: (result: { content: Record<string, unknown>; options: Record<string, unknown> }) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Union of inheritable option fields across the child types currently present. */
|
|
21
|
+
function inheritableSchema(children: ChildLike[]): SettingsSchema {
|
|
22
|
+
const out: SettingsSchema = {};
|
|
23
|
+
for (const child of children) {
|
|
24
|
+
const def = getSection(child.type);
|
|
25
|
+
if (!def?.settings || !def.inheritableSettings) continue;
|
|
26
|
+
for (const key of def.inheritableSettings) {
|
|
27
|
+
const field = def.settings[key];
|
|
28
|
+
if (field && !out[key]) out[key] = field;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function ContainerSettingsForm({
|
|
35
|
+
columns = 1,
|
|
36
|
+
flow = "row",
|
|
37
|
+
childDefaults = {},
|
|
38
|
+
children = [],
|
|
39
|
+
onChange,
|
|
40
|
+
}: ContainerSettingsFormProps) {
|
|
41
|
+
const [layout, setLayout] = useState({ columns, flow });
|
|
42
|
+
const [defaults, setDefaults] = useState<Record<string, unknown>>(childDefaults);
|
|
43
|
+
|
|
44
|
+
const childSchema = inheritableSchema(children);
|
|
45
|
+
const hasChildDefaults = Object.keys(childSchema).length > 0;
|
|
46
|
+
|
|
47
|
+
function emit(
|
|
48
|
+
nextLayout: { columns: number; flow: string },
|
|
49
|
+
nextDefaults: Record<string, unknown>,
|
|
50
|
+
) {
|
|
51
|
+
onChange({ content: { ...nextLayout, childDefaults: nextDefaults }, options: {} });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const layoutSchema: SettingsSchema = {
|
|
55
|
+
columns: {
|
|
56
|
+
type: "select",
|
|
57
|
+
label: "Columns",
|
|
58
|
+
default: String(layout.columns),
|
|
59
|
+
target: "content",
|
|
60
|
+
coerce: "number",
|
|
61
|
+
options: Array.from({ length: MAX_CONTAINER_COLUMNS }, (_, i) => ({
|
|
62
|
+
label: String(i + 1),
|
|
63
|
+
value: String(i + 1),
|
|
64
|
+
})),
|
|
65
|
+
},
|
|
66
|
+
flow: {
|
|
67
|
+
type: "select",
|
|
68
|
+
label: "Flow",
|
|
69
|
+
default: layout.flow,
|
|
70
|
+
target: "content",
|
|
71
|
+
options: [
|
|
72
|
+
{ label: "Rows (wrap across)", value: "row" },
|
|
73
|
+
{ label: "Columns (fill down)", value: "column" },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="flex flex-col gap-6">
|
|
80
|
+
<fieldset className="flex flex-col gap-4">
|
|
81
|
+
<legend className="mb-2 text-sm font-semibold text-base-contrast">Layout</legend>
|
|
82
|
+
<SettingsForm
|
|
83
|
+
schema={layoutSchema}
|
|
84
|
+
values={{ columns: String(layout.columns), flow: layout.flow }}
|
|
85
|
+
onChange={(result) => {
|
|
86
|
+
const next = {
|
|
87
|
+
columns: Number(result.content.columns ?? layout.columns),
|
|
88
|
+
flow: String(result.content.flow ?? layout.flow),
|
|
89
|
+
};
|
|
90
|
+
setLayout(next);
|
|
91
|
+
emit(next, defaults);
|
|
92
|
+
}}
|
|
93
|
+
/>
|
|
94
|
+
</fieldset>
|
|
95
|
+
|
|
96
|
+
{hasChildDefaults && (
|
|
97
|
+
<fieldset className="flex flex-col gap-4 border-t border-base-200 pt-4">
|
|
98
|
+
<legend className="mb-2 text-sm font-semibold text-base-contrast">Apply to all items</legend>
|
|
99
|
+
<SettingsForm
|
|
100
|
+
schema={childSchema}
|
|
101
|
+
values={defaults}
|
|
102
|
+
onChange={(result) => {
|
|
103
|
+
// inheritable fields are options-typed in their source defs → result.options
|
|
104
|
+
const next = { ...defaults, ...result.options };
|
|
105
|
+
setDefaults(next);
|
|
106
|
+
emit(layout, next);
|
|
107
|
+
}}
|
|
108
|
+
/>
|
|
109
|
+
</fieldset>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { defineSection } from "../../../lib/registry";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { LayoutTemplate } from "lucide-react";
|
|
4
|
+
import { getSectionSchema } from "../../../schemas/sections";
|
|
5
|
+
import { MAX_CONTAINER_COLUMNS } from "../../../lib/container-grid";
|
|
6
|
+
import { Container } from "./Container";
|
|
7
|
+
import { ContainerSettingsForm } from "./ContainerSettingsForm";
|
|
8
|
+
|
|
9
|
+
const schema = z.object({
|
|
10
|
+
type: z.literal("container"),
|
|
11
|
+
content: z
|
|
12
|
+
.object({
|
|
13
|
+
columns: z.number().int().min(1).max(MAX_CONTAINER_COLUMNS).default(1),
|
|
14
|
+
flow: z.enum(["row", "column"]).default("row"),
|
|
15
|
+
childDefaults: z.record(z.string(), z.unknown()).optional(),
|
|
16
|
+
// Recursive: children are full blocks. z.lazy defers evaluation to parse
|
|
17
|
+
// time, by which point every section schema (incl. container) is registered.
|
|
18
|
+
children: z.array(z.lazy(() => getSectionSchema())).default([]),
|
|
19
|
+
})
|
|
20
|
+
// Bound each child's colSpan to the available columns (spec §4.2). Non-rejecting
|
|
21
|
+
// and idempotent: a hand-edited/migrated file can't persist a phantom span, and
|
|
22
|
+
// a column-count reduction auto-clamps on the next save.
|
|
23
|
+
.transform((c) => ({
|
|
24
|
+
...c,
|
|
25
|
+
children: c.children.map((child) =>
|
|
26
|
+
(child as { layout?: { colSpan?: number } }).layout?.colSpan &&
|
|
27
|
+
(child as { layout?: { colSpan?: number } }).layout!.colSpan! > c.columns
|
|
28
|
+
? { ...child, layout: { ...(child as { layout?: { colSpan?: number } }).layout, colSpan: c.columns } }
|
|
29
|
+
: child,
|
|
30
|
+
),
|
|
31
|
+
})),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export default defineSection({
|
|
35
|
+
type: "container",
|
|
36
|
+
label: "Container",
|
|
37
|
+
icon: <LayoutTemplate size={18} />,
|
|
38
|
+
schema,
|
|
39
|
+
component: ({ content, onChange, isEditMode, openModal }) => (
|
|
40
|
+
<Container content={content as never} isEditMode={!!isEditMode} onChange={onChange as never} openModal={openModal} />
|
|
41
|
+
),
|
|
42
|
+
defaults: () => ({
|
|
43
|
+
type: "container" as const,
|
|
44
|
+
content: { columns: 1, flow: "row" as const, children: [] },
|
|
45
|
+
}),
|
|
46
|
+
getLabel: (content) => {
|
|
47
|
+
const n = content.content.children.length;
|
|
48
|
+
return `Container (${n} item${n === 1 ? "" : "s"})`;
|
|
49
|
+
},
|
|
50
|
+
settingsForm: ContainerSettingsForm,
|
|
51
|
+
});
|