@drawnagency/primitives 0.1.55 → 0.1.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/dist/auth/cookies.d.ts.map +1 -1
  2. package/dist/auth/index.js +1 -1
  3. package/dist/{chunk-24SUF2BC.js → chunk-ICLXLWQ5.js} +13 -74
  4. package/dist/chunk-NSCT3AMV.js +32 -0
  5. package/dist/{chunk-KDGYHU36.js → chunk-PRKUXM7E.js} +35 -10
  6. package/dist/{chunk-PUNXQK4M.js → chunk-PYWS3MOJ.js} +12 -2
  7. package/dist/chunk-TG43X7JO.js +123 -0
  8. package/dist/chunk-VKAGMEKE.js +90 -0
  9. package/dist/{chunk-B5VYSTPB.js → chunk-XTK4BR27.js} +1 -1
  10. package/dist/components/editor/ChildBlockWrapper.d.ts +19 -0
  11. package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -0
  12. package/dist/components/editor/ColSpanControl.d.ts +9 -0
  13. package/dist/components/editor/ColSpanControl.d.ts.map +1 -0
  14. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  15. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  16. package/dist/components/editor/SettingsForm.d.ts +5 -1
  17. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  18. package/dist/components/primitives/EditableGrid.d.ts.map +1 -1
  19. package/dist/components/primitives/IconPicker.d.ts +7 -1
  20. package/dist/components/primitives/IconPicker.d.ts.map +1 -1
  21. package/dist/components/sections/Container/Container.d.ts +20 -0
  22. package/dist/components/sections/Container/Container.d.ts.map +1 -0
  23. package/dist/components/sections/Container/ContainerSettingsForm.d.ts +17 -0
  24. package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -0
  25. package/dist/components/sections/Container/index.d.ts +11 -0
  26. package/dist/components/sections/Container/index.d.ts.map +1 -0
  27. package/dist/components/sections/IconList/IconList.d.ts +1 -0
  28. package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
  29. package/dist/components/sections/IconList/IconListSettings.d.ts +3 -4
  30. package/dist/components/sections/IconList/IconListSettings.d.ts.map +1 -1
  31. package/dist/components/sections/IconList/index.d.ts +1 -0
  32. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  33. package/dist/components/sections/Media/MediaBlock.d.ts +19 -0
  34. package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -0
  35. package/dist/components/sections/{MediaGrid → Media}/index.d.ts +15 -25
  36. package/dist/components/sections/Media/index.d.ts.map +1 -0
  37. package/dist/components/sections/Prose/index.d.ts.map +1 -1
  38. package/dist/components/sections/Spacer/Spacer.d.ts +2 -0
  39. package/dist/components/sections/Spacer/Spacer.d.ts.map +1 -0
  40. package/dist/components/sections/Spacer/index.d.ts +6 -0
  41. package/dist/components/sections/Spacer/index.d.ts.map +1 -0
  42. package/dist/components/sections/all-sections.d.ts +140 -0
  43. package/dist/components/sections/all-sections.d.ts.map +1 -0
  44. package/dist/components/sections/register-schemas.d.ts.map +1 -1
  45. package/dist/components/sections/register.d.ts.map +1 -1
  46. package/dist/components/shared/Tabs.d.ts +24 -0
  47. package/dist/components/shared/Tabs.d.ts.map +1 -0
  48. package/dist/components/shell/EditorShell.d.ts +2 -1
  49. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  50. package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
  51. package/dist/components/shell/blockMoveDispatch.d.ts +21 -0
  52. package/dist/components/shell/blockMoveDispatch.d.ts.map +1 -0
  53. package/dist/hooks/useBlockDnd.d.ts +48 -0
  54. package/dist/hooks/useBlockDnd.d.ts.map +1 -0
  55. package/dist/hooks/useEditorPublish.d.ts +2 -1
  56. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  57. package/dist/index.js +69 -48
  58. package/dist/lib/block-dnd.d.ts +42 -0
  59. package/dist/lib/block-dnd.d.ts.map +1 -0
  60. package/dist/lib/block-move.d.ts +31 -0
  61. package/dist/lib/block-move.d.ts.map +1 -0
  62. package/dist/lib/container-grid.d.ts +29 -0
  63. package/dist/lib/container-grid.d.ts.map +1 -0
  64. package/dist/lib/container-ops.d.ts +44 -0
  65. package/dist/lib/container-ops.d.ts.map +1 -0
  66. package/dist/lib/dexie.d.ts +12 -1
  67. package/dist/lib/dexie.d.ts.map +1 -1
  68. package/dist/lib/dexie.js +28 -3
  69. package/dist/lib/index.js +10 -7
  70. package/dist/lib/loader.d.ts.map +1 -1
  71. package/dist/lib/migrate-sections-transform.d.ts +12 -0
  72. package/dist/lib/migrate-sections-transform.d.ts.map +1 -0
  73. package/dist/lib/migrate-sections-transform.js +6 -0
  74. package/dist/lib/registry.d.ts +39 -2
  75. package/dist/lib/registry.d.ts.map +1 -1
  76. package/dist/lib/registry.js +26 -0
  77. package/dist/lib/sanitize.d.ts.map +1 -1
  78. package/dist/schemas/block.d.ts +20 -0
  79. package/dist/schemas/block.d.ts.map +1 -0
  80. package/dist/schemas/block.js +14 -0
  81. package/dist/schemas/index.js +10 -2
  82. package/dist/schemas/link.d.ts +7 -0
  83. package/dist/schemas/link.d.ts.map +1 -1
  84. package/dist/schemas/rich-text.d.ts +9 -0
  85. package/dist/schemas/rich-text.d.ts.map +1 -0
  86. package/dist/schemas/sections.d.ts +2 -0
  87. package/dist/schemas/sections.d.ts.map +1 -1
  88. package/dist/schemas/shared.d.ts +31 -0
  89. package/dist/schemas/shared.d.ts.map +1 -1
  90. package/dist/storage/index.d.ts +1 -0
  91. package/dist/storage/index.d.ts.map +1 -1
  92. package/dist/storage/types.d.ts +13 -1
  93. package/dist/storage/types.d.ts.map +1 -1
  94. package/package.json +13 -1
  95. package/src/auth/cookies.ts +6 -1
  96. package/src/components/brandguide/Colors.tsx +35 -33
  97. package/src/components/editor/ChildBlockWrapper.tsx +108 -0
  98. package/src/components/editor/ColSpanControl.tsx +56 -0
  99. package/src/components/editor/SectionWrapper.tsx +44 -20
  100. package/src/components/editor/SettingsForm.tsx +100 -73
  101. package/src/components/primitives/EditableGrid.tsx +40 -36
  102. package/src/components/primitives/IconPicker.tsx +116 -26
  103. package/src/components/sections/Container/Container.tsx +354 -0
  104. package/src/components/sections/Container/ContainerSettingsForm.tsx +113 -0
  105. package/src/components/sections/Container/index.tsx +51 -0
  106. package/src/components/sections/IconList/IconList.tsx +113 -43
  107. package/src/components/sections/IconList/IconListSettings.tsx +2 -2
  108. package/src/components/sections/IconList/index.tsx +1 -1
  109. package/src/components/sections/Media/MediaBlock.tsx +103 -0
  110. package/src/components/sections/Media/index.tsx +85 -0
  111. package/src/components/sections/Prose/index.tsx +1 -0
  112. package/src/components/sections/Spacer/Spacer.tsx +6 -0
  113. package/src/components/sections/Spacer/index.tsx +18 -0
  114. package/src/components/sections/all-sections.ts +40 -0
  115. package/src/components/sections/register-schemas.ts +13 -18
  116. package/src/components/sections/register.ts +3 -17
  117. package/src/components/shared/Tabs.tsx +63 -0
  118. package/src/components/shell/EditorShell.tsx +147 -18
  119. package/src/components/shell/SiteSettingsModal.tsx +41 -51
  120. package/src/components/shell/blockMoveDispatch.ts +40 -0
  121. package/src/hooks/useBlockDnd.ts +144 -0
  122. package/src/hooks/useEditorPublish.ts +17 -4
  123. package/src/lib/block-dnd.ts +58 -0
  124. package/src/lib/block-move.ts +236 -0
  125. package/src/lib/container-grid.ts +58 -0
  126. package/src/lib/container-ops.ts +159 -0
  127. package/src/lib/dexie.ts +47 -0
  128. package/src/lib/loader.ts +16 -4
  129. package/src/lib/migrate-sections-transform.ts +147 -0
  130. package/src/lib/registry.ts +48 -2
  131. package/src/lib/sanitize.ts +22 -1
  132. package/src/schemas/block.ts +40 -0
  133. package/src/schemas/link.ts +19 -1
  134. package/src/schemas/rich-text.ts +11 -0
  135. package/src/schemas/sections.ts +5 -1
  136. package/src/schemas/shared.ts +16 -0
  137. package/src/schemas/site-config.ts +3 -3
  138. package/src/storage/index.ts +1 -0
  139. package/src/storage/types.ts +17 -0
  140. package/dist/components/brandguide/DoDontList.d.ts +0 -16
  141. package/dist/components/brandguide/DoDontList.d.ts.map +0 -1
  142. package/dist/components/brandguide/DoDontMediaGrid.d.ts +0 -16
  143. package/dist/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
  144. package/dist/components/primitives/MediaSettingsForms.d.ts +0 -23
  145. package/dist/components/primitives/MediaSettingsForms.d.ts.map +0 -1
  146. package/dist/components/sections/DoDontList/index.d.ts +0 -21
  147. package/dist/components/sections/DoDontList/index.d.ts.map +0 -1
  148. package/dist/components/sections/DoDontMediaGrid/index.d.ts +0 -55
  149. package/dist/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
  150. package/dist/components/sections/MediaGrid/MediaGrid.d.ts +0 -17
  151. package/dist/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
  152. package/dist/components/sections/MediaGrid/index.d.ts.map +0 -1
  153. package/dist/components/sections/SplitContent/SplitContent.d.ts +0 -14
  154. package/dist/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
  155. package/dist/components/sections/SplitContent/index.d.ts +0 -13
  156. package/dist/components/sections/SplitContent/index.d.ts.map +0 -1
  157. package/src/components/brandguide/DoDontList.d.ts.map +0 -1
  158. package/src/components/brandguide/DoDontList.tsx +0 -67
  159. package/src/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
  160. package/src/components/brandguide/DoDontMediaGrid.tsx +0 -19
  161. package/src/components/primitives/MediaSettingsForms.tsx +0 -128
  162. package/src/components/sections/DoDontList/index.d.ts.map +0 -1
  163. package/src/components/sections/DoDontList/index.tsx +0 -45
  164. package/src/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
  165. package/src/components/sections/DoDontMediaGrid/index.tsx +0 -63
  166. package/src/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
  167. package/src/components/sections/MediaGrid/MediaGrid.tsx +0 -239
  168. package/src/components/sections/MediaGrid/index.d.ts.map +0 -1
  169. package/src/components/sections/MediaGrid/index.tsx +0 -57
  170. package/src/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
  171. package/src/components/sections/SplitContent/SplitContent.tsx +0 -84
  172. package/src/components/sections/SplitContent/index.d.ts.map +0 -1
  173. package/src/components/sections/SplitContent/index.tsx +0 -55
@@ -0,0 +1,144 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
3
+ import { buildBlockDragData, buildBlockDropData, isBlockDragData, canDropBlock } from "../lib/block-dnd";
4
+
5
+ type AllowedEdges = Edge[];
6
+
7
+ export interface UseBlockDndArgs {
8
+ blockId: string;
9
+ containerId: string;
10
+ index: number;
11
+ isContainer: boolean;
12
+ /** ["left","right"] inside a row container, ["top","bottom"] at root */
13
+ allowedEdges: AllowedEdges;
14
+ enabled: boolean;
15
+ }
16
+
17
+ /**
18
+ * Wires a block as a unified drag source + drop target. Manages only this block's
19
+ * local hover edge + dragging flag — the actual move dispatch is centralized in the
20
+ * EditorContent monitor (a later task), so nested targets never double-fire. The edge
21
+ * indicator is shown only when this element is the innermost drop target.
22
+ *
23
+ * Callers must pass a stable `allowedEdges` array (module-level constant or memoized)
24
+ * to avoid re-subscribing on every render.
25
+ */
26
+ export function useBlockDnd({ blockId, containerId, index, isContainer, allowedEdges, enabled }: UseBlockDndArgs) {
27
+ const dragRef = useRef<HTMLDivElement>(null);
28
+ const handleRef = useRef<HTMLButtonElement>(null);
29
+ const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
30
+ const [isDragging, setIsDragging] = useState(false);
31
+
32
+ useEffect(() => {
33
+ const element = dragRef.current;
34
+ const handle = handleRef.current;
35
+ if (!element || !enabled) return;
36
+
37
+ let cleanup: (() => void) | undefined;
38
+ let cancelled = false;
39
+
40
+ Promise.all([
41
+ import("@atlaskit/pragmatic-drag-and-drop/element/adapter"),
42
+ import("@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"),
43
+ ]).then(([{ draggable, dropTargetForElements }, { attachClosestEdge, extractClosestEdge }]) => {
44
+ if (cancelled) return;
45
+
46
+ const cleanupDraggable = draggable({
47
+ element,
48
+ dragHandle: handle ?? undefined,
49
+ getInitialData: () => buildBlockDragData({ blockId, containerId, index, isContainer }),
50
+ onGenerateDragPreview: () => {
51
+ element.style.opacity = "0.4";
52
+ requestAnimationFrame(() => { element.style.opacity = ""; });
53
+ },
54
+ onDragStart: () => setIsDragging(true),
55
+ onDrop: () => setIsDragging(false),
56
+ });
57
+
58
+ const cleanupDrop = dropTargetForElements({
59
+ element,
60
+ canDrop: ({ source }) => isBlockDragData(source.data) && canDropBlock(source.data, containerId),
61
+ getData: ({ input, element: el }) =>
62
+ attachClosestEdge(buildBlockDropData({ dropContainerId: containerId, index }), {
63
+ input, element: el, allowedEdges,
64
+ }),
65
+ onDrag: ({ self, source, location }) => {
66
+ const innermost = location.current.dropTargets[0]?.element === self.element;
67
+ if (!innermost || !isBlockDragData(source.data) || source.data.blockId === blockId) {
68
+ setClosestEdge(null);
69
+ return;
70
+ }
71
+ setClosestEdge(extractClosestEdge(self.data));
72
+ },
73
+ onDragLeave: () => setClosestEdge(null),
74
+ onDrop: () => setClosestEdge(null),
75
+ });
76
+
77
+ cleanup = () => { cleanupDraggable(); cleanupDrop(); };
78
+ });
79
+
80
+ return () => { cancelled = true; cleanup?.(); };
81
+ }, [blockId, containerId, index, isContainer, enabled, allowedEdges]);
82
+
83
+ return { dragRef, handleRef, closestEdge, isDragging };
84
+ }
85
+
86
+ export interface UseBlockDropZoneArgs {
87
+ containerId: string;
88
+ index: number;
89
+ /** When set, the zone is a column-targeted drop (ghost/spacer): place at this 1-based column. */
90
+ toColumn?: number;
91
+ allowedEdges: AllowedEdges;
92
+ enabled: boolean;
93
+ }
94
+
95
+ /**
96
+ * A drop-target-ONLY zone (not draggable) — used for an empty container's first-drop
97
+ * area, where there are no child cells to act as targets. It advertises
98
+ * `{ dropContainerId, index }` and shows an edge indicator only when it is the innermost
99
+ * drop target. The actual move dispatch is centralized in the EditorContent monitor.
100
+ *
101
+ * Callers must pass a stable `allowedEdges` array (module-level constant or memoized).
102
+ */
103
+ export function useBlockDropZone({ containerId, index, toColumn, allowedEdges, enabled }: UseBlockDropZoneArgs) {
104
+ const dropRef = useRef<HTMLDivElement>(null);
105
+ const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
106
+
107
+ useEffect(() => {
108
+ const element = dropRef.current;
109
+ if (!element || !enabled) return;
110
+
111
+ let cleanup: (() => void) | undefined;
112
+ let cancelled = false;
113
+
114
+ Promise.all([
115
+ import("@atlaskit/pragmatic-drag-and-drop/element/adapter"),
116
+ import("@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"),
117
+ ]).then(([{ dropTargetForElements }, { attachClosestEdge, extractClosestEdge }]) => {
118
+ if (cancelled) return;
119
+
120
+ cleanup = dropTargetForElements({
121
+ element,
122
+ canDrop: ({ source }) => isBlockDragData(source.data) && canDropBlock(source.data, containerId),
123
+ getData: ({ input, element: el }) =>
124
+ attachClosestEdge(buildBlockDropData({ dropContainerId: containerId, index, toColumn }), {
125
+ input, element: el, allowedEdges,
126
+ }),
127
+ onDrag: ({ self, source, location }) => {
128
+ const innermost = location.current.dropTargets[0]?.element === self.element;
129
+ if (!innermost || !isBlockDragData(source.data)) {
130
+ setClosestEdge(null);
131
+ return;
132
+ }
133
+ setClosestEdge(extractClosestEdge(self.data));
134
+ },
135
+ onDragLeave: () => setClosestEdge(null),
136
+ onDrop: () => setClosestEdge(null),
137
+ });
138
+ });
139
+
140
+ return () => { cancelled = true; cleanup?.(); };
141
+ }, [containerId, index, toColumn, enabled, allowedEdges]);
142
+
143
+ return { dropRef, closestEdge };
144
+ }
@@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect } from "react";
2
2
  import type { SiteIndex, SiteConfig } from "../schemas/site-config";
3
3
  import type { LoadedSection } from "../lib/loader";
4
4
  import type { MediaManifest, MediaItem } from "../media/types";
5
- import { getDirtySectionRows, hasLocalChanges, discardSavedChanges, cacheContent, getPendingMediaBlobs, clearPendingMediaByIds } from "../lib/dexie";
5
+ import { getDirtySectionRows, hasLocalChanges, discardSavedChanges, cacheContent, clearContentCache, getPendingMediaBlobs, clearPendingMediaByIds } from "../lib/dexie";
6
6
 
7
7
  function blobToBase64(blob: Blob): Promise<string> {
8
8
  return new Promise((resolve, reject) => {
@@ -34,6 +34,10 @@ interface PublishDeps {
34
34
  onMediaPublished: (publishedItems: MediaItem[], publishedDeletions: string[]) => void;
35
35
  onShasUpdated: (savedSha: string | null, mainSha: string | null) => void;
36
36
  onPublishComplete?: () => void;
37
+ // The draft version this editor is based on (saved-branch head, or null when
38
+ // no draft exists yet). Sent with saves so the server can 409 on a concurrent
39
+ // edit instead of silently overwriting it. Read at save time (latest value).
40
+ getBaseVersion: () => string | null;
37
41
  }
38
42
 
39
43
  interface GatheredMedia {
@@ -62,6 +66,7 @@ export function useEditorPublish({
62
66
  onMediaPublished,
63
67
  onShasUpdated,
64
68
  onPublishComplete,
69
+ getBaseVersion,
65
70
  }: PublishDeps) {
66
71
  const [publishAction, setPublishAction] = useState<PublishAction>("idle");
67
72
  const [publishFeedback, setPublishFeedback] = useState<string | null>(null);
@@ -172,6 +177,7 @@ export function useEditorPublish({
172
177
  content,
173
178
  })),
174
179
  siteIndex,
180
+ baseVersion: getBaseVersion(),
175
181
  ...(args.deletedSectionIds?.length ? { deletedSectionIds: args.deletedSectionIds } : {}),
176
182
  ...(args.isConfigDirty() ? { siteConfig: args.siteConfig } : {}),
177
183
  ...(gathered.hasMediaChanges ? {
@@ -189,7 +195,8 @@ export function useEditorPublish({
189
195
 
190
196
  if (!response.ok) {
191
197
  const errorBody = await response.json().catch(() => ({}));
192
- throw new Error(errorBody.error || "Save failed");
198
+ const message = errorBody.message || errorBody.error || "Save failed";
199
+ throw Object.assign(new Error(message), { status: response.status });
193
200
  }
194
201
 
195
202
  const responseData = await response.json().catch(() => null);
@@ -247,7 +254,8 @@ export function useEditorPublish({
247
254
  showFeedback("Saved", 3000);
248
255
  } catch (error) {
249
256
  console.error("Save failed:", error);
250
- showFeedback("Save failed", 5000);
257
+ const isConflict = (error as { status?: number })?.status === 409;
258
+ showFeedback(isConflict ? (error as Error).message : "Save failed", isConflict ? 8000 : 5000);
251
259
  } finally {
252
260
  inFlightRef.current = false;
253
261
  setPublishAction("idle");
@@ -275,6 +283,10 @@ export function useEditorPublish({
275
283
  }
276
284
  const { sha } = responseData;
277
285
 
286
+ // The draft branch is gone after publish — drop the cached content snapshot
287
+ // so its now-stale diff state (savedBranchSha/changedSectionIds) can't be
288
+ // restored on a later cache hit. (Save & Publish already re-caches instead.)
289
+ await clearContentCache();
278
290
  onShasUpdated(null, sha);
279
291
  onPublishComplete?.();
280
292
  } catch (error) {
@@ -342,7 +354,8 @@ export function useEditorPublish({
342
354
  onPublishComplete?.();
343
355
  } catch (error) {
344
356
  console.error("Publish failed:", error);
345
- showFeedback("Publish failed", 5000);
357
+ const isConflict = (error as { status?: number })?.status === 409;
358
+ showFeedback(isConflict ? (error as Error).message : "Publish failed", isConflict ? 8000 : 5000);
346
359
  } finally {
347
360
  inFlightRef.current = false;
348
361
  setPublishAction("idle");
@@ -0,0 +1,58 @@
1
+ import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
2
+
3
+ /** The page root behaves like a single-column container (spec §3). */
4
+ export const ROOT_CONTAINER_ID = "__root__";
5
+
6
+ export interface BlockDragData {
7
+ dragType: "block";
8
+ blockId: string;
9
+ /** Container the block currently lives in: ROOT_CONTAINER_ID or a container section id. */
10
+ containerId: string;
11
+ index: number;
12
+ /** True if the dragged block is itself a container (used for the depth guard). */
13
+ isContainer: boolean;
14
+ [key: string | symbol]: unknown;
15
+ }
16
+
17
+ export interface BlockDropData {
18
+ dropContainerId: string;
19
+ index: number;
20
+ /** When set, a column-targeted drop (ghost/spacer cell): place at this 1-based grid column. */
21
+ toColumn?: number;
22
+ [key: string | symbol]: unknown;
23
+ }
24
+
25
+ export function buildBlockDragData(args: {
26
+ blockId: string;
27
+ containerId: string;
28
+ index: number;
29
+ isContainer: boolean;
30
+ }): BlockDragData {
31
+ return { dragType: "block", ...args };
32
+ }
33
+
34
+ export function buildBlockDropData(args: { dropContainerId: string; index: number; toColumn?: number }): BlockDropData {
35
+ return { ...args };
36
+ }
37
+
38
+ export function isBlockDragData(data: Record<string | symbol, unknown>): data is BlockDragData {
39
+ return data.dragType === "block";
40
+ }
41
+
42
+ /**
43
+ * May `source` drop into `dropContainerId`?
44
+ * - A container block may only land at the page root (v1 one-level nesting → no
45
+ * container-in-container; MAX_BLOCK_DEPTH=2).
46
+ * - Nothing may drop onto itself.
47
+ */
48
+ export function canDropBlock(source: BlockDragData, dropContainerId: string): boolean {
49
+ if (source.blockId === dropContainerId) return false;
50
+ if (source.isContainer && dropContainerId !== ROOT_CONTAINER_ID) return false;
51
+ return true;
52
+ }
53
+
54
+ /** Closest edge → insertion index (before = index, after = index + 1). */
55
+ export function resolveDropIndex(targetIndex: number, edge: Edge | null): number {
56
+ if (edge === "left" || edge === "top") return targetIndex;
57
+ return targetIndex + 1; // right / bottom / null
58
+ }
@@ -0,0 +1,236 @@
1
+ import type { LoadedSection } from "./loader";
2
+ import type { Section } from "../schemas/sections";
3
+ import type { SiteIndex, SectionMeta } from "../schemas/site-config";
4
+ import { ROOT_CONTAINER_ID, canDropBlock } from "./block-dnd";
5
+ import { getContainerChildren, reorderChild, insertChildAt, removeChildAt, placeBlockAtColumn, trimTrailingSpacers } from "./container-ops";
6
+ import { reorderSectionInPage, addSectionToPage, removeSectionFromPages } from "./pages";
7
+
8
+ export interface BlockMoveState {
9
+ sections: LoadedSection[];
10
+ index: SiteIndex;
11
+ rootPageId: string;
12
+ }
13
+
14
+ export interface BlockMove {
15
+ blockId: string;
16
+ fromContainerId: string;
17
+ fromIndex: number;
18
+ toContainerId: string;
19
+ /** Raw insertion index in the ORIGINAL (pre-removal) array, 0..len — as resolveDropIndex returns. applyBlockMove shifts it for same-container moves. */
20
+ toIndex: number;
21
+ /** When set, place at this 1-based grid column (ghost/spacer drop) instead of toIndex. */
22
+ toColumn?: number;
23
+ }
24
+
25
+ export interface BlockMoveEffects {
26
+ markDirtySectionIds: string[];
27
+ indexDirty: boolean;
28
+ deleteSectionIds: string[];
29
+ createSectionIds: string[];
30
+ }
31
+
32
+ export interface BlockMoveResult {
33
+ sections: LoadedSection[];
34
+ index: SiteIndex;
35
+ effects: BlockMoveEffects;
36
+ changed: boolean;
37
+ }
38
+
39
+ function defaultNewId(): string {
40
+ return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
41
+ ? crypto.randomUUID()
42
+ : `block-${Math.random().toString(36).slice(2)}`;
43
+ }
44
+
45
+ const DEFAULT_CHILD_META = (type: string): SectionMeta => ({ type, status: "draft", access: [] });
46
+
47
+ function isContainerSection(section: Section): boolean {
48
+ return Array.isArray((section.content as { children?: unknown })?.children);
49
+ }
50
+
51
+ function mapContainerSection(sections: LoadedSection[], id: string, fn: (s: Section) => Section): LoadedSection[] {
52
+ return sections.map((l) => (l.section.id === id ? { ...l, section: fn(l.section) } : l));
53
+ }
54
+
55
+ function noop(state: BlockMoveState): BlockMoveResult {
56
+ return {
57
+ sections: state.sections,
58
+ index: state.index,
59
+ effects: { markDirtySectionIds: [], indexDirty: false, deleteSectionIds: [], createSectionIds: [] },
60
+ changed: false,
61
+ };
62
+ }
63
+
64
+ export function applyBlockMove(
65
+ state: BlockMoveState,
66
+ move: BlockMove,
67
+ newId: () => string = defaultNewId,
68
+ ): BlockMoveResult {
69
+ const { sections, index, rootPageId } = state;
70
+ const { blockId, fromContainerId, fromIndex, toContainerId, toIndex } = move;
71
+
72
+ if (move.toColumn != null) return applyColumnPlacement(state, move, newId);
73
+
74
+ const draggedSection = findBlock(sections, fromContainerId, fromIndex, blockId);
75
+ if (!draggedSection) return noop(state);
76
+ const dragData = {
77
+ dragType: "block" as const,
78
+ blockId,
79
+ containerId: fromContainerId,
80
+ index: fromIndex,
81
+ isContainer: isContainerSection(draggedSection),
82
+ };
83
+ if (!canDropBlock(dragData, toContainerId)) return noop(state);
84
+
85
+ if (fromContainerId === toContainerId) {
86
+ // toIndex is the raw insertion index in the original (pre-removal) array.
87
+ // Both reorderSectionInPage and reorderChild use splice(from,1)+splice(to,0,moved),
88
+ // so we must convert to the post-removal position for same-container moves.
89
+ const adjusted = fromIndex < toIndex ? toIndex - 1 : toIndex;
90
+ if (fromContainerId === ROOT_CONTAINER_ID) {
91
+ const nextIndex = reorderSectionInPage(index, rootPageId, fromIndex, adjusted);
92
+ if (nextIndex === index) return noop(state);
93
+ return {
94
+ sections,
95
+ index: nextIndex,
96
+ effects: { markDirtySectionIds: [], indexDirty: true, deleteSectionIds: [], createSectionIds: [] },
97
+ changed: true,
98
+ };
99
+ }
100
+ const nextSections = mapContainerSection(sections, fromContainerId, (c) => reorderChild(c, fromIndex, adjusted));
101
+ return {
102
+ sections: nextSections,
103
+ index,
104
+ effects: { markDirtySectionIds: [fromContainerId], indexDirty: false, deleteSectionIds: [], createSectionIds: [] },
105
+ changed: true,
106
+ };
107
+ }
108
+
109
+ const markDirty = new Set<string>();
110
+ const deleteSectionIds: string[] = [];
111
+ const createSectionIds: string[] = [];
112
+ let indexDirty = false;
113
+ let nextSections = sections;
114
+ let nextIndex = index;
115
+
116
+ let moving: Section;
117
+ if (fromContainerId === ROOT_CONTAINER_ID) {
118
+ moving = draggedSection;
119
+ nextSections = nextSections.filter((l) => l.section.id !== blockId);
120
+ nextIndex = removeSectionFromPages(nextIndex, blockId);
121
+ deleteSectionIds.push(blockId);
122
+ indexDirty = true;
123
+ } else {
124
+ const src = nextSections.find((l) => l.section.id === fromContainerId);
125
+ if (!src) return noop(state);
126
+ const { container, removed } = removeChildAt(src.section, fromIndex);
127
+ if (!removed) return noop(state);
128
+ moving = removed;
129
+ nextSections = mapContainerSection(nextSections, fromContainerId, () => container);
130
+ markDirty.add(fromContainerId);
131
+ }
132
+
133
+ if (toContainerId === ROOT_CONTAINER_ID) {
134
+ const { layout: _dropLayout, ...rootBlock } = moving as Section & { layout?: unknown };
135
+ const meta = DEFAULT_CHILD_META(rootBlock.type);
136
+ nextIndex = addSectionToPage(nextIndex, rootPageId, rootBlock.id, meta, toIndex);
137
+ nextSections = [...nextSections, { section: rootBlock as Section, meta }];
138
+ indexDirty = true;
139
+ createSectionIds.push(rootBlock.id);
140
+ markDirty.add(rootBlock.id);
141
+ } else {
142
+ const dest = nextSections.find((l) => l.section.id === toContainerId);
143
+ if (!dest) return noop(state);
144
+ nextSections = mapContainerSection(nextSections, toContainerId, (c) => insertChildAt(c, moving, toIndex));
145
+ markDirty.add(toContainerId);
146
+ }
147
+
148
+ return {
149
+ sections: nextSections,
150
+ index: nextIndex,
151
+ effects: { markDirtySectionIds: [...markDirty], indexDirty, deleteSectionIds, createSectionIds },
152
+ changed: true,
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Column-targeted placement (a ghost/spacer drop). Removes the dragged block from its source
158
+ * (root or a container), then places it at 1-based column `toColumn` of the destination
159
+ * container — padding intervening empty columns with spacers, or replacing a spacer already at
160
+ * that column. Root is never a column target (it has no columns).
161
+ */
162
+ function applyColumnPlacement(
163
+ state: BlockMoveState,
164
+ move: BlockMove,
165
+ newId: () => string,
166
+ ): BlockMoveResult {
167
+ const { sections, index } = state;
168
+ const { blockId, fromContainerId, fromIndex, toContainerId } = move;
169
+ const toColumn = move.toColumn!;
170
+
171
+ if (toContainerId === ROOT_CONTAINER_ID) return noop(state);
172
+
173
+ const draggedSection = findBlock(sections, fromContainerId, fromIndex, blockId);
174
+ if (!draggedSection) return noop(state);
175
+ const dragData = {
176
+ dragType: "block" as const,
177
+ blockId,
178
+ containerId: fromContainerId,
179
+ index: fromIndex,
180
+ isContainer: isContainerSection(draggedSection),
181
+ };
182
+ if (!canDropBlock(dragData, toContainerId)) return noop(state);
183
+
184
+ const markDirty = new Set<string>();
185
+ const deleteSectionIds: string[] = [];
186
+ let indexDirty = false;
187
+ let nextSections = sections;
188
+ let nextIndex = index;
189
+ let moving: Section;
190
+
191
+ if (fromContainerId === ROOT_CONTAINER_ID) {
192
+ moving = draggedSection;
193
+ nextSections = nextSections.filter((l) => l.section.id !== blockId);
194
+ nextIndex = removeSectionFromPages(nextIndex, blockId);
195
+ deleteSectionIds.push(blockId);
196
+ indexDirty = true;
197
+ } else {
198
+ const src = nextSections.find((l) => l.section.id === fromContainerId);
199
+ if (!src) return noop(state);
200
+ const { container, removed } = removeChildAt(src.section, fromIndex);
201
+ if (!removed) return noop(state);
202
+ moving = removed;
203
+ nextSections = mapContainerSection(nextSections, fromContainerId, () => trimTrailingSpacers(container));
204
+ markDirty.add(fromContainerId);
205
+ }
206
+
207
+ const dest = nextSections.find((l) => l.section.id === toContainerId);
208
+ if (!dest) return noop(state);
209
+ const columns = ((dest.section.content as { columns?: number })?.columns) ?? 1;
210
+ nextSections = mapContainerSection(nextSections, toContainerId, (c) =>
211
+ placeBlockAtColumn(c, moving, toColumn, columns, newId),
212
+ );
213
+ markDirty.add(toContainerId);
214
+
215
+ return {
216
+ sections: nextSections,
217
+ index: nextIndex,
218
+ effects: { markDirtySectionIds: [...markDirty], indexDirty, deleteSectionIds, createSectionIds: [] },
219
+ changed: true,
220
+ };
221
+ }
222
+
223
+ function findBlock(
224
+ sections: LoadedSection[],
225
+ containerId: string,
226
+ index: number,
227
+ blockId: string,
228
+ ): Section | null {
229
+ if (containerId === ROOT_CONTAINER_ID) {
230
+ return sections.find((l) => l.section.id === blockId)?.section ?? null;
231
+ }
232
+ const host = sections.find((l) => l.section.id === containerId);
233
+ if (!host) return null;
234
+ const children = getContainerChildren(host.section);
235
+ return children[index]?.id === blockId ? children[index] : (children.find((c) => c.id === blockId) ?? null);
236
+ }
@@ -0,0 +1,58 @@
1
+ /** Max columns a container exposes in v1 (covers halves/thirds/sixths via spans). */
2
+ export const MAX_CONTAINER_COLUMNS = 6;
3
+
4
+ /**
5
+ * Column count -> grid-cols classes that collapse by the CONTAINER's own width
6
+ * (Tailwind 4 container-query `@` variants), not the viewport. Full static
7
+ * strings so Tailwind's source scan generates them (no interpolation/purge gap).
8
+ */
9
+ export const containerGridClass: Record<number, string> = {
10
+ 1: "grid-cols-1",
11
+ 2: "grid-cols-1 @sm:grid-cols-2",
12
+ 3: "grid-cols-1 @sm:grid-cols-2 @lg:grid-cols-3",
13
+ 4: "grid-cols-1 @xs:grid-cols-2 @lg:grid-cols-4",
14
+ 5: "grid-cols-1 @xs:grid-cols-2 @md:grid-cols-3 @xl:grid-cols-5",
15
+ 6: "grid-cols-1 @xs:grid-cols-2 @md:grid-cols-3 @2xl:grid-cols-6",
16
+ };
17
+
18
+ /**
19
+ * Per-child colSpan -> col-span classes, keyed [columns][span]. A child's span
20
+ * must clamp to the columns AVAILABLE at each container-query breakpoint, and the
21
+ * collapse curve differs per column count — so the class depends on BOTH the
22
+ * parent's column count and the child's span, not span alone. At every breakpoint
23
+ * the emitted span = min(span, columns-at-that-breakpoint), guaranteeing a span
24
+ * never exceeds the tracks that exist (no phantom column). Span 1 needs no class.
25
+ * Full static strings so Tailwind's source scan generates them.
26
+ */
27
+ export const colSpanClass: Record<number, Record<number, string>> = {
28
+ 1: { 1: "" },
29
+ 2: { 1: "", 2: "col-span-1 @sm:col-span-2" },
30
+ 3: { 1: "", 2: "col-span-1 @sm:col-span-2", 3: "col-span-1 @sm:col-span-2 @lg:col-span-3" },
31
+ 4: { 1: "", 2: "col-span-1 @xs:col-span-2", 3: "col-span-1 @xs:col-span-2 @lg:col-span-3", 4: "col-span-1 @xs:col-span-2 @lg:col-span-4" },
32
+ 5: { 1: "", 2: "col-span-1 @xs:col-span-2", 3: "col-span-1 @xs:col-span-2 @md:col-span-3", 4: "col-span-1 @xs:col-span-2 @md:col-span-3 @xl:col-span-4", 5: "col-span-1 @xs:col-span-2 @md:col-span-3 @xl:col-span-5" },
33
+ 6: { 1: "", 2: "col-span-1 @xs:col-span-2", 3: "col-span-1 @xs:col-span-2 @md:col-span-3", 4: "col-span-1 @xs:col-span-2 @md:col-span-3 @2xl:col-span-4", 5: "col-span-1 @xs:col-span-2 @md:col-span-3 @2xl:col-span-5", 6: "col-span-1 @xs:col-span-2 @md:col-span-3 @2xl:col-span-6" },
34
+ };
35
+
36
+ /** Gap token -> Tailwind gap class. Retained as a utility; the container now uses a
37
+ * fixed gap (the Gap setting was removed), but the map stays for general use. */
38
+ export const gapClass: Record<string, string> = {
39
+ none: "gap-0",
40
+ sm: "gap-2",
41
+ md: "gap-4",
42
+ lg: "gap-8",
43
+ };
44
+
45
+ /**
46
+ * Per-column-count container-query classes that HIDE a spacer cell once the container
47
+ * collapses to a single column (mobile). Mirrors `containerGridClass`'s collapse curve:
48
+ * hidden at base width, shown at the first breakpoint where the grid has ≥2 columns.
49
+ * Static strings so Tailwind's source scan generates them.
50
+ */
51
+ export const spacerHiddenClass: Record<number, string> = {
52
+ 1: "hidden",
53
+ 2: "hidden @sm:block",
54
+ 3: "hidden @sm:block",
55
+ 4: "hidden @xs:block",
56
+ 5: "hidden @xs:block",
57
+ 6: "hidden @xs:block",
58
+ };