@morphika/andami 0.4.1 → 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.
@@ -16,10 +16,16 @@ import type {
16
16
  SectionColumn,
17
17
  SectionV2Preset,
18
18
  CoverSection,
19
+ CoverSectionResponsiveOverride,
19
20
  } from "../../lib/sanity/types";
20
21
  import { isPageSectionV2, isParallaxGroup, isCoverSection } from "../../lib/sanity/types";
21
22
  import { columnsFromPreset, detectPreset } from "./cascade";
22
- 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";
23
29
  import { applyBlocksToColumns, toCascadeColumns, type CascadeColumn } from "./cascade-helpers";
24
30
  import { generateKey } from "./utils";
25
31
  import { createDefaultParallaxSlide } from "./defaults";
@@ -480,6 +486,25 @@ export function updateSectionAtPath(
480
486
  return rows.map((item, i) => {
481
487
  if (i !== path.index || !isCoverSection(item)) return item;
482
488
  const cover = item as CoverSection;
489
+
490
+ // Passthrough of `responsive.tablet/phone.columns` only — V2 operations
491
+ // (column DnD, resize) read and write this field, and its shape
492
+ // (`ColumnOverride[]`) is identical between V2 and Cover. We do NOT
493
+ // expose Cover-specific fields (`cover_rows`, Cover `settings`) to the
494
+ // updater via virtualSection.responsive — those stay on the cover
495
+ // unchanged. This keeps the virtualSection's shape a true V2.
496
+ const virtualResponsive: PageSectionV2["responsive"] =
497
+ cover.responsive?.tablet?.columns || cover.responsive?.phone?.columns
498
+ ? {
499
+ ...(cover.responsive.tablet?.columns
500
+ ? { tablet: { columns: cover.responsive.tablet.columns } }
501
+ : {}),
502
+ ...(cover.responsive.phone?.columns
503
+ ? { phone: { columns: cover.responsive.phone.columns } }
504
+ : {}),
505
+ }
506
+ : undefined;
507
+
483
508
  const virtualSection: PageSectionV2 = {
484
509
  _type: "pageSectionV2",
485
510
  _key: cover._key,
@@ -497,11 +522,34 @@ export function updateSectionAtPath(
497
522
  enter_animation: cover.settings.enter_animation,
498
523
  stagger: cover.settings.stagger,
499
524
  },
525
+ responsive: virtualResponsive,
500
526
  };
501
527
  const updated = updater(virtualSection);
528
+
529
+ // Merge updated `responsive.columns` back into cover.responsive,
530
+ // preserving `cover_rows` and Cover-specific `settings` per viewport.
531
+ const mergedResponsive: CoverSection["responsive"] = {};
532
+ for (const vp of ["tablet", "phone"] as const) {
533
+ const existing: CoverSectionResponsiveOverride = {
534
+ ...(cover.responsive?.[vp] || {}),
535
+ };
536
+ const updatedCols = updated.responsive?.[vp]?.columns;
537
+ if (updatedCols === undefined) {
538
+ delete existing.columns;
539
+ } else {
540
+ existing.columns = updatedCols;
541
+ }
542
+ if (existing.columns || existing.cover_rows || existing.settings) {
543
+ mergedResponsive[vp] = existing;
544
+ }
545
+ }
546
+
502
547
  return {
503
548
  ...cover,
504
549
  columns: updated.columns,
550
+ responsive: Object.keys(mergedResponsive).length
551
+ ? mergedResponsive
552
+ : undefined,
505
553
  settings: {
506
554
  ...cover.settings,
507
555
  grid_columns: updated.settings.grid_columns,
@@ -582,3 +630,299 @@ export function addParallaxGroupInState(
582
630
 
583
631
  return { rows: newRows, newGroupKey: newGroup._key };
584
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
+ }
@@ -1,4 +1,4 @@
1
- import type { BuilderStore, BuilderState, BlockType } from "./types";
1
+ import type { BuilderStore, BuilderState, BlockType, SectionSliceActions } from "./types";
2
2
  import type {
3
3
  ContentBlock,
4
4
  ContentItem,
@@ -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 = (
@@ -45,7 +47,7 @@ type StoreSet = (
45
47
  ) => void;
46
48
  type StoreGet = () => BuilderStore;
47
49
 
48
- export function createSectionActions(set: StoreSet, get: StoreGet) {
50
+ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSliceActions {
49
51
  return {
50
52
  // ---- Section operations ----
51
53
 
@@ -339,6 +341,64 @@ export function createSectionActions(set: StoreSet, get: StoreGet) {
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