@drawnagency/primitives 0.1.56 → 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/{chunk-KGYWQDBB.js → chunk-ICLXLWQ5.js} +9 -72
- package/dist/chunk-NSCT3AMV.js +32 -0
- package/dist/{chunk-EU6NZ4GS.js → chunk-PRKUXM7E.js} +23 -9
- package/dist/{chunk-7IAWF7LE.js → chunk-PYWS3MOJ.js} +12 -2
- package/dist/chunk-TG43X7JO.js +123 -0
- package/dist/chunk-VKAGMEKE.js +90 -0
- 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 +29 -103
- package/dist/components/sections/all-sections.d.ts.map +1 -1
- package/dist/components/sections/register-schemas.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.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/index.js +56 -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.map +1 -1
- package/dist/lib/dexie.js +13 -0
- 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 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/lib/registry.js +26 -0
- 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 +8 -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 +30 -0
- package/dist/schemas/shared.d.ts.map +1 -1
- package/package.json +13 -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 +10 -8
- package/src/components/sections/register-schemas.ts +5 -2
- package/src/components/shared/Tabs.tsx +63 -0
- package/src/components/shell/EditorShell.tsx +105 -13
- 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/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 +22 -0
- package/src/lib/loader.ts +16 -4
- package/src/lib/migrate-sections-transform.ts +147 -0
- package/src/lib/registry.ts +48 -0
- 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 +6 -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
|
@@ -3,7 +3,7 @@ import { ImageIcon } from "lucide-react";
|
|
|
3
3
|
import { AddIcon, DragHandle, DeleteIcon, SettingsIcon } from "../shared/icons";
|
|
4
4
|
import { IconButton } from "../shared/IconButton";
|
|
5
5
|
import { cn } from "../../lib/cn";
|
|
6
|
-
import {
|
|
6
|
+
import { containerGridClass } from "../../lib/container-grid";
|
|
7
7
|
import { useEditableCollection } from "./useEditableCollection";
|
|
8
8
|
|
|
9
9
|
interface EditableGridProps<T> {
|
|
@@ -68,47 +68,51 @@ export function EditableGrid<T>({
|
|
|
68
68
|
|
|
69
69
|
if (!isEditMode) {
|
|
70
70
|
return (
|
|
71
|
-
<div className=
|
|
72
|
-
{
|
|
73
|
-
|
|
74
|
-
{
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
<div className="@container">
|
|
72
|
+
<div className={cn("grid gap-4", containerGridClass[columns] || containerGridClass[1], className)}>
|
|
73
|
+
{wrappedItems.map((wrapped, index) => (
|
|
74
|
+
<div key={wrapped.id}>
|
|
75
|
+
{renderItem(wrapped.data, { isEditMode: false, index })}
|
|
76
|
+
</div>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
77
79
|
</div>
|
|
78
80
|
);
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
return (
|
|
82
|
-
<div className=
|
|
83
|
-
{
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
84
|
+
<div className="@container">
|
|
85
|
+
<div className={cn("group/grid relative grid gap-4", containerGridClass[columns] || containerGridClass[1], className)}>
|
|
86
|
+
{wrappedItems.map((wrapped, index) => (
|
|
87
|
+
<GridCell
|
|
88
|
+
key={wrapped.id}
|
|
89
|
+
id={wrapped.id}
|
|
90
|
+
index={index}
|
|
91
|
+
isLast={index === wrappedItems.length - 1}
|
|
92
|
+
isDragging={isDragging}
|
|
93
|
+
onReorder={onReorder}
|
|
94
|
+
onRemove={onRemove}
|
|
95
|
+
onInsert={onInsert}
|
|
96
|
+
onSettings={onItemSettings ? () => onItemSettings(index) : undefined}
|
|
97
|
+
onImageClick={onItemImageClick ? () => onItemImageClick(index) : undefined}
|
|
98
|
+
chromeTopClass={chromeTopClass}
|
|
99
|
+
dragState={dragState}
|
|
100
|
+
setDragState={setDragState}
|
|
101
|
+
>
|
|
102
|
+
{renderItem(wrapped.data, { isEditMode: true, index })}
|
|
103
|
+
</GridCell>
|
|
104
|
+
))}
|
|
102
105
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
106
|
+
{/* Add button — absolute, centered below the grid */}
|
|
107
|
+
<IconButton
|
|
108
|
+
icon={<AddIcon size={16} />}
|
|
109
|
+
label="Add item"
|
|
110
|
+
size="lg"
|
|
111
|
+
intent="primary"
|
|
112
|
+
onClick={onAdd}
|
|
113
|
+
className="absolute -bottom-6 left-1/2 z-20 -translate-x-1/2 rounded-full border border-base-200 bg-base opacity-0 transition-opacity group-hover/grid:opacity-100"
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
112
116
|
</div>
|
|
113
117
|
);
|
|
114
118
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useEffect, useRef } from "react";
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Check, X } from "lucide-react";
|
|
2
3
|
import { cn } from "../../lib/cn";
|
|
3
4
|
import { curatedIcons } from "../../lib/icons";
|
|
4
5
|
|
|
@@ -7,10 +8,26 @@ interface IconPickerProps {
|
|
|
7
8
|
onSelect: (iconId: string | null) => void;
|
|
8
9
|
onClose: () => void;
|
|
9
10
|
showRemove?: boolean;
|
|
11
|
+
/** Opt-in: render the None / Do-Don't mode toggle. Default false → grid-only. */
|
|
12
|
+
allowDoDont?: boolean;
|
|
13
|
+
/** Current per-item Do/Don't tag (only meaningful when allowDoDont). */
|
|
14
|
+
dodont?: "do" | "dont";
|
|
15
|
+
/** Commit a Do/Don't choice; pass undefined to clear the tag (None). */
|
|
16
|
+
onSelectDoDont?: (v: "do" | "dont" | undefined) => void;
|
|
10
17
|
}
|
|
11
18
|
|
|
12
|
-
export function IconPicker({
|
|
19
|
+
export function IconPicker({
|
|
20
|
+
selected,
|
|
21
|
+
onSelect,
|
|
22
|
+
onClose,
|
|
23
|
+
showRemove = true,
|
|
24
|
+
allowDoDont = false,
|
|
25
|
+
dodont,
|
|
26
|
+
onSelectDoDont,
|
|
27
|
+
}: IconPickerProps) {
|
|
13
28
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
29
|
+
// Open in the mode matching the item's current state.
|
|
30
|
+
const [mode, setMode] = useState<"none" | "dodont">(dodont ? "dodont" : "none");
|
|
14
31
|
|
|
15
32
|
useEffect(() => {
|
|
16
33
|
function handleMouseDown(e: MouseEvent) {
|
|
@@ -22,38 +39,111 @@ export function IconPicker({ selected, onSelect, onClose, showRemove = true }: I
|
|
|
22
39
|
return () => document.removeEventListener("mousedown", handleMouseDown);
|
|
23
40
|
}, [onClose]);
|
|
24
41
|
|
|
42
|
+
const showDoDont = allowDoDont && mode === "dodont";
|
|
43
|
+
|
|
25
44
|
return (
|
|
26
45
|
<div
|
|
27
46
|
ref={panelRef}
|
|
28
47
|
className="absolute z-50 w-56 rounded-lg border border-base-200 bg-base p-2 shadow-lg"
|
|
29
48
|
>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
49
|
+
{allowDoDont && (
|
|
50
|
+
<div role="tablist" aria-label="Icon mode" className="mb-2 flex gap-1 rounded-md bg-base-200 p-0.5">
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
role="tab"
|
|
54
|
+
aria-label="None"
|
|
55
|
+
aria-selected={mode === "none"}
|
|
56
|
+
className={cn(
|
|
57
|
+
"cursor-pointer flex-1 rounded px-2 py-1 text-xs font-medium transition-colors",
|
|
58
|
+
mode === "none"
|
|
59
|
+
? "bg-base text-base-contrast shadow-sm"
|
|
60
|
+
: "text-base-contrast-light hover:text-base-contrast",
|
|
61
|
+
)}
|
|
62
|
+
onClick={() => {
|
|
63
|
+
setMode("none");
|
|
64
|
+
onSelectDoDont?.(undefined);
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
None
|
|
68
|
+
</button>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
role="tab"
|
|
72
|
+
aria-label="Do / Don't"
|
|
73
|
+
aria-selected={mode === "dodont"}
|
|
74
|
+
className={cn(
|
|
75
|
+
"cursor-pointer flex-1 rounded px-2 py-1 text-xs font-medium transition-colors",
|
|
76
|
+
mode === "dodont"
|
|
77
|
+
? "bg-base text-base-contrast shadow-sm"
|
|
78
|
+
: "text-base-contrast-light hover:text-base-contrast",
|
|
79
|
+
)}
|
|
80
|
+
onClick={() => setMode("dodont")}
|
|
81
|
+
>
|
|
82
|
+
Do / Don't
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
|
|
87
|
+
{showDoDont ? (
|
|
88
|
+
<div className="grid grid-cols-2 gap-2">
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
aria-label="Do"
|
|
92
|
+
className={cn(
|
|
93
|
+
"cursor-pointer flex flex-col items-center justify-center gap-1 rounded py-2 transition-colors",
|
|
94
|
+
"text-green-600 hover:bg-base-accent",
|
|
95
|
+
dodont === "do" && "ring-2 ring-primary bg-base-accent",
|
|
96
|
+
)}
|
|
97
|
+
onClick={() => onSelectDoDont?.("do")}
|
|
98
|
+
>
|
|
99
|
+
<Check size={20} />
|
|
100
|
+
<span className="text-xs font-medium">Do</span>
|
|
101
|
+
</button>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
aria-label="Don't"
|
|
105
|
+
className={cn(
|
|
106
|
+
"cursor-pointer flex flex-col items-center justify-center gap-1 rounded py-2 transition-colors",
|
|
107
|
+
"text-red-600 hover:bg-base-accent",
|
|
108
|
+
dodont === "dont" && "ring-2 ring-primary bg-base-accent",
|
|
109
|
+
)}
|
|
110
|
+
onClick={() => onSelectDoDont?.("dont")}
|
|
111
|
+
>
|
|
112
|
+
<X size={20} />
|
|
113
|
+
<span className="text-xs font-medium">Don't</span>
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
) : (
|
|
117
|
+
<>
|
|
118
|
+
<div className="grid grid-cols-5 gap-1">
|
|
119
|
+
{curatedIcons.map((entry) => {
|
|
120
|
+
const Icon = entry.icon;
|
|
121
|
+
const isSelected = selected === entry.id;
|
|
122
|
+
return (
|
|
123
|
+
<button
|
|
124
|
+
key={entry.id}
|
|
125
|
+
aria-label={entry.label}
|
|
126
|
+
className={cn(
|
|
127
|
+
"cursor-pointer flex h-9 w-9 items-center justify-center rounded transition-colors",
|
|
128
|
+
"text-base-contrast-light hover:bg-base-accent hover:text-base-contrast",
|
|
129
|
+
isSelected && "ring-2 ring-primary bg-base-accent text-primary",
|
|
130
|
+
)}
|
|
131
|
+
onClick={() => onSelect(entry.id)}
|
|
132
|
+
>
|
|
133
|
+
<Icon size={18} />
|
|
134
|
+
</button>
|
|
135
|
+
);
|
|
136
|
+
})}
|
|
137
|
+
</div>
|
|
138
|
+
{showRemove && (
|
|
35
139
|
<button
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
className={cn(
|
|
39
|
-
"cursor-pointer flex h-9 w-9 items-center justify-center rounded transition-colors",
|
|
40
|
-
"text-base-contrast-light hover:bg-base-accent hover:text-base-contrast",
|
|
41
|
-
isSelected && "ring-2 ring-primary bg-base-accent text-primary",
|
|
42
|
-
)}
|
|
43
|
-
onClick={() => onSelect(entry.id)}
|
|
140
|
+
className="cursor-pointer mt-2 w-full rounded px-2 py-1.5 text-center text-xs text-base-contrast-light hover:bg-base-accent hover:text-base-contrast"
|
|
141
|
+
onClick={() => onSelect(null)}
|
|
44
142
|
>
|
|
45
|
-
|
|
143
|
+
Remove icon
|
|
46
144
|
</button>
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
</div>
|
|
50
|
-
{showRemove && (
|
|
51
|
-
<button
|
|
52
|
-
className="cursor-pointer mt-2 w-full rounded px-2 py-1.5 text-center text-xs text-base-contrast-light hover:bg-base-accent hover:text-base-contrast"
|
|
53
|
-
onClick={() => onSelect(null)}
|
|
54
|
-
>
|
|
55
|
-
Remove icon
|
|
56
|
-
</button>
|
|
145
|
+
)}
|
|
146
|
+
</>
|
|
57
147
|
)}
|
|
58
148
|
</div>
|
|
59
149
|
);
|
|
@@ -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
|
+
}
|