@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
@@ -1,7 +1,7 @@
1
1
  import { useState, useEffect } from "react";
2
2
  import { Eye, EyeOff } from "lucide-react";
3
3
  import { cn } from "../../lib/cn";
4
- import { gridColsClass } from "../../lib/grid";
4
+ import { containerGridClass } from "../../lib/container-grid";
5
5
  import { EditableGrid } from "../primitives/EditableGrid";
6
6
  import { EditablePlainText } from "../primitives/EditablePlainText";
7
7
  import type { ReactNode } from "react";
@@ -119,40 +119,42 @@ function ColorsView({
119
119
  </div>
120
120
  )}
121
121
 
122
- <div className={cn("grid gap-4", gridColsClass[columns] || "grid-cols-3")}>
123
- {colors.map((color, i) => {
124
- const hex = color.spaces[0]?.hex;
125
- const contrast = getContrastClass(hex);
126
- return (
127
- <div key={i} className="overflow-hidden rounded-md border border-base-200">
128
- <div className={cn("relative flex min-h-[80px] items-end p-3", contrast)} style={{ backgroundColor: hex || "#ccc" }}>
129
- {color.name && <span className="text-sm font-bold">{color.name}</span>}
130
- {copiedIndex === i && (
131
- <span className={cn(
132
- "absolute top-2 right-2 rounded-full px-2.5 py-0.5 text-xs font-medium",
133
- getContrastClass(hex) === "text-black" ? "bg-black/15 text-black" : "bg-white/25 text-white",
134
- )}>
135
- Copied!
136
- </span>
137
- )}
138
- </div>
139
- {expanded && (
140
- <div className="space-y-1 bg-base-accent p-3 text-sm">
141
- {color.spaces.map((space, j) =>
142
- Object.entries(space).map(([key, value]) =>
143
- value ? (
144
- <button key={`${j}-${key}`} onClick={() => handleCopy(value, i)} className="cursor-pointer flex w-full justify-between hover:text-primary">
145
- <span className="font-medium uppercase">{key}</span>
146
- <span>{value}</span>
147
- </button>
148
- ) : null
149
- )
122
+ <div className="@container">
123
+ <div className={cn("grid gap-4", containerGridClass[columns] || containerGridClass[3])}>
124
+ {colors.map((color, i) => {
125
+ const hex = color.spaces[0]?.hex;
126
+ const contrast = getContrastClass(hex);
127
+ return (
128
+ <div key={i} className="overflow-hidden rounded-md border border-base-200">
129
+ <div className={cn("relative flex min-h-[80px] items-end p-3", contrast)} style={{ backgroundColor: hex || "#ccc" }}>
130
+ {color.name && <span className="text-sm font-bold">{color.name}</span>}
131
+ {copiedIndex === i && (
132
+ <span className={cn(
133
+ "absolute top-2 right-2 rounded-full px-2.5 py-0.5 text-xs font-medium",
134
+ getContrastClass(hex) === "text-black" ? "bg-black/15 text-black" : "bg-white/25 text-white",
135
+ )}>
136
+ Copied!
137
+ </span>
150
138
  )}
151
139
  </div>
152
- )}
153
- </div>
154
- );
155
- })}
140
+ {expanded && (
141
+ <div className="space-y-1 bg-base-accent p-3 text-sm">
142
+ {color.spaces.map((space, j) =>
143
+ Object.entries(space).map(([key, value]) =>
144
+ value ? (
145
+ <button key={`${j}-${key}`} onClick={() => handleCopy(value, i)} className="cursor-pointer flex w-full justify-between hover:text-primary">
146
+ <span className="font-medium uppercase">{key}</span>
147
+ <span>{value}</span>
148
+ </button>
149
+ ) : null
150
+ )
151
+ )}
152
+ </div>
153
+ )}
154
+ </div>
155
+ );
156
+ })}
157
+ </div>
156
158
  </div>
157
159
  </div>
158
160
  );
@@ -0,0 +1,108 @@
1
+ import { forwardRef, type ReactNode, type Ref } from "react";
2
+ import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
3
+ import { CopyPlus } from "lucide-react";
4
+ import { DragHandle } from "./DragHandle";
5
+ import { DeleteButton } from "./DeleteButton";
6
+ import { SettingsButton } from "./SettingsButton";
7
+ import { ColSpanControl } from "./ColSpanControl";
8
+ import { cn } from "../../lib/cn";
9
+
10
+ export interface ChildBlockWrapperProps {
11
+ label: string;
12
+ columns: number;
13
+ colSpan: number;
14
+ closestEdge: Edge | null;
15
+ isDragging: boolean;
16
+ hasSettings: boolean;
17
+ /** Forwarded to the drag handle button (pragmatic-dnd dragHandle target). */
18
+ dragHandleRef?: Ref<HTMLButtonElement>;
19
+ onDelete: () => void;
20
+ onDuplicate: () => void;
21
+ onColSpanChange: (span: number) => void;
22
+ onOpenSettings?: () => void;
23
+ children: ReactNode;
24
+ }
25
+
26
+ export const ChildBlockWrapper = forwardRef<HTMLDivElement, ChildBlockWrapperProps>(
27
+ function ChildBlockWrapper(
28
+ {
29
+ label,
30
+ columns,
31
+ colSpan,
32
+ closestEdge,
33
+ isDragging,
34
+ hasSettings,
35
+ dragHandleRef,
36
+ onDelete,
37
+ onDuplicate,
38
+ onColSpanChange,
39
+ onOpenSettings,
40
+ children,
41
+ },
42
+ ref,
43
+ ) {
44
+ return (
45
+ <div
46
+ ref={ref}
47
+ className={cn(
48
+ "group/childblock relative h-full rounded-sm",
49
+ isDragging && "opacity-50",
50
+ "hover:outline hover:outline-2 hover:outline-primary/30",
51
+ )}
52
+ >
53
+ {/* Controls — vertical rail in the cell's LEFT gutter. Revealed on hover OR
54
+ focus-within, so an open col-span popover (which traps focus) keeps the rail
55
+ visible even when the pointer moves off the block to reach an option. */}
56
+ <div className="absolute top-0 left-0 -translate-x-full z-20 flex flex-col items-center gap-1 opacity-0 transition-opacity group-hover/childblock:opacity-100 group-focus-within/childblock:opacity-100 pointer-events-none">
57
+ <span className="sr-only">{label}</span>
58
+ <div className="pointer-events-auto">
59
+ <DragHandle ref={dragHandleRef} />
60
+ </div>
61
+ <div className="pointer-events-auto">
62
+ <ColSpanControl colSpan={colSpan} columns={columns} onChange={onColSpanChange} />
63
+ </div>
64
+ {hasSettings && onOpenSettings && (
65
+ <div className="pointer-events-auto">
66
+ <SettingsButton onClick={onOpenSettings} />
67
+ </div>
68
+ )}
69
+ <div className="pointer-events-auto">
70
+ <button
71
+ type="button"
72
+ aria-label="Duplicate block"
73
+ onClick={onDuplicate}
74
+ className="cursor-pointer rounded p-1 text-base-contrast-light hover:text-primary"
75
+ >
76
+ <CopyPlus size={16} />
77
+ </button>
78
+ </div>
79
+ <div className="pointer-events-auto">
80
+ <DeleteButton onDelete={onDelete} />
81
+ </div>
82
+ </div>
83
+
84
+ {/* Drop-edge indicators */}
85
+ {closestEdge === "left" && (
86
+ <div
87
+ data-drop-edge="left"
88
+ className="absolute bottom-0 left-0 top-0 z-10 w-0.5 -translate-x-1 bg-primary"
89
+ />
90
+ )}
91
+ {closestEdge === "right" && (
92
+ <div
93
+ data-drop-edge="right"
94
+ className="absolute bottom-0 right-0 top-0 z-10 w-0.5 translate-x-1 bg-primary"
95
+ />
96
+ )}
97
+ {closestEdge === "top" && (
98
+ <div data-drop-edge="top" className="absolute left-0 right-0 top-0 z-10 h-0.5 -translate-y-1 bg-primary" />
99
+ )}
100
+ {closestEdge === "bottom" && (
101
+ <div data-drop-edge="bottom" className="absolute left-0 right-0 bottom-0 z-10 h-0.5 translate-y-1 bg-primary" />
102
+ )}
103
+
104
+ {children}
105
+ </div>
106
+ );
107
+ },
108
+ );
@@ -0,0 +1,56 @@
1
+ import { useRef, useState } from "react";
2
+ import { Columns3 } from "lucide-react";
3
+ import { Popover } from "../shared/Popover";
4
+ import { PopoverItem } from "../shared/PopoverItem";
5
+ import { cn } from "../../lib/cn";
6
+
7
+ interface ColSpanControlProps {
8
+ colSpan: number;
9
+ columns: number;
10
+ onChange: (span: number) => void;
11
+ }
12
+
13
+ /** Contextual col-span picker — only meaningful inside a multi-column container. */
14
+ export function ColSpanControl({ colSpan, columns, onChange }: ColSpanControlProps) {
15
+ const buttonRef = useRef<HTMLButtonElement>(null);
16
+ const [open, setOpen] = useState(false);
17
+
18
+ if (columns <= 1) return null;
19
+
20
+ const current = Math.min(Math.max(colSpan, 1), columns);
21
+
22
+ return (
23
+ <div className="relative">
24
+ <button
25
+ ref={buttonRef}
26
+ type="button"
27
+ aria-label="Column span"
28
+ aria-haspopup="true"
29
+ aria-expanded={open}
30
+ onClick={() => setOpen((v) => !v)}
31
+ className="pointer-events-auto inline-flex cursor-pointer items-center gap-1 rounded p-1 text-xs text-base-contrast-light hover:text-primary"
32
+ >
33
+ <Columns3 size={14} />
34
+ </button>
35
+
36
+ <Popover isOpen={open} onClose={() => setOpen(false)} anchorRef={buttonRef} className="min-w-28">
37
+ <ul role="list" className="py-1">
38
+ {Array.from({ length: columns }, (_, i) => i + 1).map((span) => (
39
+ <li key={span}>
40
+ <PopoverItem
41
+ aria-current={span === current}
42
+ onClick={() => {
43
+ onChange(span);
44
+ setOpen(false);
45
+ }}
46
+ className={cn(span === current && "font-semibold text-primary")}
47
+ >
48
+ Span {span}
49
+ </PopoverItem>
50
+ </li>
51
+ ))}
52
+ </ul>
53
+ </Popover>
54
+ </div>
55
+ );
56
+ }
@@ -1,5 +1,5 @@
1
1
  import { useState, useRef, useEffect } from "react";
2
- import { ArrowRightLeft } from "lucide-react";
2
+ import { ArrowRightLeft, Plus } from "lucide-react";
3
3
  import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
4
4
  import { DragHandle } from "./DragHandle";
5
5
  import { InsertButton } from "./InsertButton";
@@ -11,6 +11,7 @@ import { SettingsForm } from "./SettingsForm";
11
11
  import { useEditorContext } from "../shell/EditorContext";
12
12
  import { useEditorModal } from "../shell/EditorModalContext";
13
13
  import type { WrapperProps } from "../../lib/registry";
14
+ import { buildBlockDragData, buildBlockDropData, isBlockDragData, canDropBlock } from "../../lib/block-dnd";
14
15
  import { cn } from "../../lib/cn";
15
16
 
16
17
  export function SectionWrapper({
@@ -20,6 +21,8 @@ export function SectionWrapper({
20
21
  dirty,
21
22
  index,
22
23
  isLast,
24
+ containerId,
25
+ isContainerBlock,
23
26
  definition,
24
27
  options,
25
28
  audiences,
@@ -31,6 +34,7 @@ export function SectionWrapper({
31
34
  onRequestInsert,
32
35
  onDelete,
33
36
  onMoveSection,
37
+ onAddChild,
34
38
  mainStatus,
35
39
  contentDiffersFromMain,
36
40
  isLocalOnly,
@@ -77,7 +81,7 @@ export function SectionWrapper({
77
81
  useEffect(() => {
78
82
  const block = blockRef.current;
79
83
  const handle = handleRef.current;
80
- if (!block || !handle || !onReorder) return;
84
+ if (!block || !handle || !isEditMode) return;
81
85
 
82
86
  let cleanup: (() => void) | undefined;
83
87
  let cancelled = false;
@@ -96,7 +100,13 @@ export function SectionWrapper({
96
100
  const cleanupDraggable = draggable({
97
101
  element: dropElement,
98
102
  dragHandle: handle,
99
- getInitialData: () => ({ dragType: "section", sectionId, index }),
103
+ getInitialData: () =>
104
+ buildBlockDragData({
105
+ blockId: sectionId,
106
+ containerId,
107
+ index,
108
+ isContainer: isContainerBlock,
109
+ }),
100
110
  onGenerateDragPreview: () => {
101
111
  dropElement.style.opacity = "0.4";
102
112
  requestAnimationFrame(() => {
@@ -115,31 +125,32 @@ export function SectionWrapper({
115
125
 
116
126
  const cleanupDropTarget = dropTargetForElements({
117
127
  element: dropElement,
118
- canDrop: ({ source }) => source.data.dragType === "section",
128
+ canDrop: ({ source }) =>
129
+ isBlockDragData(source.data) && canDropBlock(source.data, containerId),
119
130
  getData: ({ input, element }) =>
120
131
  attachClosestEdge(
121
- { sectionId, index },
132
+ buildBlockDropData({ dropContainerId: containerId, index }),
122
133
  { input, element, allowedEdges: ["top", "bottom"] },
123
134
  ),
124
- onDragEnter: ({ self }) => {
125
- setClosestEdge(extractClosestEdge(self.data));
126
- },
127
- onDrag: ({ self }) => {
135
+ onDrag: ({ self, source, location }) => {
136
+ const innermost = location.current.dropTargets[0]?.element === self.element;
137
+ if (
138
+ !innermost ||
139
+ !isBlockDragData(source.data) ||
140
+ source.data.blockId === sectionId
141
+ ) {
142
+ setClosestEdge(null);
143
+ return;
144
+ }
128
145
  setClosestEdge(extractClosestEdge(self.data));
129
146
  },
130
147
  onDragLeave: () => {
131
148
  setClosestEdge(null);
132
149
  },
133
- onDrop: ({ source, self }) => {
150
+ onDrop: () => {
151
+ // Central monitor (EditorContent) dispatches the move; we only
152
+ // clear local hover state here.
134
153
  setClosestEdge(null);
135
- const fromIndex = source.data.index as number;
136
- const edge = extractClosestEdge(self.data);
137
- let toIndex = index;
138
- if (edge === "bottom") toIndex = index + 1;
139
- if (fromIndex < toIndex) toIndex--;
140
- if (fromIndex !== toIndex) {
141
- onReorder(fromIndex, toIndex);
142
- }
143
154
  },
144
155
  });
145
156
 
@@ -153,7 +164,7 @@ export function SectionWrapper({
153
164
  cancelled = true;
154
165
  cleanup?.();
155
166
  };
156
- }, [sectionId, index, onReorder]);
167
+ }, [sectionId, index, containerId, isContainerBlock, isEditMode]);
157
168
 
158
169
  function handleSettingsClick() {
159
170
  if (!onSectionChange) return;
@@ -164,7 +175,8 @@ export function SectionWrapper({
164
175
  `${definition.label} Settings`,
165
176
  <CustomForm
166
177
  {...(options ?? {})}
167
- onChange={(values: Record<string, unknown>) => onSectionChange({ content: {}, options: values })}
178
+ onChange={(result: { content: Record<string, unknown>; options: Record<string, unknown> }) =>
179
+ onSectionChange(result as unknown as Record<string, unknown>)}
168
180
  />,
169
181
  );
170
182
  } else if (definition.settings) {
@@ -174,6 +186,7 @@ export function SectionWrapper({
174
186
  schema={definition.settings}
175
187
  values={options ?? {}}
176
188
  onChange={(result) => onSectionChange(result as unknown as Record<string, unknown>)}
189
+ tabs={definition.settingsTabs}
177
190
  />,
178
191
  );
179
192
  }
@@ -284,6 +297,17 @@ export function SectionWrapper({
284
297
  />
285
298
  </div>
286
299
 
300
+ {onAddChild && (
301
+ <button
302
+ type="button"
303
+ onClick={onAddChild}
304
+ aria-label="Add block"
305
+ className="pointer-events-auto cursor-pointer rounded p-1 text-base-contrast-light hover:text-primary"
306
+ >
307
+ <Plus size={16} />
308
+ </button>
309
+ )}
310
+
287
311
  {hasSettings && (
288
312
  <SettingsButton onClick={handleSettingsClick} />
289
313
  )}
@@ -5,6 +5,7 @@ import { Select } from "../shared/Select";
5
5
  import { Checkbox } from "../shared/Checkbox";
6
6
  import { FormLabel } from "../shared/FormLabel";
7
7
  import { LinkField } from "../shared/LinkField";
8
+ import { Tabs } from "../shared/Tabs";
8
9
  import type { LinkValue } from "../../schemas/link";
9
10
  import { cn } from "../../lib/cn";
10
11
 
@@ -17,6 +18,7 @@ interface SettingsFormProps {
17
18
  schema: SettingsSchema;
18
19
  values: Record<string, unknown>;
19
20
  onChange: (result: SettingsFormResult) => void;
21
+ tabs?: { label: string; fields: string[] }[];
20
22
  }
21
23
 
22
24
  function resolveValues(
@@ -37,6 +39,13 @@ function coerceValue(field: SettingsFieldDef, value: unknown): unknown {
37
39
  return value;
38
40
  }
39
41
 
42
+ function normalizeEmpty(field: SettingsFieldDef, value: unknown): unknown {
43
+ if (field.type === "select" && "emptyIsUndefined" in field && field.emptyIsUndefined && value === "") {
44
+ return undefined;
45
+ }
46
+ return value;
47
+ }
48
+
40
49
  function splitByTarget(
41
50
  schema: SettingsSchema,
42
51
  allValues: Record<string, unknown>,
@@ -44,7 +53,7 @@ function splitByTarget(
44
53
  const content: Record<string, unknown> = {};
45
54
  const options: Record<string, unknown> = {};
46
55
  for (const [key, field] of Object.entries(schema)) {
47
- const value = coerceValue(field, allValues[key]);
56
+ const value = normalizeEmpty(field, coerceValue(field, allValues[key]));
48
57
  const target = field.target ?? "options";
49
58
  if (target === "content") {
50
59
  content[key] = value;
@@ -84,7 +93,82 @@ function RangeField({
84
93
  );
85
94
  }
86
95
 
87
- export function SettingsForm({ schema, values, onChange }: SettingsFormProps) {
96
+ function renderField(
97
+ key: string,
98
+ field: SettingsFieldDef,
99
+ value: unknown,
100
+ handleFieldChange: (key: string, newValue: unknown) => void,
101
+ ) {
102
+ switch (field.type) {
103
+ case "text":
104
+ return (
105
+ <Input
106
+ key={key}
107
+ label={field.label}
108
+ value={String(value ?? "")}
109
+ placeholder={field.placeholder}
110
+ onChange={(v) => handleFieldChange(key, v)}
111
+ />
112
+ );
113
+
114
+ case "number":
115
+ return (
116
+ <Input
117
+ key={key}
118
+ label={field.label}
119
+ type="number"
120
+ value={String(value ?? "")}
121
+ onChange={(v) => handleFieldChange(key, v === "" ? "" : Number(v))}
122
+ />
123
+ );
124
+
125
+ case "checkbox":
126
+ return (
127
+ <Checkbox
128
+ key={key}
129
+ label={field.label}
130
+ checked={Boolean(value)}
131
+ onChange={(checked) => handleFieldChange(key, checked)}
132
+ />
133
+ );
134
+
135
+ case "select":
136
+ return (
137
+ <Select
138
+ key={key}
139
+ label={field.label}
140
+ value={String(value ?? "")}
141
+ options={field.options}
142
+ onChange={(v) => handleFieldChange(key, v)}
143
+ />
144
+ );
145
+
146
+ case "range":
147
+ return (
148
+ <RangeField
149
+ key={key}
150
+ field={field}
151
+ value={Number(value ?? field.min)}
152
+ onChange={(v) => handleFieldChange(key, v)}
153
+ />
154
+ );
155
+
156
+ case "link":
157
+ return (
158
+ <LinkField
159
+ key={key}
160
+ label={field.label}
161
+ value={(value as LinkValue) ?? field.default}
162
+ onChange={(v) => handleFieldChange(key, v)}
163
+ />
164
+ );
165
+
166
+ default:
167
+ return null;
168
+ }
169
+ }
170
+
171
+ export function SettingsForm({ schema, values, onChange, tabs }: SettingsFormProps) {
88
172
  const [local, setLocal] = useState(() => resolveValues(schema, values));
89
173
 
90
174
  function handleFieldChange(key: string, newValue: unknown) {
@@ -93,79 +177,22 @@ export function SettingsForm({ schema, values, onChange }: SettingsFormProps) {
93
177
  onChange(splitByTarget(schema, next));
94
178
  }
95
179
 
180
+ if (tabs) {
181
+ const fields = (keys: string[]) =>
182
+ keys.filter((key) => key in schema).map((key) => renderField(key, schema[key], local[key], handleFieldChange));
183
+
184
+ const listed = new Set(tabs.flatMap((t) => t.fields));
185
+ const otherKeys = Object.keys(schema).filter((key) => !listed.has(key));
186
+ const tabItems = [
187
+ ...tabs.map((t) => ({ id: t.label, label: t.label, content: <div className="flex flex-col gap-4">{fields(t.fields)}</div> })),
188
+ ...(otherKeys.length > 0 ? [{ id: "__other__", label: "Other", content: <div className="flex flex-col gap-4">{fields(otherKeys)}</div> }] : []),
189
+ ];
190
+ return <Tabs tabs={tabItems} fullBleedTabBar />;
191
+ }
192
+
96
193
  return (
97
194
  <div className="flex flex-col gap-4">
98
- {Object.entries(schema).map(([key, field]) => {
99
- const value = local[key];
100
-
101
- switch (field.type) {
102
- case "text":
103
- return (
104
- <Input
105
- key={key}
106
- label={field.label}
107
- value={String(value ?? "")}
108
- placeholder={field.placeholder}
109
- onChange={(v) => handleFieldChange(key, v)}
110
- />
111
- );
112
-
113
- case "number":
114
- return (
115
- <Input
116
- key={key}
117
- label={field.label}
118
- type="number"
119
- value={String(value ?? "")}
120
- onChange={(v) => handleFieldChange(key, v === "" ? "" : Number(v))}
121
- />
122
- );
123
-
124
- case "checkbox":
125
- return (
126
- <Checkbox
127
- key={key}
128
- label={field.label}
129
- checked={Boolean(value)}
130
- onChange={(checked) => handleFieldChange(key, checked)}
131
- />
132
- );
133
-
134
- case "select":
135
- return (
136
- <Select
137
- key={key}
138
- label={field.label}
139
- value={String(value ?? "")}
140
- options={field.options}
141
- onChange={(v) => handleFieldChange(key, v)}
142
- />
143
- );
144
-
145
- case "range":
146
- return (
147
- <RangeField
148
- key={key}
149
- field={field}
150
- value={Number(value ?? field.min)}
151
- onChange={(v) => handleFieldChange(key, v)}
152
- />
153
- );
154
-
155
- case "link":
156
- return (
157
- <LinkField
158
- key={key}
159
- label={field.label}
160
- value={(value as LinkValue) ?? field.default}
161
- onChange={(v) => handleFieldChange(key, v)}
162
- />
163
- );
164
-
165
- default:
166
- return null;
167
- }
168
- })}
195
+ {Object.entries(schema).map(([key, field]) => renderField(key, field, local[key], handleFieldChange))}
169
196
  </div>
170
197
  );
171
198
  }