@morphika/andami 0.4.2 → 0.5.0
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/components/builder/ColumnDragContext.tsx +5 -0
- package/components/builder/ColumnDragOverlay.tsx +38 -11
- package/components/builder/InsertionLines.tsx +9 -1
- package/components/builder/SectionV2Canvas.tsx +13 -3
- package/components/builder/hooks/useColumnDrag.ts +206 -132
- package/lib/builder/store-helpers.ts +302 -1
- package/lib/builder/store-sections.ts +60 -0
- package/lib/builder/types-slices.ts +27 -0
- package/lib/sanity/types.ts +17 -0
- package/lib/version.ts +1 -1
- package/package.json +1 -1
|
@@ -32,6 +32,11 @@ export function ColumnDragProvider({ children }: ColumnDragProviderProps) {
|
|
|
32
32
|
sectionKey={columnDrag.draggedSectionKey}
|
|
33
33
|
columnKey={columnDrag.draggedColumnKey}
|
|
34
34
|
position={columnDrag.overlayPosition}
|
|
35
|
+
// If hovering over a target, use its validity; if no target
|
|
36
|
+
// (empty space), stay in default (valid) state.
|
|
37
|
+
isValidDrop={
|
|
38
|
+
columnDrag.dropTarget ? columnDrag.dropTarget.isValid : true
|
|
39
|
+
}
|
|
35
40
|
/>
|
|
36
41
|
)}
|
|
37
42
|
</ColumnDragContext.Provider>
|
|
@@ -7,16 +7,29 @@ import type { PageSectionV2, CoverSection, SectionColumn } from "../../lib/sanit
|
|
|
7
7
|
import { isPageSectionV2, isCoverSection } from "../../lib/sanity/types";
|
|
8
8
|
import { BUILDER_BLUE } from "../../lib/builder/constants";
|
|
9
9
|
|
|
10
|
+
/** Color used when the current drop target is invalid (cross-section
|
|
11
|
+
* swap, or a target that is not V2/Cover). Red 500 with compatible
|
|
12
|
+
* translucent tints for the overlay chrome. */
|
|
13
|
+
const INVALID_RED = "#ef4444";
|
|
14
|
+
const INVALID_RED_RGB = "239, 68, 68";
|
|
15
|
+
|
|
10
16
|
interface ColumnDragOverlayProps {
|
|
11
17
|
sectionKey: string;
|
|
12
18
|
columnKey: string;
|
|
13
19
|
position: { x: number; y: number };
|
|
20
|
+
/**
|
|
21
|
+
* Whether the current drop target under the cursor is a valid drop.
|
|
22
|
+
* - true (or no target): render in blue (default).
|
|
23
|
+
* - false: render in red to signal "drop here won't execute".
|
|
24
|
+
*/
|
|
25
|
+
isValidDrop?: boolean;
|
|
14
26
|
}
|
|
15
27
|
|
|
16
28
|
const ColumnDragOverlay = memo(function ColumnDragOverlay({
|
|
17
29
|
sectionKey,
|
|
18
30
|
columnKey,
|
|
19
31
|
position,
|
|
32
|
+
isValidDrop = true,
|
|
20
33
|
}: ColumnDragOverlayProps) {
|
|
21
34
|
const rows = useBuilderStore((s) => s.rows);
|
|
22
35
|
const item = rows.find((r) => r._key === sectionKey);
|
|
@@ -44,6 +57,10 @@ const ColumnDragOverlay = memo(function ColumnDragOverlay({
|
|
|
44
57
|
|
|
45
58
|
const blockCount = (col.blocks || []).length;
|
|
46
59
|
|
|
60
|
+
// Pick accent based on drop validity.
|
|
61
|
+
const accentColor = isValidDrop ? BUILDER_BLUE : INVALID_RED;
|
|
62
|
+
const accentRgb = isValidDrop ? "71, 148, 226" : INVALID_RED_RGB;
|
|
63
|
+
|
|
47
64
|
const overlay = (
|
|
48
65
|
<div
|
|
49
66
|
style={{
|
|
@@ -53,18 +70,20 @@ const ColumnDragOverlay = memo(function ColumnDragOverlay({
|
|
|
53
70
|
transform: "translate(-50%, -50%)",
|
|
54
71
|
pointerEvents: "none",
|
|
55
72
|
zIndex: 99999,
|
|
73
|
+
transition: "filter 120ms ease",
|
|
56
74
|
}}
|
|
57
75
|
>
|
|
58
76
|
<div
|
|
59
77
|
style={{
|
|
60
78
|
width: 180,
|
|
61
79
|
minHeight: 80,
|
|
62
|
-
background:
|
|
80
|
+
background: `rgba(${accentRgb}, 0.08)`,
|
|
63
81
|
backdropFilter: "blur(8px)",
|
|
64
82
|
opacity: 0.85,
|
|
65
83
|
borderRadius: 8,
|
|
66
|
-
border: `2px solid ${
|
|
67
|
-
boxShadow:
|
|
84
|
+
border: `2px solid ${accentColor}`,
|
|
85
|
+
boxShadow: `0 8px 32px rgba(${accentRgb}, 0.3)`,
|
|
86
|
+
transition: "border-color 120ms ease, background 120ms ease, box-shadow 120ms ease",
|
|
68
87
|
}}
|
|
69
88
|
>
|
|
70
89
|
{/* Column header badge */}
|
|
@@ -74,17 +93,25 @@ const ColumnDragOverlay = memo(function ColumnDragOverlay({
|
|
|
74
93
|
alignItems: "center",
|
|
75
94
|
gap: 8,
|
|
76
95
|
padding: "8px 12px",
|
|
77
|
-
borderBottom:
|
|
96
|
+
borderBottom: `1px solid rgba(${accentRgb}, 0.2)`,
|
|
78
97
|
}}
|
|
79
98
|
>
|
|
80
|
-
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
99
|
+
{isValidDrop ? (
|
|
100
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill={accentColor}>
|
|
101
|
+
<circle cx="3" cy="3" r="1" />
|
|
102
|
+
<circle cx="7" cy="3" r="1" />
|
|
103
|
+
<circle cx="3" cy="7" r="1" />
|
|
104
|
+
<circle cx="7" cy="7" r="1" />
|
|
105
|
+
</svg>
|
|
106
|
+
) : (
|
|
107
|
+
// "Blocked" icon when invalid — slashed circle
|
|
108
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="none">
|
|
109
|
+
<circle cx="8" cy="8" r="6.5" stroke={accentColor} strokeWidth="1.5" />
|
|
110
|
+
<line x1="3.5" y1="12.5" x2="12.5" y2="3.5" stroke={accentColor} strokeWidth="1.5" />
|
|
111
|
+
</svg>
|
|
112
|
+
)}
|
|
86
113
|
<span style={{ fontSize: 12, color: "white", fontWeight: 500 }}>
|
|
87
|
-
Column {col.span}
|
|
114
|
+
{isValidDrop ? `Column ${col.span}/${gridColumns}` : "Cannot drop here"}
|
|
88
115
|
</span>
|
|
89
116
|
</div>
|
|
90
117
|
{/* Block count indicators */}
|
|
@@ -30,6 +30,11 @@ interface InsertionLinesProps {
|
|
|
30
30
|
colGap: number;
|
|
31
31
|
sectionKey: string;
|
|
32
32
|
dropTarget: DropTarget | null;
|
|
33
|
+
/** Offset added to grid_row in emitted `data-insert-row` attributes —
|
|
34
|
+
* required when this SectionV2Canvas is a virtual per-row view inside
|
|
35
|
+
* a CoverSection (rows are remapped to 1 for rendering; the real
|
|
36
|
+
* grid_row must be recovered for the DnD hit-test). Default 0. */
|
|
37
|
+
gridRowOffset?: number;
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
export const InsertionLines = memo(function InsertionLines({
|
|
@@ -40,6 +45,7 @@ export const InsertionLines = memo(function InsertionLines({
|
|
|
40
45
|
colGap,
|
|
41
46
|
sectionKey,
|
|
42
47
|
dropTarget,
|
|
48
|
+
gridRowOffset = 0,
|
|
43
49
|
}: InsertionLinesProps) {
|
|
44
50
|
// Compute insertion points between adjacent columns (no gap between them)
|
|
45
51
|
// Exclude the currently dragged column from consideration
|
|
@@ -124,7 +130,9 @@ export const InsertionLines = memo(function InsertionLines({
|
|
|
124
130
|
<div
|
|
125
131
|
data-col-v2-insert=""
|
|
126
132
|
data-section-key={sectionKey}
|
|
127
|
-
|
|
133
|
+
// Absolute grid_row (including Cover row offset) — see
|
|
134
|
+
// SectionV2Canvas's data-gap-row comment for rationale.
|
|
135
|
+
data-insert-row={pt.row + gridRowOffset}
|
|
128
136
|
data-insert-col={pt.gridColumn}
|
|
129
137
|
data-insert-left-key={pt.leftColKey}
|
|
130
138
|
data-insert-right-key={pt.rightColKey}
|
|
@@ -69,8 +69,13 @@ export default function SectionV2Canvas({
|
|
|
69
69
|
startDrag,
|
|
70
70
|
} = useColumnDragContext();
|
|
71
71
|
|
|
72
|
-
//
|
|
73
|
-
|
|
72
|
+
// Show insertion lines + highlighted gaps:
|
|
73
|
+
// - While dragging, in the section the drag ORIGINATED from (source feedback)
|
|
74
|
+
// - While dragging, in ANY section the cursor is currently over (target feedback
|
|
75
|
+
// for cross-section drops — the user needs a visible drop zone in the target).
|
|
76
|
+
const showInsertionLines =
|
|
77
|
+
isColDragActive &&
|
|
78
|
+
(draggingSectionKey === section._key || isSectionHovered);
|
|
74
79
|
|
|
75
80
|
// When drag ends (isColDragActive becomes false), reset hover state so it
|
|
76
81
|
// doesn't stay stuck true if the pointer ended outside the grid.
|
|
@@ -267,7 +272,11 @@ export default function SectionV2Canvas({
|
|
|
267
272
|
key={`gap-${gap.grid_row}-${gap.grid_column}`}
|
|
268
273
|
data-col-v2-gap=""
|
|
269
274
|
data-section-key={section._key}
|
|
270
|
-
|
|
275
|
+
// Absolute grid_row (including Cover row offset) — used by the
|
|
276
|
+
// DnD hit-test. Without the offset, Cover drops in row 2/3 would
|
|
277
|
+
// collapse to row 1. The `onClick` below applies the same offset
|
|
278
|
+
// to keep "+ Add Column" consistent with drag drops.
|
|
279
|
+
data-gap-row={gap.grid_row + gridRowOffset}
|
|
271
280
|
data-gap-col={gap.grid_column}
|
|
272
281
|
data-gap-span={gap.span}
|
|
273
282
|
onClick={(e) => {
|
|
@@ -304,6 +313,7 @@ export default function SectionV2Canvas({
|
|
|
304
313
|
colGap={colGap}
|
|
305
314
|
sectionKey={section._key}
|
|
306
315
|
dropTarget={dropTarget}
|
|
316
|
+
gridRowOffset={gridRowOffset}
|
|
307
317
|
/>
|
|
308
318
|
)}
|
|
309
319
|
</div>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useState, useCallback, useRef, useEffect } from "react";
|
|
4
4
|
import { useBuilderStore } from "../../../lib/builder/store";
|
|
5
5
|
import type { PageSectionV2, CoverSection, ContentItem } from "../../../lib/sanity/types";
|
|
6
|
-
import { isPageSectionV2, isCoverSection } from "../../../lib/sanity/types";
|
|
6
|
+
import { isPageSectionV2, isCoverSection, isColumnarSection } from "../../../lib/sanity/types";
|
|
7
7
|
import { getEffectiveColumnsV2, buildColumnV2Overrides } from "../settings-panel/responsive-helpers";
|
|
8
8
|
import { moveColumn as cascadeMoveColumn } from "../../../lib/builder/cascade";
|
|
9
9
|
import type { DeviceViewport } from "../../../lib/builder/types";
|
|
@@ -80,6 +80,22 @@ export interface DropTarget {
|
|
|
80
80
|
/** insert: grid position where the column will be inserted */
|
|
81
81
|
insertRow?: number;
|
|
82
82
|
insertCol?: number;
|
|
83
|
+
/**
|
|
84
|
+
* Whether the drop is to a DIFFERENT section than the source.
|
|
85
|
+
* - false → same-section drag (existing behavior).
|
|
86
|
+
* - true → cross-section move (must land on V2 or Cover; swap
|
|
87
|
+
* across sections is not supported and is reported as
|
|
88
|
+
* isValid=false below).
|
|
89
|
+
*/
|
|
90
|
+
isCrossSection: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* Whether the drop is legal. Set to false when:
|
|
93
|
+
* - target section is not a columnar section (parallax, custom-instance)
|
|
94
|
+
* - cross-section swap is attempted (only gap/insert cross sections)
|
|
95
|
+
* When false, the drop action is NOT executed on mouseup and the
|
|
96
|
+
* overlay renders in "invalid" (red) state.
|
|
97
|
+
*/
|
|
98
|
+
isValid: boolean;
|
|
83
99
|
}
|
|
84
100
|
|
|
85
101
|
export interface InsertBetween {
|
|
@@ -97,6 +113,103 @@ export interface UseColumnDragReturn {
|
|
|
97
113
|
startDrag: (e: React.MouseEvent, sectionKey: string, columnKey: string) => void;
|
|
98
114
|
}
|
|
99
115
|
|
|
116
|
+
// ============================================
|
|
117
|
+
// Hit-test (shared by mousemove and mouseup)
|
|
118
|
+
// ============================================
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Given a pointer event, resolve the topmost drop target under the cursor.
|
|
122
|
+
*
|
|
123
|
+
* - Priority order: Insert zone > Gap > Column (swap)
|
|
124
|
+
* - Cross-section drops are ALLOWED for Insert and Gap, but only if the
|
|
125
|
+
* target section is columnar (V2 or Cover). Non-columnar targets
|
|
126
|
+
* (parallax slide columns, custom instances) produce a target with
|
|
127
|
+
* `isValid: false` so the caller can show a red overlay.
|
|
128
|
+
* - Cross-section Swap is explicitly rejected (`isValid: false`). Swapping
|
|
129
|
+
* across sections is ambiguous UX; the user can drag into a gap/insert
|
|
130
|
+
* instead.
|
|
131
|
+
*/
|
|
132
|
+
function hitTestDrop(
|
|
133
|
+
e: MouseEvent,
|
|
134
|
+
sourceSectionKey: string,
|
|
135
|
+
sourceColumnKey: string,
|
|
136
|
+
): { target: DropTarget | null; insert: InsertBetween | null } {
|
|
137
|
+
const rows = useBuilderStore.getState().rows;
|
|
138
|
+
const isTargetColumnar = (sectionKey: string): boolean => {
|
|
139
|
+
const section = rows.find((r) => r._key === sectionKey);
|
|
140
|
+
return section ? isColumnarSection(section) : false;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const elements = document.elementsFromPoint(e.clientX, e.clientY);
|
|
144
|
+
for (const el of elements) {
|
|
145
|
+
const htmlEl = el as HTMLElement;
|
|
146
|
+
|
|
147
|
+
// Priority 1: Insert zone (between two columns, or at start/end of a row)
|
|
148
|
+
if (htmlEl.dataset.colV2Insert !== undefined) {
|
|
149
|
+
const ts = htmlEl.dataset.sectionKey!;
|
|
150
|
+
const isCross = ts !== sourceSectionKey;
|
|
151
|
+
const isValid = isTargetColumnar(ts);
|
|
152
|
+
const target: DropTarget = {
|
|
153
|
+
type: "insert",
|
|
154
|
+
sectionKey: ts,
|
|
155
|
+
insertRow: parseInt(htmlEl.dataset.insertRow!, 10),
|
|
156
|
+
insertCol: parseInt(htmlEl.dataset.insertCol!, 10),
|
|
157
|
+
isCrossSection: isCross,
|
|
158
|
+
isValid,
|
|
159
|
+
};
|
|
160
|
+
const leftKey = htmlEl.dataset.insertLeftKey;
|
|
161
|
+
const rightKey = htmlEl.dataset.insertRightKey;
|
|
162
|
+
const insert: InsertBetween | null =
|
|
163
|
+
leftKey && rightKey ? { leftKey, rightKey } : null;
|
|
164
|
+
return { target, insert };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Priority 2: Gap (empty grid cell)
|
|
168
|
+
if (htmlEl.dataset.colV2Gap !== undefined) {
|
|
169
|
+
const ts = htmlEl.dataset.sectionKey!;
|
|
170
|
+
const isCross = ts !== sourceSectionKey;
|
|
171
|
+
const isValid = isTargetColumnar(ts);
|
|
172
|
+
return {
|
|
173
|
+
target: {
|
|
174
|
+
type: "gap",
|
|
175
|
+
sectionKey: ts,
|
|
176
|
+
gapRow: parseInt(htmlEl.dataset.gapRow!, 10),
|
|
177
|
+
gapCol: parseInt(htmlEl.dataset.gapCol!, 10),
|
|
178
|
+
gapSpan: parseInt(htmlEl.dataset.gapSpan!, 10),
|
|
179
|
+
isCrossSection: isCross,
|
|
180
|
+
isValid,
|
|
181
|
+
},
|
|
182
|
+
insert: null,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Priority 3: Column (swap). Valid both same-section AND cross-section
|
|
187
|
+
// (as long as target is a columnar section — V2 or Cover). Cross-section
|
|
188
|
+
// swap routes to `swapColumnsBetweenSections` at drop time; same-section
|
|
189
|
+
// swap routes to `swapColumnV2`.
|
|
190
|
+
if (htmlEl.dataset.colV2Droptarget !== undefined) {
|
|
191
|
+
const ts = htmlEl.dataset.sectionKey!;
|
|
192
|
+
const tc = htmlEl.dataset.columnKey!;
|
|
193
|
+
// Skip if hovering over self (within same section)
|
|
194
|
+
if (ts === sourceSectionKey && tc === sourceColumnKey) continue;
|
|
195
|
+
const isCross = ts !== sourceSectionKey;
|
|
196
|
+
const isValid = isTargetColumnar(ts);
|
|
197
|
+
return {
|
|
198
|
+
target: {
|
|
199
|
+
type: "swap",
|
|
200
|
+
sectionKey: ts,
|
|
201
|
+
columnKey: tc,
|
|
202
|
+
isCrossSection: isCross,
|
|
203
|
+
isValid,
|
|
204
|
+
},
|
|
205
|
+
insert: null,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { target: null, insert: null };
|
|
211
|
+
}
|
|
212
|
+
|
|
100
213
|
// ============================================
|
|
101
214
|
// Responsive Helper Functions (private)
|
|
102
215
|
// ============================================
|
|
@@ -252,6 +365,8 @@ export function useColumnDrag(): UseColumnDragReturn {
|
|
|
252
365
|
swapColumnV2: s.swapColumnV2,
|
|
253
366
|
moveColumnToGapV2: s.moveColumnToGapV2,
|
|
254
367
|
moveColumnV2: s.moveColumnV2,
|
|
368
|
+
moveColumnBetweenSections: s.moveColumnBetweenSections,
|
|
369
|
+
swapColumnsBetweenSections: s.swapColumnsBetweenSections,
|
|
255
370
|
updateSectionV2Responsive: s.updateSectionV2Responsive,
|
|
256
371
|
};
|
|
257
372
|
};
|
|
@@ -296,60 +411,12 @@ export function useColumnDrag(): UseColumnDragReturn {
|
|
|
296
411
|
|
|
297
412
|
const { sectionKey, columnKey } = dragRef.current;
|
|
298
413
|
|
|
299
|
-
// 2. Hit-test via
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const htmlEl = el as HTMLElement;
|
|
306
|
-
|
|
307
|
-
// Priority 1: Insert zone
|
|
308
|
-
if (htmlEl.dataset.colV2Insert !== undefined) {
|
|
309
|
-
const targetSection = htmlEl.dataset.sectionKey;
|
|
310
|
-
if (targetSection !== sectionKey) continue;
|
|
311
|
-
newTarget = {
|
|
312
|
-
type: "insert",
|
|
313
|
-
sectionKey: targetSection,
|
|
314
|
-
insertRow: parseInt(htmlEl.dataset.insertRow!, 10),
|
|
315
|
-
insertCol: parseInt(htmlEl.dataset.insertCol!, 10),
|
|
316
|
-
};
|
|
317
|
-
const leftKey = htmlEl.dataset.insertLeftKey;
|
|
318
|
-
const rightKey = htmlEl.dataset.insertRightKey;
|
|
319
|
-
if (leftKey && rightKey) {
|
|
320
|
-
newInsert = { leftKey, rightKey };
|
|
321
|
-
}
|
|
322
|
-
break;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// Priority 2: Gap
|
|
326
|
-
if (htmlEl.dataset.colV2Gap !== undefined) {
|
|
327
|
-
const targetSection = htmlEl.dataset.sectionKey;
|
|
328
|
-
if (targetSection !== sectionKey) continue;
|
|
329
|
-
newTarget = {
|
|
330
|
-
type: "gap",
|
|
331
|
-
sectionKey: targetSection,
|
|
332
|
-
gapRow: parseInt(htmlEl.dataset.gapRow!, 10),
|
|
333
|
-
gapCol: parseInt(htmlEl.dataset.gapCol!, 10),
|
|
334
|
-
gapSpan: parseInt(htmlEl.dataset.gapSpan!, 10),
|
|
335
|
-
};
|
|
336
|
-
break;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Priority 3: Column (swap)
|
|
340
|
-
if (htmlEl.dataset.colV2Droptarget !== undefined) {
|
|
341
|
-
const targetSection = htmlEl.dataset.sectionKey;
|
|
342
|
-
const targetColumn = htmlEl.dataset.columnKey;
|
|
343
|
-
if (targetSection !== sectionKey) continue;
|
|
344
|
-
if (targetColumn === columnKey) continue; // skip self
|
|
345
|
-
newTarget = {
|
|
346
|
-
type: "swap",
|
|
347
|
-
sectionKey: targetSection,
|
|
348
|
-
columnKey: targetColumn,
|
|
349
|
-
};
|
|
350
|
-
break;
|
|
351
|
-
}
|
|
352
|
-
}
|
|
414
|
+
// 2. Hit-test via shared helper (returns DropTarget with isValid/isCrossSection)
|
|
415
|
+
const { target: newTarget, insert: newInsert } = hitTestDrop(
|
|
416
|
+
e,
|
|
417
|
+
sectionKey,
|
|
418
|
+
columnKey,
|
|
419
|
+
);
|
|
353
420
|
|
|
354
421
|
setDropTarget(newTarget);
|
|
355
422
|
setInsertBetween(newTarget?.type === "insert" ? newInsert : null);
|
|
@@ -378,93 +445,100 @@ export function useColumnDrag(): UseColumnDragReturn {
|
|
|
378
445
|
const { sectionKey, columnKey } = dragRef.current;
|
|
379
446
|
dragRef.current.active = false;
|
|
380
447
|
|
|
381
|
-
// Final hit-test at mouseup position
|
|
382
|
-
const
|
|
383
|
-
let finalTarget: DropTarget | null = null;
|
|
384
|
-
|
|
385
|
-
for (const el of elements) {
|
|
386
|
-
const htmlEl = el as HTMLElement;
|
|
387
|
-
|
|
388
|
-
// Priority 1: Insert zone
|
|
389
|
-
if (htmlEl.dataset.colV2Insert !== undefined) {
|
|
390
|
-
const ts = htmlEl.dataset.sectionKey;
|
|
391
|
-
if (ts !== sectionKey) continue;
|
|
392
|
-
finalTarget = {
|
|
393
|
-
type: "insert",
|
|
394
|
-
sectionKey: ts,
|
|
395
|
-
insertRow: parseInt(htmlEl.dataset.insertRow!, 10),
|
|
396
|
-
insertCol: parseInt(htmlEl.dataset.insertCol!, 10),
|
|
397
|
-
};
|
|
398
|
-
break;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// Priority 2: Gap
|
|
402
|
-
if (htmlEl.dataset.colV2Gap !== undefined) {
|
|
403
|
-
const ts = htmlEl.dataset.sectionKey;
|
|
404
|
-
if (ts !== sectionKey) continue;
|
|
405
|
-
finalTarget = {
|
|
406
|
-
type: "gap",
|
|
407
|
-
sectionKey: ts,
|
|
408
|
-
gapRow: parseInt(htmlEl.dataset.gapRow!, 10),
|
|
409
|
-
gapCol: parseInt(htmlEl.dataset.gapCol!, 10),
|
|
410
|
-
gapSpan: parseInt(htmlEl.dataset.gapSpan!, 10),
|
|
411
|
-
};
|
|
412
|
-
break;
|
|
413
|
-
}
|
|
448
|
+
// Final hit-test at mouseup position (shared with mousemove)
|
|
449
|
+
const { target: finalTarget } = hitTestDrop(e, sectionKey, columnKey);
|
|
414
450
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
if (ts !== sectionKey || tc === columnKey) continue;
|
|
420
|
-
finalTarget = { type: "swap", sectionKey: ts, columnKey: tc };
|
|
421
|
-
break;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// Execute the drop action — read actions fresh from store (RC-002)
|
|
426
|
-
if (finalTarget) {
|
|
451
|
+
// Execute the drop action — read actions fresh from store (RC-002).
|
|
452
|
+
// Only valid targets execute; invalid (e.g. cross-section swap, drop
|
|
453
|
+
// on parallax) fall through silently — overlay already showed red.
|
|
454
|
+
if (finalTarget && finalTarget.isValid) {
|
|
427
455
|
const storeState = useBuilderStore.getState();
|
|
428
456
|
const activeViewport = storeState.activeViewport;
|
|
429
457
|
const isResponsive = activeViewport !== "desktop";
|
|
430
458
|
const actions = getActions();
|
|
431
459
|
|
|
432
|
-
if (finalTarget.
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
460
|
+
if (finalTarget.isCrossSection) {
|
|
461
|
+
// Cross-section drops supported:
|
|
462
|
+
// - gap / insert → `moveColumnBetweenSections`
|
|
463
|
+
// - swap → `swapColumnsBetweenSections` (each column adopts the
|
|
464
|
+
// other's position/row/span, same semantic as intra-section swap)
|
|
465
|
+
// Responsive cross-section moves are not yet supported in this
|
|
466
|
+
// first iteration — treat as desktop move (span/col clamped by
|
|
467
|
+
// helpers). The drop is still written to the Sanity doc.
|
|
468
|
+
if (finalTarget.type === "gap") {
|
|
469
|
+
actions.moveColumnBetweenSections(
|
|
470
|
+
sectionKey,
|
|
471
|
+
columnKey,
|
|
472
|
+
finalTarget.sectionKey,
|
|
473
|
+
finalTarget.gapRow!,
|
|
474
|
+
finalTarget.gapCol!,
|
|
475
|
+
finalTarget.gapSpan!,
|
|
439
476
|
);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
477
|
+
} else if (finalTarget.type === "insert") {
|
|
478
|
+
// For insert drops, preserve the source column's span. The helper
|
|
479
|
+
// clamps to target's grid if needed.
|
|
480
|
+
const sourceCol = useBuilderStore
|
|
481
|
+
.getState()
|
|
482
|
+
.rows.find((r) => r._key === sectionKey);
|
|
483
|
+
const sourceSpan =
|
|
484
|
+
sourceCol && isColumnarSection(sourceCol)
|
|
485
|
+
? sourceCol.columns.find((c) => c._key === columnKey)?.span ?? 12
|
|
486
|
+
: 12;
|
|
487
|
+
actions.moveColumnBetweenSections(
|
|
488
|
+
sectionKey,
|
|
489
|
+
columnKey,
|
|
490
|
+
finalTarget.sectionKey,
|
|
491
|
+
finalTarget.insertRow!,
|
|
492
|
+
finalTarget.insertCol!,
|
|
493
|
+
sourceSpan,
|
|
446
494
|
);
|
|
447
|
-
} else {
|
|
448
|
-
|
|
449
|
-
sectionKey,
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
495
|
+
} else if (finalTarget.type === "swap" && finalTarget.columnKey) {
|
|
496
|
+
actions.swapColumnsBetweenSections(
|
|
497
|
+
sectionKey,
|
|
498
|
+
columnKey,
|
|
499
|
+
finalTarget.sectionKey,
|
|
500
|
+
finalTarget.columnKey,
|
|
453
501
|
);
|
|
454
502
|
}
|
|
455
|
-
} else
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
)
|
|
503
|
+
} else {
|
|
504
|
+
// Same-section — existing behavior preserved exactly
|
|
505
|
+
if (finalTarget.type === "swap" && finalTarget.columnKey) {
|
|
506
|
+
if (!isResponsive) {
|
|
507
|
+
actions.swapColumnV2(sectionKey, columnKey, finalTarget.columnKey);
|
|
508
|
+
} else {
|
|
509
|
+
executeResponsiveSwap(
|
|
510
|
+
sectionKey, columnKey, finalTarget.columnKey, activeViewport,
|
|
511
|
+
actions.updateSectionV2Responsive
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
} else if (finalTarget.type === "gap") {
|
|
515
|
+
if (!isResponsive) {
|
|
516
|
+
actions.moveColumnToGapV2(
|
|
517
|
+
sectionKey, columnKey,
|
|
518
|
+
finalTarget.gapRow!, finalTarget.gapCol!, finalTarget.gapSpan!
|
|
519
|
+
);
|
|
520
|
+
} else {
|
|
521
|
+
executeResponsiveGapMove(
|
|
522
|
+
sectionKey, columnKey,
|
|
523
|
+
finalTarget.gapRow!, finalTarget.gapCol!, finalTarget.gapSpan!,
|
|
524
|
+
activeViewport,
|
|
525
|
+
actions.updateSectionV2Responsive
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
} else if (finalTarget.type === "insert") {
|
|
529
|
+
if (!isResponsive) {
|
|
530
|
+
actions.moveColumnV2(
|
|
531
|
+
sectionKey, columnKey,
|
|
532
|
+
finalTarget.insertRow!, finalTarget.insertCol!
|
|
533
|
+
);
|
|
534
|
+
} else {
|
|
535
|
+
executeResponsiveInsert(
|
|
536
|
+
sectionKey, columnKey,
|
|
537
|
+
finalTarget.insertRow!, finalTarget.insertCol!,
|
|
538
|
+
activeViewport,
|
|
539
|
+
actions.updateSectionV2Responsive
|
|
540
|
+
);
|
|
541
|
+
}
|
|
468
542
|
}
|
|
469
543
|
}
|
|
470
544
|
}
|
|
@@ -20,7 +20,12 @@ import type {
|
|
|
20
20
|
} from "../../lib/sanity/types";
|
|
21
21
|
import { isPageSectionV2, isParallaxGroup, isCoverSection } from "../../lib/sanity/types";
|
|
22
22
|
import { columnsFromPreset, detectPreset } from "./cascade";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
resizeColumnLeft as cascadeResizeLeft,
|
|
25
|
+
moveColumn as cascadeMoveColumn,
|
|
26
|
+
addColumn as cascadeAddColumn,
|
|
27
|
+
type ResizeLeftResult,
|
|
28
|
+
} from "./cascade";
|
|
24
29
|
import { applyBlocksToColumns, toCascadeColumns, type CascadeColumn } from "./cascade-helpers";
|
|
25
30
|
import { generateKey } from "./utils";
|
|
26
31
|
import { createDefaultParallaxSlide } from "./defaults";
|
|
@@ -625,3 +630,299 @@ export function addParallaxGroupInState(
|
|
|
625
630
|
|
|
626
631
|
return { rows: newRows, newGroupKey: newGroup._key };
|
|
627
632
|
}
|
|
633
|
+
|
|
634
|
+
// ============================================
|
|
635
|
+
// moveColumnBetweenSectionsInState (Session 183 — cross-section column DnD)
|
|
636
|
+
// ============================================
|
|
637
|
+
|
|
638
|
+
export interface MoveColumnBetweenSectionsResult {
|
|
639
|
+
rows: ContentItem[];
|
|
640
|
+
/** Final span after clamping to target grid. */
|
|
641
|
+
finalSpan: number;
|
|
642
|
+
/** Final grid_column after clamping. */
|
|
643
|
+
finalGridColumn: number;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Move a column from one columnar section to another (V2 ↔ Cover).
|
|
648
|
+
*
|
|
649
|
+
* Blocks travel with the column. Responsive overrides for the moved
|
|
650
|
+
* column are STRIPPED from the source section — responsive overrides
|
|
651
|
+
* are per-section `_key` lookups and don't carry semantic meaning in
|
|
652
|
+
* the new home. Tablet/phone layouts for the moved column revert to
|
|
653
|
+
* desktop until the user re-adds overrides in the target.
|
|
654
|
+
*
|
|
655
|
+
* Span and grid_column are clamped to target's grid_columns.
|
|
656
|
+
*
|
|
657
|
+
* Returns null if:
|
|
658
|
+
* - source or target key doesn't resolve to a columnar section
|
|
659
|
+
* - source and target are the same section (caller should use
|
|
660
|
+
* moveColumnV2InState instead)
|
|
661
|
+
* - the column key doesn't exist in the source
|
|
662
|
+
*/
|
|
663
|
+
export function moveColumnBetweenSectionsInState(
|
|
664
|
+
rows: ContentItem[],
|
|
665
|
+
sourceSectionKey: string,
|
|
666
|
+
columnKey: string,
|
|
667
|
+
targetSectionKey: string,
|
|
668
|
+
targetRow: number,
|
|
669
|
+
targetColumn: number,
|
|
670
|
+
targetSpan: number,
|
|
671
|
+
): MoveColumnBetweenSectionsResult | null {
|
|
672
|
+
if (sourceSectionKey === targetSectionKey) return null;
|
|
673
|
+
|
|
674
|
+
const sourcePath = findSectionPath(rows, sourceSectionKey);
|
|
675
|
+
const targetPath = findSectionPath(rows, targetSectionKey);
|
|
676
|
+
if (!sourcePath || !targetPath) return null;
|
|
677
|
+
|
|
678
|
+
// Read the source column (full SectionColumn with blocks)
|
|
679
|
+
const sourceVirtual = getSectionFromPath(rows, sourcePath);
|
|
680
|
+
if (!sourceVirtual) return null;
|
|
681
|
+
const movedColumn = sourceVirtual.columns.find((c) => c._key === columnKey);
|
|
682
|
+
if (!movedColumn) return null;
|
|
683
|
+
|
|
684
|
+
// Read target for grid_columns
|
|
685
|
+
const targetVirtual = getSectionFromPath(rows, targetPath);
|
|
686
|
+
if (!targetVirtual) return null;
|
|
687
|
+
const targetGridCols = targetVirtual.settings.grid_columns || 12;
|
|
688
|
+
|
|
689
|
+
// Clamp span + grid column to target grid
|
|
690
|
+
const clampedSpan = Math.max(1, Math.min(targetSpan, targetGridCols));
|
|
691
|
+
const clampedCol = Math.max(
|
|
692
|
+
1,
|
|
693
|
+
Math.min(targetColumn, targetGridCols - clampedSpan + 1),
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
// --- Step 1: Remove column from source, strip its responsive overrides ---
|
|
697
|
+
let newRows = updateSectionAtPath(rows, sourcePath, (section) => {
|
|
698
|
+
const filteredColumns = section.columns.filter((c) => c._key !== columnKey);
|
|
699
|
+
|
|
700
|
+
// Strip responsive overrides referencing the moved column
|
|
701
|
+
let newResponsive = section.responsive;
|
|
702
|
+
if (newResponsive) {
|
|
703
|
+
const cleanResponsive: PageSectionV2["responsive"] = {};
|
|
704
|
+
for (const vp of ["tablet", "phone"] as const) {
|
|
705
|
+
const vpData = newResponsive[vp];
|
|
706
|
+
if (!vpData) continue;
|
|
707
|
+
const newVpData: NonNullable<PageSectionV2["responsive"]>[typeof vp] = {
|
|
708
|
+
...vpData,
|
|
709
|
+
};
|
|
710
|
+
if (newVpData.columns) {
|
|
711
|
+
const filtered = newVpData.columns.filter((o) => o._key !== columnKey);
|
|
712
|
+
newVpData.columns = filtered.length > 0 ? filtered : undefined;
|
|
713
|
+
}
|
|
714
|
+
// Keep viewport entry only if it still has something
|
|
715
|
+
if (newVpData.columns || newVpData.settings) {
|
|
716
|
+
cleanResponsive[vp] = newVpData;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
newResponsive =
|
|
720
|
+
Object.keys(cleanResponsive).length > 0 ? cleanResponsive : undefined;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const cascadeCols = toCascadeColumns(filteredColumns);
|
|
724
|
+
const newPreset = detectPreset(cascadeCols, section.settings.grid_columns);
|
|
725
|
+
|
|
726
|
+
return {
|
|
727
|
+
...section,
|
|
728
|
+
columns: filteredColumns,
|
|
729
|
+
responsive: newResponsive,
|
|
730
|
+
settings: { ...section.settings, preset: newPreset },
|
|
731
|
+
};
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// --- Step 2: Insert the column into target with cascade ---
|
|
735
|
+
newRows = updateSectionAtPath(newRows, targetPath, (section) => {
|
|
736
|
+
// Augment the source list so `applyBlocksToColumns` can find the moved
|
|
737
|
+
// column's blocks by key. The position fields here are overwritten by
|
|
738
|
+
// cascade; only the blocks matter for the lookup.
|
|
739
|
+
const augmentedSource: SectionColumn[] = [
|
|
740
|
+
...section.columns,
|
|
741
|
+
{
|
|
742
|
+
...movedColumn,
|
|
743
|
+
grid_row: targetRow,
|
|
744
|
+
grid_column: clampedCol,
|
|
745
|
+
span: clampedSpan,
|
|
746
|
+
},
|
|
747
|
+
];
|
|
748
|
+
|
|
749
|
+
const cascadeResult = cascadeAddColumn(
|
|
750
|
+
toCascadeColumns(section.columns),
|
|
751
|
+
targetRow,
|
|
752
|
+
clampedCol,
|
|
753
|
+
clampedSpan,
|
|
754
|
+
columnKey,
|
|
755
|
+
section.settings.grid_columns,
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
const finalColumns = applyBlocksToColumns(cascadeResult, augmentedSource);
|
|
759
|
+
const newPreset = detectPreset(cascadeResult, section.settings.grid_columns);
|
|
760
|
+
|
|
761
|
+
return {
|
|
762
|
+
...section,
|
|
763
|
+
columns: finalColumns,
|
|
764
|
+
settings: { ...section.settings, preset: newPreset },
|
|
765
|
+
};
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
return {
|
|
769
|
+
rows: newRows,
|
|
770
|
+
finalSpan: clampedSpan,
|
|
771
|
+
finalGridColumn: clampedCol,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ============================================
|
|
776
|
+
// swapColumnsBetweenSectionsInState (Session 183 — cross-section column swap)
|
|
777
|
+
// ============================================
|
|
778
|
+
|
|
779
|
+
export interface SwapColumnsBetweenSectionsResult {
|
|
780
|
+
rows: ContentItem[];
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Swap two columns across different columnar sections (V2 ↔ Cover).
|
|
785
|
+
*
|
|
786
|
+
* Semantically identical to the intra-section `swapColumnV2InState`:
|
|
787
|
+
* each column adopts the other's position/row/span. The difference is
|
|
788
|
+
* that the columns live in DIFFERENT sections, so each has to travel.
|
|
789
|
+
* Blocks go with their owner column.
|
|
790
|
+
*
|
|
791
|
+
* Clamping: when the two sections have different `grid_columns`, each
|
|
792
|
+
* column's new span and grid_column are clamped to fit the destination
|
|
793
|
+
* grid. Clamping is defensive — if both sections share 12 columns (the
|
|
794
|
+
* common case), no clamping happens.
|
|
795
|
+
*
|
|
796
|
+
* Responsive: overrides for BOTH swapped columns are stripped from both
|
|
797
|
+
* sections' responsive tables. Overrides live per-section by `_key`
|
|
798
|
+
* lookup; transplanting either direction would leave orphaned entries.
|
|
799
|
+
*
|
|
800
|
+
* Returns null if:
|
|
801
|
+
* - source and target are the same section
|
|
802
|
+
* - either key doesn't resolve to a columnar section
|
|
803
|
+
* - either column key doesn't exist in its section
|
|
804
|
+
*/
|
|
805
|
+
export function swapColumnsBetweenSectionsInState(
|
|
806
|
+
rows: ContentItem[],
|
|
807
|
+
sourceSectionKey: string,
|
|
808
|
+
sourceColumnKey: string,
|
|
809
|
+
targetSectionKey: string,
|
|
810
|
+
targetColumnKey: string,
|
|
811
|
+
): SwapColumnsBetweenSectionsResult | null {
|
|
812
|
+
if (sourceSectionKey === targetSectionKey) return null;
|
|
813
|
+
|
|
814
|
+
const sourcePath = findSectionPath(rows, sourceSectionKey);
|
|
815
|
+
const targetPath = findSectionPath(rows, targetSectionKey);
|
|
816
|
+
if (!sourcePath || !targetPath) return null;
|
|
817
|
+
|
|
818
|
+
const sourceVirtual = getSectionFromPath(rows, sourcePath);
|
|
819
|
+
const targetVirtual = getSectionFromPath(rows, targetPath);
|
|
820
|
+
if (!sourceVirtual || !targetVirtual) return null;
|
|
821
|
+
|
|
822
|
+
const sourceCol = sourceVirtual.columns.find((c) => c._key === sourceColumnKey);
|
|
823
|
+
const targetCol = targetVirtual.columns.find((c) => c._key === targetColumnKey);
|
|
824
|
+
if (!sourceCol || !targetCol) return null;
|
|
825
|
+
|
|
826
|
+
const sourceGridCols = sourceVirtual.settings.grid_columns || 12;
|
|
827
|
+
const targetGridCols = targetVirtual.settings.grid_columns || 12;
|
|
828
|
+
|
|
829
|
+
// Clamp source column's new dimensions (adopting target's position) to target grid
|
|
830
|
+
const newSourceSpan = Math.max(1, Math.min(targetCol.span, targetGridCols));
|
|
831
|
+
const newSourceGridCol = Math.max(
|
|
832
|
+
1,
|
|
833
|
+
Math.min(targetCol.grid_column, targetGridCols - newSourceSpan + 1),
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
// Clamp target column's new dimensions (adopting source's position) to source grid
|
|
837
|
+
const newTargetSpan = Math.max(1, Math.min(sourceCol.span, sourceGridCols));
|
|
838
|
+
const newTargetGridCol = Math.max(
|
|
839
|
+
1,
|
|
840
|
+
Math.min(sourceCol.grid_column, sourceGridCols - newTargetSpan + 1),
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
// --- Helper: strip responsive overrides for a given column key ---
|
|
844
|
+
const stripResponsiveForKey = (
|
|
845
|
+
responsive: PageSectionV2["responsive"],
|
|
846
|
+
columnKey: string,
|
|
847
|
+
): PageSectionV2["responsive"] => {
|
|
848
|
+
if (!responsive) return responsive;
|
|
849
|
+
const cleanResponsive: PageSectionV2["responsive"] = {};
|
|
850
|
+
for (const vp of ["tablet", "phone"] as const) {
|
|
851
|
+
const vpData = responsive[vp];
|
|
852
|
+
if (!vpData) continue;
|
|
853
|
+
const newVpData: NonNullable<PageSectionV2["responsive"]>[typeof vp] = {
|
|
854
|
+
...vpData,
|
|
855
|
+
};
|
|
856
|
+
if (newVpData.columns) {
|
|
857
|
+
const filtered = newVpData.columns.filter((o) => o._key !== columnKey);
|
|
858
|
+
newVpData.columns = filtered.length > 0 ? filtered : undefined;
|
|
859
|
+
}
|
|
860
|
+
if (newVpData.columns || newVpData.settings) {
|
|
861
|
+
cleanResponsive[vp] = newVpData;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return Object.keys(cleanResponsive).length > 0 ? cleanResponsive : undefined;
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
// --- Step 1: Replace source column with target column (transplanted) ---
|
|
868
|
+
// Also strip sourceColumnKey's responsive overrides.
|
|
869
|
+
let newRows = updateSectionAtPath(rows, sourcePath, (section) => {
|
|
870
|
+
// Build the transplanted column with target's blocks, adopting
|
|
871
|
+
// source's OLD position/row/span (clamped to source grid).
|
|
872
|
+
const transplanted: SectionColumn = {
|
|
873
|
+
...targetCol,
|
|
874
|
+
grid_row: sourceCol.grid_row,
|
|
875
|
+
grid_column: newTargetGridCol,
|
|
876
|
+
span: newTargetSpan,
|
|
877
|
+
};
|
|
878
|
+
const updatedColumns = section.columns.map((c) =>
|
|
879
|
+
c._key === sourceColumnKey ? transplanted : c,
|
|
880
|
+
);
|
|
881
|
+
|
|
882
|
+
const newResponsive = stripResponsiveForKey(
|
|
883
|
+
section.responsive,
|
|
884
|
+
sourceColumnKey,
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
const cascadeCols = toCascadeColumns(updatedColumns);
|
|
888
|
+
const newPreset = detectPreset(cascadeCols, section.settings.grid_columns);
|
|
889
|
+
|
|
890
|
+
return {
|
|
891
|
+
...section,
|
|
892
|
+
columns: updatedColumns,
|
|
893
|
+
responsive: newResponsive,
|
|
894
|
+
settings: { ...section.settings, preset: newPreset },
|
|
895
|
+
};
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
// --- Step 2: Replace target column with source column (transplanted) ---
|
|
899
|
+
// Also strip targetColumnKey's responsive overrides.
|
|
900
|
+
newRows = updateSectionAtPath(newRows, targetPath, (section) => {
|
|
901
|
+
const transplanted: SectionColumn = {
|
|
902
|
+
...sourceCol,
|
|
903
|
+
grid_row: targetCol.grid_row,
|
|
904
|
+
grid_column: newSourceGridCol,
|
|
905
|
+
span: newSourceSpan,
|
|
906
|
+
};
|
|
907
|
+
const updatedColumns = section.columns.map((c) =>
|
|
908
|
+
c._key === targetColumnKey ? transplanted : c,
|
|
909
|
+
);
|
|
910
|
+
|
|
911
|
+
const newResponsive = stripResponsiveForKey(
|
|
912
|
+
section.responsive,
|
|
913
|
+
targetColumnKey,
|
|
914
|
+
);
|
|
915
|
+
|
|
916
|
+
const cascadeCols = toCascadeColumns(updatedColumns);
|
|
917
|
+
const newPreset = detectPreset(cascadeCols, section.settings.grid_columns);
|
|
918
|
+
|
|
919
|
+
return {
|
|
920
|
+
...section,
|
|
921
|
+
columns: updatedColumns,
|
|
922
|
+
responsive: newResponsive,
|
|
923
|
+
settings: { ...section.settings, preset: newPreset },
|
|
924
|
+
};
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
return { rows: newRows };
|
|
928
|
+
}
|
|
@@ -38,6 +38,8 @@ import {
|
|
|
38
38
|
moveColumnV2InState,
|
|
39
39
|
swapColumnV2InState,
|
|
40
40
|
moveColumnToGapV2InState,
|
|
41
|
+
moveColumnBetweenSectionsInState,
|
|
42
|
+
swapColumnsBetweenSectionsInState,
|
|
41
43
|
} from "./store-helpers";
|
|
42
44
|
|
|
43
45
|
type StoreSet = (
|
|
@@ -339,6 +341,64 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
|
|
|
339
341
|
set({ rows: result.rows, isDirty: true });
|
|
340
342
|
},
|
|
341
343
|
|
|
344
|
+
moveColumnBetweenSections: (
|
|
345
|
+
sourceSectionKey: string,
|
|
346
|
+
columnKey: string,
|
|
347
|
+
targetSectionKey: string,
|
|
348
|
+
targetRow: number,
|
|
349
|
+
targetColumn: number,
|
|
350
|
+
targetSpan: number,
|
|
351
|
+
): void => {
|
|
352
|
+
const result = moveColumnBetweenSectionsInState(
|
|
353
|
+
get().rows,
|
|
354
|
+
sourceSectionKey,
|
|
355
|
+
columnKey,
|
|
356
|
+
targetSectionKey,
|
|
357
|
+
targetRow,
|
|
358
|
+
targetColumn,
|
|
359
|
+
targetSpan,
|
|
360
|
+
);
|
|
361
|
+
if (!result) return;
|
|
362
|
+
|
|
363
|
+
get()._pushSnapshot();
|
|
364
|
+
// Clear any selection referencing the moved column in its old section —
|
|
365
|
+
// the column key remains but the "selected section" context has changed.
|
|
366
|
+
set({
|
|
367
|
+
rows: result.rows,
|
|
368
|
+
isDirty: true,
|
|
369
|
+
selectedColumnKey: columnKey,
|
|
370
|
+
selectedRowKey: targetSectionKey,
|
|
371
|
+
selectedBlockKey: null,
|
|
372
|
+
});
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
swapColumnsBetweenSections: (
|
|
376
|
+
sourceSectionKey: string,
|
|
377
|
+
sourceColumnKey: string,
|
|
378
|
+
targetSectionKey: string,
|
|
379
|
+
targetColumnKey: string,
|
|
380
|
+
): void => {
|
|
381
|
+
const result = swapColumnsBetweenSectionsInState(
|
|
382
|
+
get().rows,
|
|
383
|
+
sourceSectionKey,
|
|
384
|
+
sourceColumnKey,
|
|
385
|
+
targetSectionKey,
|
|
386
|
+
targetColumnKey,
|
|
387
|
+
);
|
|
388
|
+
if (!result) return;
|
|
389
|
+
|
|
390
|
+
get()._pushSnapshot();
|
|
391
|
+
// After swap: the dragged column now lives in the target section.
|
|
392
|
+
// Select it there (match mental model: "I just put this column here").
|
|
393
|
+
set({
|
|
394
|
+
rows: result.rows,
|
|
395
|
+
isDirty: true,
|
|
396
|
+
selectedColumnKey: sourceColumnKey,
|
|
397
|
+
selectedRowKey: targetSectionKey,
|
|
398
|
+
selectedBlockKey: null,
|
|
399
|
+
});
|
|
400
|
+
},
|
|
401
|
+
|
|
342
402
|
applyPresetV2: (sectionKey: string, preset: SectionV2Preset): void => {
|
|
343
403
|
if (preset === "custom") return;
|
|
344
404
|
|
|
@@ -124,6 +124,33 @@ export interface SectionSliceActions {
|
|
|
124
124
|
targetColumn: number,
|
|
125
125
|
targetSpan: number,
|
|
126
126
|
) => void;
|
|
127
|
+
/**
|
|
128
|
+
* Move a column from one columnar section (V2 or Cover) to another.
|
|
129
|
+
* Blocks travel with the column. Responsive overrides for the moved
|
|
130
|
+
* column are stripped from the source section. Span/grid_column are
|
|
131
|
+
* clamped to the target's grid_columns. Returns the final landed span
|
|
132
|
+
* so the caller can adjust UI feedback (e.g. show clamped value).
|
|
133
|
+
*/
|
|
134
|
+
moveColumnBetweenSections: (
|
|
135
|
+
sourceSectionKey: string,
|
|
136
|
+
columnKey: string,
|
|
137
|
+
targetSectionKey: string,
|
|
138
|
+
targetRow: number,
|
|
139
|
+
targetColumn: number,
|
|
140
|
+
targetSpan: number,
|
|
141
|
+
) => void;
|
|
142
|
+
/**
|
|
143
|
+
* Swap two columns across different columnar sections (V2 ↔ Cover).
|
|
144
|
+
* Each column adopts the other's position/row/span. Blocks travel
|
|
145
|
+
* with the column. Responsive overrides for both swapped columns
|
|
146
|
+
* are stripped (overrides are per-section by `_key`).
|
|
147
|
+
*/
|
|
148
|
+
swapColumnsBetweenSections: (
|
|
149
|
+
sourceSectionKey: string,
|
|
150
|
+
sourceColumnKey: string,
|
|
151
|
+
targetSectionKey: string,
|
|
152
|
+
targetColumnKey: string,
|
|
153
|
+
) => void;
|
|
127
154
|
applyPresetV2: (sectionKey: string, preset: SectionV2Preset) => void;
|
|
128
155
|
updateSectionV2Settings: (sectionKey: string, settings: Partial<SectionV2Settings>) => void;
|
|
129
156
|
updateSectionV2Responsive: (
|
package/lib/sanity/types.ts
CHANGED
|
@@ -596,6 +596,23 @@ export function isCoverSection(item: ContentItem): item is CoverSection {
|
|
|
596
596
|
return (item as CoverSection)._type === "coverSection";
|
|
597
597
|
}
|
|
598
598
|
|
|
599
|
+
/**
|
|
600
|
+
* Type guard: check if a content item supports column drag-and-drop as a
|
|
601
|
+
* source or target — i.e. it has a top-level `columns` array with the same
|
|
602
|
+
* V2 grid semantics. Used by cross-section column DnD (Session 183).
|
|
603
|
+
*
|
|
604
|
+
* Eligible: PageSectionV2, CoverSection
|
|
605
|
+
* NOT eligible:
|
|
606
|
+
* - ParallaxGroup (columns live per-slide, cross-slide DnD not supported)
|
|
607
|
+
* - CustomSectionInstance (references an external document, mutating it
|
|
608
|
+
* from the page would break the reference contract)
|
|
609
|
+
*/
|
|
610
|
+
export function isColumnarSection(
|
|
611
|
+
item: ContentItem,
|
|
612
|
+
): item is PageSectionV2 | CoverSection {
|
|
613
|
+
return isPageSectionV2(item) || isCoverSection(item);
|
|
614
|
+
}
|
|
615
|
+
|
|
599
616
|
// ============================================
|
|
600
617
|
// Shared references
|
|
601
618
|
// ============================================
|
package/lib/version.ts
CHANGED
package/package.json
CHANGED