@drawnagency/primitives 0.1.55 → 0.1.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/cookies.d.ts.map +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/{chunk-24SUF2BC.js → chunk-ICLXLWQ5.js} +13 -74
- package/dist/chunk-NSCT3AMV.js +32 -0
- package/dist/{chunk-KDGYHU36.js → chunk-PRKUXM7E.js} +35 -10
- package/dist/{chunk-PUNXQK4M.js → chunk-PYWS3MOJ.js} +12 -2
- package/dist/chunk-TG43X7JO.js +123 -0
- package/dist/chunk-VKAGMEKE.js +90 -0
- package/dist/{chunk-B5VYSTPB.js → chunk-XTK4BR27.js} +1 -1
- package/dist/components/editor/ChildBlockWrapper.d.ts +19 -0
- package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -0
- package/dist/components/editor/ColSpanControl.d.ts +9 -0
- package/dist/components/editor/ColSpanControl.d.ts.map +1 -0
- package/dist/components/editor/SectionWrapper.d.ts +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/SettingsForm.d.ts +5 -1
- package/dist/components/editor/SettingsForm.d.ts.map +1 -1
- package/dist/components/primitives/EditableGrid.d.ts.map +1 -1
- package/dist/components/primitives/IconPicker.d.ts +7 -1
- package/dist/components/primitives/IconPicker.d.ts.map +1 -1
- package/dist/components/sections/Container/Container.d.ts +20 -0
- package/dist/components/sections/Container/Container.d.ts.map +1 -0
- package/dist/components/sections/Container/ContainerSettingsForm.d.ts +17 -0
- package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -0
- package/dist/components/sections/Container/index.d.ts +11 -0
- package/dist/components/sections/Container/index.d.ts.map +1 -0
- package/dist/components/sections/IconList/IconList.d.ts +1 -0
- package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
- package/dist/components/sections/IconList/IconListSettings.d.ts +3 -4
- package/dist/components/sections/IconList/IconListSettings.d.ts.map +1 -1
- package/dist/components/sections/IconList/index.d.ts +1 -0
- package/dist/components/sections/IconList/index.d.ts.map +1 -1
- package/dist/components/sections/Media/MediaBlock.d.ts +19 -0
- package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -0
- package/dist/components/sections/{MediaGrid → Media}/index.d.ts +15 -25
- package/dist/components/sections/Media/index.d.ts.map +1 -0
- package/dist/components/sections/Prose/index.d.ts.map +1 -1
- package/dist/components/sections/Spacer/Spacer.d.ts +2 -0
- package/dist/components/sections/Spacer/Spacer.d.ts.map +1 -0
- package/dist/components/sections/Spacer/index.d.ts +6 -0
- package/dist/components/sections/Spacer/index.d.ts.map +1 -0
- package/dist/components/sections/all-sections.d.ts +140 -0
- package/dist/components/sections/all-sections.d.ts.map +1 -0
- package/dist/components/sections/register-schemas.d.ts.map +1 -1
- package/dist/components/sections/register.d.ts.map +1 -1
- package/dist/components/shared/Tabs.d.ts +24 -0
- package/dist/components/shared/Tabs.d.ts.map +1 -0
- package/dist/components/shell/EditorShell.d.ts +2 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
- package/dist/components/shell/blockMoveDispatch.d.ts +21 -0
- package/dist/components/shell/blockMoveDispatch.d.ts.map +1 -0
- package/dist/hooks/useBlockDnd.d.ts +48 -0
- package/dist/hooks/useBlockDnd.d.ts.map +1 -0
- package/dist/hooks/useEditorPublish.d.ts +2 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/index.js +69 -48
- package/dist/lib/block-dnd.d.ts +42 -0
- package/dist/lib/block-dnd.d.ts.map +1 -0
- package/dist/lib/block-move.d.ts +31 -0
- package/dist/lib/block-move.d.ts.map +1 -0
- package/dist/lib/container-grid.d.ts +29 -0
- package/dist/lib/container-grid.d.ts.map +1 -0
- package/dist/lib/container-ops.d.ts +44 -0
- package/dist/lib/container-ops.d.ts.map +1 -0
- package/dist/lib/dexie.d.ts +12 -1
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +28 -3
- package/dist/lib/index.js +10 -7
- package/dist/lib/loader.d.ts.map +1 -1
- package/dist/lib/migrate-sections-transform.d.ts +12 -0
- package/dist/lib/migrate-sections-transform.d.ts.map +1 -0
- package/dist/lib/migrate-sections-transform.js +6 -0
- package/dist/lib/registry.d.ts +39 -2
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/lib/registry.js +26 -0
- package/dist/lib/sanitize.d.ts.map +1 -1
- package/dist/schemas/block.d.ts +20 -0
- package/dist/schemas/block.d.ts.map +1 -0
- package/dist/schemas/block.js +14 -0
- package/dist/schemas/index.js +10 -2
- package/dist/schemas/link.d.ts +7 -0
- package/dist/schemas/link.d.ts.map +1 -1
- package/dist/schemas/rich-text.d.ts +9 -0
- package/dist/schemas/rich-text.d.ts.map +1 -0
- package/dist/schemas/sections.d.ts +2 -0
- package/dist/schemas/sections.d.ts.map +1 -1
- package/dist/schemas/shared.d.ts +31 -0
- package/dist/schemas/shared.d.ts.map +1 -1
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/types.d.ts +13 -1
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +13 -1
- package/src/auth/cookies.ts +6 -1
- package/src/components/brandguide/Colors.tsx +35 -33
- package/src/components/editor/ChildBlockWrapper.tsx +108 -0
- package/src/components/editor/ColSpanControl.tsx +56 -0
- package/src/components/editor/SectionWrapper.tsx +44 -20
- package/src/components/editor/SettingsForm.tsx +100 -73
- package/src/components/primitives/EditableGrid.tsx +40 -36
- package/src/components/primitives/IconPicker.tsx +116 -26
- package/src/components/sections/Container/Container.tsx +354 -0
- package/src/components/sections/Container/ContainerSettingsForm.tsx +113 -0
- package/src/components/sections/Container/index.tsx +51 -0
- package/src/components/sections/IconList/IconList.tsx +113 -43
- package/src/components/sections/IconList/IconListSettings.tsx +2 -2
- package/src/components/sections/IconList/index.tsx +1 -1
- package/src/components/sections/Media/MediaBlock.tsx +103 -0
- package/src/components/sections/Media/index.tsx +85 -0
- package/src/components/sections/Prose/index.tsx +1 -0
- package/src/components/sections/Spacer/Spacer.tsx +6 -0
- package/src/components/sections/Spacer/index.tsx +18 -0
- package/src/components/sections/all-sections.ts +40 -0
- package/src/components/sections/register-schemas.ts +13 -18
- package/src/components/sections/register.ts +3 -17
- package/src/components/shared/Tabs.tsx +63 -0
- package/src/components/shell/EditorShell.tsx +147 -18
- package/src/components/shell/SiteSettingsModal.tsx +41 -51
- package/src/components/shell/blockMoveDispatch.ts +40 -0
- package/src/hooks/useBlockDnd.ts +144 -0
- package/src/hooks/useEditorPublish.ts +17 -4
- package/src/lib/block-dnd.ts +58 -0
- package/src/lib/block-move.ts +236 -0
- package/src/lib/container-grid.ts +58 -0
- package/src/lib/container-ops.ts +159 -0
- package/src/lib/dexie.ts +47 -0
- package/src/lib/loader.ts +16 -4
- package/src/lib/migrate-sections-transform.ts +147 -0
- package/src/lib/registry.ts +48 -2
- package/src/lib/sanitize.ts +22 -1
- package/src/schemas/block.ts +40 -0
- package/src/schemas/link.ts +19 -1
- package/src/schemas/rich-text.ts +11 -0
- package/src/schemas/sections.ts +5 -1
- package/src/schemas/shared.ts +16 -0
- package/src/schemas/site-config.ts +3 -3
- package/src/storage/index.ts +1 -0
- package/src/storage/types.ts +17 -0
- package/dist/components/brandguide/DoDontList.d.ts +0 -16
- package/dist/components/brandguide/DoDontList.d.ts.map +0 -1
- package/dist/components/brandguide/DoDontMediaGrid.d.ts +0 -16
- package/dist/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
- package/dist/components/primitives/MediaSettingsForms.d.ts +0 -23
- package/dist/components/primitives/MediaSettingsForms.d.ts.map +0 -1
- package/dist/components/sections/DoDontList/index.d.ts +0 -21
- package/dist/components/sections/DoDontList/index.d.ts.map +0 -1
- package/dist/components/sections/DoDontMediaGrid/index.d.ts +0 -55
- package/dist/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
- package/dist/components/sections/MediaGrid/MediaGrid.d.ts +0 -17
- package/dist/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
- package/dist/components/sections/MediaGrid/index.d.ts.map +0 -1
- package/dist/components/sections/SplitContent/SplitContent.d.ts +0 -14
- package/dist/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
- package/dist/components/sections/SplitContent/index.d.ts +0 -13
- package/dist/components/sections/SplitContent/index.d.ts.map +0 -1
- package/src/components/brandguide/DoDontList.d.ts.map +0 -1
- package/src/components/brandguide/DoDontList.tsx +0 -67
- package/src/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
- package/src/components/brandguide/DoDontMediaGrid.tsx +0 -19
- package/src/components/primitives/MediaSettingsForms.tsx +0 -128
- package/src/components/sections/DoDontList/index.d.ts.map +0 -1
- package/src/components/sections/DoDontList/index.tsx +0 -45
- package/src/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
- package/src/components/sections/DoDontMediaGrid/index.tsx +0 -63
- package/src/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
- package/src/components/sections/MediaGrid/MediaGrid.tsx +0 -239
- package/src/components/sections/MediaGrid/index.d.ts.map +0 -1
- package/src/components/sections/MediaGrid/index.tsx +0 -57
- package/src/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
- package/src/components/sections/SplitContent/SplitContent.tsx +0 -84
- package/src/components/sections/SplitContent/index.d.ts.map +0 -1
- package/src/components/sections/SplitContent/index.tsx +0 -55
|
@@ -0,0 +1,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|