@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.
Files changed (153) hide show
  1. package/dist/{chunk-KGYWQDBB.js → chunk-ICLXLWQ5.js} +9 -72
  2. package/dist/chunk-NSCT3AMV.js +32 -0
  3. package/dist/{chunk-EU6NZ4GS.js → chunk-PRKUXM7E.js} +23 -9
  4. package/dist/{chunk-7IAWF7LE.js → chunk-PYWS3MOJ.js} +12 -2
  5. package/dist/chunk-TG43X7JO.js +123 -0
  6. package/dist/chunk-VKAGMEKE.js +90 -0
  7. package/dist/components/editor/ChildBlockWrapper.d.ts +19 -0
  8. package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -0
  9. package/dist/components/editor/ColSpanControl.d.ts +9 -0
  10. package/dist/components/editor/ColSpanControl.d.ts.map +1 -0
  11. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  12. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  13. package/dist/components/editor/SettingsForm.d.ts +5 -1
  14. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  15. package/dist/components/primitives/EditableGrid.d.ts.map +1 -1
  16. package/dist/components/primitives/IconPicker.d.ts +7 -1
  17. package/dist/components/primitives/IconPicker.d.ts.map +1 -1
  18. package/dist/components/sections/Container/Container.d.ts +20 -0
  19. package/dist/components/sections/Container/Container.d.ts.map +1 -0
  20. package/dist/components/sections/Container/ContainerSettingsForm.d.ts +17 -0
  21. package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -0
  22. package/dist/components/sections/Container/index.d.ts +11 -0
  23. package/dist/components/sections/Container/index.d.ts.map +1 -0
  24. package/dist/components/sections/IconList/IconList.d.ts +1 -0
  25. package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
  26. package/dist/components/sections/IconList/IconListSettings.d.ts +3 -4
  27. package/dist/components/sections/IconList/IconListSettings.d.ts.map +1 -1
  28. package/dist/components/sections/IconList/index.d.ts +1 -0
  29. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  30. package/dist/components/sections/Media/MediaBlock.d.ts +19 -0
  31. package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -0
  32. package/dist/components/sections/{MediaGrid → Media}/index.d.ts +15 -25
  33. package/dist/components/sections/Media/index.d.ts.map +1 -0
  34. package/dist/components/sections/Prose/index.d.ts.map +1 -1
  35. package/dist/components/sections/Spacer/Spacer.d.ts +2 -0
  36. package/dist/components/sections/Spacer/Spacer.d.ts.map +1 -0
  37. package/dist/components/sections/Spacer/index.d.ts +6 -0
  38. package/dist/components/sections/Spacer/index.d.ts.map +1 -0
  39. package/dist/components/sections/all-sections.d.ts +29 -103
  40. package/dist/components/sections/all-sections.d.ts.map +1 -1
  41. package/dist/components/sections/register-schemas.d.ts.map +1 -1
  42. package/dist/components/shared/Tabs.d.ts +24 -0
  43. package/dist/components/shared/Tabs.d.ts.map +1 -0
  44. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  45. package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
  46. package/dist/components/shell/blockMoveDispatch.d.ts +21 -0
  47. package/dist/components/shell/blockMoveDispatch.d.ts.map +1 -0
  48. package/dist/hooks/useBlockDnd.d.ts +48 -0
  49. package/dist/hooks/useBlockDnd.d.ts.map +1 -0
  50. package/dist/index.js +56 -48
  51. package/dist/lib/block-dnd.d.ts +42 -0
  52. package/dist/lib/block-dnd.d.ts.map +1 -0
  53. package/dist/lib/block-move.d.ts +31 -0
  54. package/dist/lib/block-move.d.ts.map +1 -0
  55. package/dist/lib/container-grid.d.ts +29 -0
  56. package/dist/lib/container-grid.d.ts.map +1 -0
  57. package/dist/lib/container-ops.d.ts +44 -0
  58. package/dist/lib/container-ops.d.ts.map +1 -0
  59. package/dist/lib/dexie.d.ts.map +1 -1
  60. package/dist/lib/dexie.js +13 -0
  61. package/dist/lib/index.js +10 -7
  62. package/dist/lib/loader.d.ts.map +1 -1
  63. package/dist/lib/migrate-sections-transform.d.ts +12 -0
  64. package/dist/lib/migrate-sections-transform.d.ts.map +1 -0
  65. package/dist/lib/migrate-sections-transform.js +6 -0
  66. package/dist/lib/registry.d.ts +39 -0
  67. package/dist/lib/registry.d.ts.map +1 -1
  68. package/dist/lib/registry.js +26 -0
  69. package/dist/schemas/block.d.ts +20 -0
  70. package/dist/schemas/block.d.ts.map +1 -0
  71. package/dist/schemas/block.js +14 -0
  72. package/dist/schemas/index.js +8 -2
  73. package/dist/schemas/link.d.ts +7 -0
  74. package/dist/schemas/link.d.ts.map +1 -1
  75. package/dist/schemas/rich-text.d.ts +9 -0
  76. package/dist/schemas/rich-text.d.ts.map +1 -0
  77. package/dist/schemas/sections.d.ts +2 -0
  78. package/dist/schemas/sections.d.ts.map +1 -1
  79. package/dist/schemas/shared.d.ts +30 -0
  80. package/dist/schemas/shared.d.ts.map +1 -1
  81. package/package.json +13 -1
  82. package/src/components/brandguide/Colors.tsx +35 -33
  83. package/src/components/editor/ChildBlockWrapper.tsx +108 -0
  84. package/src/components/editor/ColSpanControl.tsx +56 -0
  85. package/src/components/editor/SectionWrapper.tsx +44 -20
  86. package/src/components/editor/SettingsForm.tsx +100 -73
  87. package/src/components/primitives/EditableGrid.tsx +40 -36
  88. package/src/components/primitives/IconPicker.tsx +116 -26
  89. package/src/components/sections/Container/Container.tsx +354 -0
  90. package/src/components/sections/Container/ContainerSettingsForm.tsx +113 -0
  91. package/src/components/sections/Container/index.tsx +51 -0
  92. package/src/components/sections/IconList/IconList.tsx +113 -43
  93. package/src/components/sections/IconList/IconListSettings.tsx +2 -2
  94. package/src/components/sections/IconList/index.tsx +1 -1
  95. package/src/components/sections/Media/MediaBlock.tsx +103 -0
  96. package/src/components/sections/Media/index.tsx +85 -0
  97. package/src/components/sections/Prose/index.tsx +1 -0
  98. package/src/components/sections/Spacer/Spacer.tsx +6 -0
  99. package/src/components/sections/Spacer/index.tsx +18 -0
  100. package/src/components/sections/all-sections.ts +10 -8
  101. package/src/components/sections/register-schemas.ts +5 -2
  102. package/src/components/shared/Tabs.tsx +63 -0
  103. package/src/components/shell/EditorShell.tsx +105 -13
  104. package/src/components/shell/SiteSettingsModal.tsx +41 -51
  105. package/src/components/shell/blockMoveDispatch.ts +40 -0
  106. package/src/hooks/useBlockDnd.ts +144 -0
  107. package/src/lib/block-dnd.ts +58 -0
  108. package/src/lib/block-move.ts +236 -0
  109. package/src/lib/container-grid.ts +58 -0
  110. package/src/lib/container-ops.ts +159 -0
  111. package/src/lib/dexie.ts +22 -0
  112. package/src/lib/loader.ts +16 -4
  113. package/src/lib/migrate-sections-transform.ts +147 -0
  114. package/src/lib/registry.ts +48 -0
  115. package/src/schemas/block.ts +40 -0
  116. package/src/schemas/link.ts +19 -1
  117. package/src/schemas/rich-text.ts +11 -0
  118. package/src/schemas/sections.ts +5 -1
  119. package/src/schemas/shared.ts +6 -0
  120. package/dist/components/brandguide/DoDontList.d.ts +0 -16
  121. package/dist/components/brandguide/DoDontList.d.ts.map +0 -1
  122. package/dist/components/brandguide/DoDontMediaGrid.d.ts +0 -16
  123. package/dist/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
  124. package/dist/components/primitives/MediaSettingsForms.d.ts +0 -23
  125. package/dist/components/primitives/MediaSettingsForms.d.ts.map +0 -1
  126. package/dist/components/sections/DoDontList/index.d.ts +0 -21
  127. package/dist/components/sections/DoDontList/index.d.ts.map +0 -1
  128. package/dist/components/sections/DoDontMediaGrid/index.d.ts +0 -55
  129. package/dist/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
  130. package/dist/components/sections/MediaGrid/MediaGrid.d.ts +0 -17
  131. package/dist/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
  132. package/dist/components/sections/MediaGrid/index.d.ts.map +0 -1
  133. package/dist/components/sections/SplitContent/SplitContent.d.ts +0 -14
  134. package/dist/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
  135. package/dist/components/sections/SplitContent/index.d.ts +0 -13
  136. package/dist/components/sections/SplitContent/index.d.ts.map +0 -1
  137. package/src/components/brandguide/DoDontList.d.ts.map +0 -1
  138. package/src/components/brandguide/DoDontList.tsx +0 -67
  139. package/src/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
  140. package/src/components/brandguide/DoDontMediaGrid.tsx +0 -19
  141. package/src/components/primitives/MediaSettingsForms.tsx +0 -128
  142. package/src/components/sections/DoDontList/index.d.ts.map +0 -1
  143. package/src/components/sections/DoDontList/index.tsx +0 -45
  144. package/src/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
  145. package/src/components/sections/DoDontMediaGrid/index.tsx +0 -63
  146. package/src/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
  147. package/src/components/sections/MediaGrid/MediaGrid.tsx +0 -239
  148. package/src/components/sections/MediaGrid/index.d.ts.map +0 -1
  149. package/src/components/sections/MediaGrid/index.tsx +0 -57
  150. package/src/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
  151. package/src/components/sections/SplitContent/SplitContent.tsx +0 -84
  152. package/src/components/sections/SplitContent/index.d.ts.map +0 -1
  153. 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 { gridColsClass } from "../../lib/grid";
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={cn("grid gap-4", gridColsClass[columns] || "grid-cols-1", className)}>
72
- {wrappedItems.map((wrapped, index) => (
73
- <div key={wrapped.id}>
74
- {renderItem(wrapped.data, { isEditMode: false, index })}
75
- </div>
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={cn("group/grid relative grid gap-4", gridColsClass[columns] || "grid-cols-1", className)}>
83
- {wrappedItems.map((wrapped, index) => (
84
- <GridCell
85
- key={wrapped.id}
86
- id={wrapped.id}
87
- index={index}
88
- isLast={index === wrappedItems.length - 1}
89
- isDragging={isDragging}
90
- onReorder={onReorder}
91
- onRemove={onRemove}
92
- onInsert={onInsert}
93
- onSettings={onItemSettings ? () => onItemSettings(index) : undefined}
94
- onImageClick={onItemImageClick ? () => onItemImageClick(index) : undefined}
95
- chromeTopClass={chromeTopClass}
96
- dragState={dragState}
97
- setDragState={setDragState}
98
- >
99
- {renderItem(wrapped.data, { isEditMode: true, index })}
100
- </GridCell>
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
- {/* Add button — absolute, centered below the grid */}
104
- <IconButton
105
- icon={<AddIcon size={16} />}
106
- label="Add item"
107
- size="lg"
108
- intent="primary"
109
- onClick={onAdd}
110
- 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"
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({ selected, onSelect, onClose, showRemove = true }: IconPickerProps) {
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
- <div className="grid grid-cols-5 gap-1">
31
- {curatedIcons.map((entry) => {
32
- const Icon = entry.icon;
33
- const isSelected = selected === entry.id;
34
- return (
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
- key={entry.id}
37
- aria-label={entry.label}
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
- <Icon size={18} />
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
+ }