@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.
@@ -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: "rgba(71, 148, 226, 0.08)",
80
+ background: `rgba(${accentRgb}, 0.08)`,
63
81
  backdropFilter: "blur(8px)",
64
82
  opacity: 0.85,
65
83
  borderRadius: 8,
66
- border: `2px solid ${BUILDER_BLUE}`,
67
- boxShadow: "0 8px 32px rgba(71, 148, 226, 0.3)",
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: "1px solid rgba(71, 148, 226, 0.2)",
96
+ borderBottom: `1px solid rgba(${accentRgb}, 0.2)`,
78
97
  }}
79
98
  >
80
- <svg width="10" height="10" viewBox="0 0 10 10" fill={BUILDER_BLUE}>
81
- <circle cx="3" cy="3" r="1" />
82
- <circle cx="7" cy="3" r="1" />
83
- <circle cx="3" cy="7" r="1" />
84
- <circle cx="7" cy="7" r="1" />
85
- </svg>
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}/{gridColumns}
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
- data-insert-row={pt.row}
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
- // Only show insertion lines if the drag originates from THIS section
73
- const showInsertionLines = isColDragActive && draggingSectionKey === section._key;
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
- data-gap-row={gap.grid_row}
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 elementsFromPoint
300
- const elements = document.elementsFromPoint(e.clientX, e.clientY);
301
- let newTarget: DropTarget | null = null;
302
- let newInsert: InsertBetween | null = null;
303
-
304
- for (const el of elements) {
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 elements = document.elementsFromPoint(e.clientX, e.clientY);
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
- // Priority 3: Column (swap)
416
- if (htmlEl.dataset.colV2Droptarget !== undefined) {
417
- const ts = htmlEl.dataset.sectionKey;
418
- const tc = htmlEl.dataset.columnKey;
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.type === "swap" && finalTarget.columnKey) {
433
- if (!isResponsive) {
434
- actions.swapColumnV2(sectionKey, columnKey, finalTarget.columnKey);
435
- } else {
436
- executeResponsiveSwap(
437
- sectionKey, columnKey, finalTarget.columnKey, activeViewport,
438
- actions.updateSectionV2Responsive
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
- } else if (finalTarget.type === "gap") {
442
- if (!isResponsive) {
443
- actions.moveColumnToGapV2(
444
- sectionKey, columnKey,
445
- finalTarget.gapRow!, finalTarget.gapCol!, finalTarget.gapSpan!
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
- executeResponsiveGapMove(
449
- sectionKey, columnKey,
450
- finalTarget.gapRow!, finalTarget.gapCol!, finalTarget.gapSpan!,
451
- activeViewport,
452
- actions.updateSectionV2Responsive
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 if (finalTarget.type === "insert") {
456
- if (!isResponsive) {
457
- actions.moveColumnV2(
458
- sectionKey, columnKey,
459
- finalTarget.insertRow!, finalTarget.insertCol!
460
- );
461
- } else {
462
- executeResponsiveInsert(
463
- sectionKey, columnKey,
464
- finalTarget.insertRow!, finalTarget.insertCol!,
465
- activeViewport,
466
- actions.updateSectionV2Responsive
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 { resizeColumnLeft as cascadeResizeLeft, moveColumn as cascadeMoveColumn, type ResizeLeftResult } from "./cascade";
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: (
@@ -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
@@ -6,4 +6,4 @@
6
6
  * Exposed as a plain constant so it can be imported without reading
7
7
  * package.json at runtime.
8
8
  */
9
- export const ANDAMI_VERSION = "0.4.2";
9
+ export const ANDAMI_VERSION = "0.5.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphika/andami",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Visual Page Builder — core library. A reusable website builder with visual editing, CMS integration, and asset management.",
5
5
  "type": "module",
6
6
  "license": "MIT",