@morphika/andami 0.4.2 → 0.5.1
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/README.md +151 -36
- package/app/admin/layout.tsx +145 -152
- package/components/blocks/AudioBlockRenderer.tsx +286 -0
- package/components/blocks/BeforeAfterBlockRenderer.tsx +274 -0
- package/components/builder/BlockCardIcons.tsx +89 -0
- package/components/builder/BlockTypePicker.tsx +2 -0
- package/components/builder/ColumnDragContext.tsx +5 -0
- package/components/builder/ColumnDragOverlay.tsx +38 -11
- package/components/builder/CoverSectionCanvas.tsx +90 -2
- package/components/builder/InsertionLines.tsx +9 -1
- package/components/builder/SectionV2Canvas.tsx +32 -6
- package/components/builder/SectionV2Column.tsx +5 -1
- package/components/builder/asset-browser/R2BrowserContent.tsx +23 -6
- package/components/builder/asset-browser/helpers.ts +4 -0
- package/components/builder/asset-browser/types.ts +2 -1
- package/components/builder/blockStyles.tsx +12 -0
- package/components/builder/editors/AudioBlockEditor.tsx +242 -0
- package/components/builder/editors/BeforeAfterBlockEditor.tsx +360 -0
- package/components/builder/editors/shared.tsx +1 -1
- package/components/builder/hooks/useColumnDrag.ts +206 -132
- package/components/builder/live-preview/LiveAudioPreview.tsx +120 -0
- package/components/builder/live-preview/LiveBeforeAfterPreview.tsx +176 -0
- package/lib/animation/enter-types.ts +2 -0
- package/lib/animation/hover-effect-types.ts +2 -0
- package/lib/builder/block-registrations.ts +83 -1
- 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/builder/types.ts +2 -0
- package/lib/sanity/types.ts +75 -0
- package/lib/version.ts +1 -1
- package/package.json +1 -1
- package/sanity/schemas/blocks/audioBlock.ts +69 -0
- package/sanity/schemas/blocks/beforeAfterBlock.ts +121 -0
- package/sanity/schemas/blocks/index.ts +3 -1
- package/sanity/schemas/index.ts +7 -1
|
@@ -73,6 +73,8 @@ export const BLOCK_ENTER_PRESETS: Record<BlockType, readonly EnterPreset[]> = {
|
|
|
73
73
|
imageGridBlock: ["fade", "scale", "slide-up"],
|
|
74
74
|
videoBlock: ["fade", "slide-up"],
|
|
75
75
|
buttonBlock: ["fade", "slide-up", "scale"],
|
|
76
|
+
beforeAfterBlock: ["fade", "slide-up", "scale"],
|
|
77
|
+
audioBlock: ["fade", "slide-up", "scale"],
|
|
76
78
|
spacerBlock: [], // invisible — no animation
|
|
77
79
|
projectGridBlock: [], // uses card_entrance system
|
|
78
80
|
projectCarouselBlock: [], // uses card_entrance system
|
|
@@ -66,6 +66,8 @@ export const BLOCK_HOVER_PRESETS: Record<BlockType, readonly HoverPreset[]> = {
|
|
|
66
66
|
imageGridBlock: ["tilt-3d"],
|
|
67
67
|
videoBlock: [], // video has play/pause interaction
|
|
68
68
|
buttonBlock: ["scale-up", "lift", "border-glow"],
|
|
69
|
+
beforeAfterBlock: [], // slider handles its own drag interaction
|
|
70
|
+
audioBlock: [], // audio has play/pause interaction
|
|
69
71
|
spacerBlock: [], // invisible
|
|
70
72
|
projectGridBlock: ["scale-up", "lift"], // per-card effect
|
|
71
73
|
projectCarouselBlock: [], // uses per-card hover_effect field directly
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Block registrations — fills the registry defined in `./block-registry.ts`
|
|
5
|
-
* with the
|
|
5
|
+
* with the 10 built-in block types shipped by the framework.
|
|
6
6
|
*
|
|
7
7
|
* Importing this module has a side effect: every `registerBlockType()`
|
|
8
8
|
* call below runs synchronously and populates the module-level registry.
|
|
@@ -26,6 +26,8 @@ import type {
|
|
|
26
26
|
VideoBlock,
|
|
27
27
|
SpacerBlock,
|
|
28
28
|
ButtonBlock,
|
|
29
|
+
BeforeAfterBlock,
|
|
30
|
+
AudioBlock,
|
|
29
31
|
ProjectGridBlock,
|
|
30
32
|
ProjectCarouselBlock,
|
|
31
33
|
} from "../sanity/types";
|
|
@@ -39,6 +41,8 @@ import {
|
|
|
39
41
|
videoBlock,
|
|
40
42
|
spacerBlock,
|
|
41
43
|
buttonBlock,
|
|
44
|
+
beforeAfterBlock,
|
|
45
|
+
audioBlock,
|
|
42
46
|
projectGridBlock,
|
|
43
47
|
projectCarouselBlock,
|
|
44
48
|
} from "../../sanity/schemas/blocks";
|
|
@@ -51,6 +55,8 @@ import ImageGridBlockRenderer from "../../components/blocks/ImageGridBlockRender
|
|
|
51
55
|
import VideoBlockRenderer from "../../components/blocks/VideoBlockRenderer";
|
|
52
56
|
import SpacerBlockRenderer from "../../components/blocks/SpacerBlockRenderer";
|
|
53
57
|
import ButtonBlockRenderer from "../../components/blocks/ButtonBlockRenderer";
|
|
58
|
+
import BeforeAfterBlockRenderer from "../../components/blocks/BeforeAfterBlockRenderer";
|
|
59
|
+
import AudioBlockRenderer from "../../components/blocks/AudioBlockRenderer";
|
|
54
60
|
import ProjectGridBlockRenderer from "../../components/blocks/ProjectGridBlockRenderer";
|
|
55
61
|
import ProjectCarouselBlockRenderer from "../../components/blocks/ProjectCarouselBlockRenderer";
|
|
56
62
|
|
|
@@ -62,6 +68,8 @@ import LiveImageGridPreview from "../../components/builder/live-preview/LiveImag
|
|
|
62
68
|
import LiveVideoPreview from "../../components/builder/live-preview/LiveVideoPreview";
|
|
63
69
|
import LiveSpacerPreview from "../../components/builder/live-preview/LiveSpacerPreview";
|
|
64
70
|
import LiveButtonPreview from "../../components/builder/live-preview/LiveButtonPreview";
|
|
71
|
+
import LiveBeforeAfterPreview from "../../components/builder/live-preview/LiveBeforeAfterPreview";
|
|
72
|
+
import LiveAudioPreview from "../../components/builder/live-preview/LiveAudioPreview";
|
|
65
73
|
import LiveProjectGridPreview from "../../components/builder/live-preview/LiveProjectGridPreview";
|
|
66
74
|
import LiveProjectCarouselPreview from "../../components/builder/live-preview/LiveProjectCarouselPreview";
|
|
67
75
|
|
|
@@ -73,6 +81,8 @@ import ImageGridBlockEditor from "../../components/builder/editors/ImageGridBloc
|
|
|
73
81
|
import VideoBlockEditor from "../../components/builder/editors/VideoBlockEditor";
|
|
74
82
|
import SpacerBlockEditor from "../../components/builder/editors/SpacerBlockEditor";
|
|
75
83
|
import ButtonBlockEditor from "../../components/builder/editors/ButtonBlockEditor";
|
|
84
|
+
import BeforeAfterBlockEditor from "../../components/builder/editors/BeforeAfterBlockEditor";
|
|
85
|
+
import AudioBlockEditor from "../../components/builder/editors/AudioBlockEditor";
|
|
76
86
|
import ProjectGridEditor from "../../components/builder/editors/ProjectGridEditor";
|
|
77
87
|
import ProjectCarouselBlockEditor from "../../components/builder/editors/ProjectCarouselBlockEditor";
|
|
78
88
|
|
|
@@ -85,6 +95,8 @@ import {
|
|
|
85
95
|
VideoBlockCardIcon,
|
|
86
96
|
SpacerBlockCardIcon,
|
|
87
97
|
ButtonBlockCardIcon,
|
|
98
|
+
BeforeAfterBlockCardIcon,
|
|
99
|
+
AudioBlockCardIcon,
|
|
88
100
|
} from "../../components/builder/BlockCardIcons";
|
|
89
101
|
import {
|
|
90
102
|
ProjectGridCardIcon,
|
|
@@ -100,6 +112,8 @@ import {
|
|
|
100
112
|
VideoBlockIcon,
|
|
101
113
|
SpacerBlockIcon,
|
|
102
114
|
ButtonBlockIcon,
|
|
115
|
+
BeforeAfterBlockIcon,
|
|
116
|
+
AudioBlockIcon,
|
|
103
117
|
ProjectGridBlockIcon,
|
|
104
118
|
ProjectCarouselBlockIcon,
|
|
105
119
|
} from "../../components/builder/blockStyles";
|
|
@@ -267,6 +281,74 @@ registerBlockType<ButtonBlock>({
|
|
|
267
281
|
hoverPresets: ["scale-up", "lift", "border-glow"],
|
|
268
282
|
});
|
|
269
283
|
|
|
284
|
+
registerBlockType<BeforeAfterBlock>({
|
|
285
|
+
type: "beforeAfterBlock",
|
|
286
|
+
label: "Before / After",
|
|
287
|
+
description: "Drag-slider comparison between two images or videos",
|
|
288
|
+
category: "content",
|
|
289
|
+
iconGlyph: "◫",
|
|
290
|
+
schema: beforeAfterBlock,
|
|
291
|
+
defaultFactory: (key) => ({
|
|
292
|
+
_type: "beforeAfterBlock",
|
|
293
|
+
_key: key,
|
|
294
|
+
before_media_type: "image",
|
|
295
|
+
before_asset_path: "",
|
|
296
|
+
before_alt: "",
|
|
297
|
+
after_media_type: "image",
|
|
298
|
+
after_asset_path: "",
|
|
299
|
+
after_alt: "",
|
|
300
|
+
orientation: "horizontal",
|
|
301
|
+
initial_position: 50,
|
|
302
|
+
handle_color: "#FFFFFF",
|
|
303
|
+
width: "full",
|
|
304
|
+
aspect_ratio: "16:9",
|
|
305
|
+
video_autoplay: true,
|
|
306
|
+
video_loop: true,
|
|
307
|
+
video_muted: true,
|
|
308
|
+
border_radius: "",
|
|
309
|
+
shadow: false,
|
|
310
|
+
}),
|
|
311
|
+
renderer: BeforeAfterBlockRenderer as React.ComponentType<{ block: BeforeAfterBlock }>,
|
|
312
|
+
livePreview: LiveBeforeAfterPreview as unknown as React.ComponentType<{ block: BeforeAfterBlock; viewport?: import("./types").DeviceViewport; editable?: boolean }>,
|
|
313
|
+
editor: BeforeAfterBlockEditor as React.ComponentType<{ block: BeforeAfterBlock }>,
|
|
314
|
+
cardIcon: BeforeAfterBlockCardIcon,
|
|
315
|
+
compactIcon: BeforeAfterBlockIcon,
|
|
316
|
+
enterPresets: ["fade", "slide-up", "scale"],
|
|
317
|
+
hoverPresets: [],
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
registerBlockType<AudioBlock>({
|
|
321
|
+
type: "audioBlock",
|
|
322
|
+
label: "Audio",
|
|
323
|
+
description: "Minimal audio player with cover art and metadata",
|
|
324
|
+
category: "content",
|
|
325
|
+
iconGlyph: "♪",
|
|
326
|
+
schema: audioBlock,
|
|
327
|
+
defaultFactory: (key) => ({
|
|
328
|
+
_type: "audioBlock",
|
|
329
|
+
_key: key,
|
|
330
|
+
asset_path: "",
|
|
331
|
+
alt: "",
|
|
332
|
+
title: "",
|
|
333
|
+
artist: "",
|
|
334
|
+
cover_path: "",
|
|
335
|
+
accent_color: "#4794E2",
|
|
336
|
+
width: "contained",
|
|
337
|
+
border_radius: "",
|
|
338
|
+
shadow: false,
|
|
339
|
+
autoplay: false,
|
|
340
|
+
loop: false,
|
|
341
|
+
muted: false,
|
|
342
|
+
}),
|
|
343
|
+
renderer: AudioBlockRenderer as React.ComponentType<{ block: AudioBlock }>,
|
|
344
|
+
livePreview: LiveAudioPreview as unknown as React.ComponentType<{ block: AudioBlock; viewport?: import("./types").DeviceViewport; editable?: boolean }>,
|
|
345
|
+
editor: AudioBlockEditor as React.ComponentType<{ block: AudioBlock }>,
|
|
346
|
+
cardIcon: AudioBlockCardIcon,
|
|
347
|
+
compactIcon: AudioBlockIcon,
|
|
348
|
+
enterPresets: ["fade", "slide-up", "scale"],
|
|
349
|
+
hoverPresets: [],
|
|
350
|
+
});
|
|
351
|
+
|
|
270
352
|
// ── Section-level blocks ──
|
|
271
353
|
|
|
272
354
|
registerBlockType<ProjectGridBlock>({
|
|
@@ -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/builder/types.ts
CHANGED
|
@@ -51,6 +51,8 @@ export const BLOCK_TYPE_REGISTRY: BlockTypeInfo[] = [
|
|
|
51
51
|
{ type: "videoBlock", label: "Video", description: "Vimeo, YouTube, or MP4", group: "generic", icon: "▶", category: "content" },
|
|
52
52
|
{ type: "spacerBlock", label: "Spacer", description: "Vertical spacing", group: "generic", icon: "↕", category: "content" },
|
|
53
53
|
{ type: "buttonBlock", label: "Button", description: "Call-to-action button", group: "generic", icon: "▣", category: "content" },
|
|
54
|
+
{ type: "beforeAfterBlock", label: "Before / After", description: "Drag-slider comparison between two images or videos", group: "generic", icon: "◫", category: "content" },
|
|
55
|
+
{ type: "audioBlock", label: "Audio", description: "Minimal audio player with cover art and metadata", group: "generic", icon: "♪", category: "content" },
|
|
54
56
|
];
|
|
55
57
|
|
|
56
58
|
/**
|
package/lib/sanity/types.ts
CHANGED
|
@@ -210,6 +210,62 @@ export interface ButtonBlock {
|
|
|
210
210
|
responsive?: ResponsiveOverrides<ButtonBlock>;
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
export interface BeforeAfterBlock {
|
|
214
|
+
_type: "beforeAfterBlock";
|
|
215
|
+
_key: string;
|
|
216
|
+
// Before side
|
|
217
|
+
before_media_type?: "image" | "video";
|
|
218
|
+
before_asset_path: string;
|
|
219
|
+
before_alt?: string;
|
|
220
|
+
// After side
|
|
221
|
+
after_media_type?: "image" | "video";
|
|
222
|
+
after_asset_path: string;
|
|
223
|
+
after_alt?: string;
|
|
224
|
+
// Slider
|
|
225
|
+
orientation?: "horizontal" | "vertical";
|
|
226
|
+
initial_position?: number; // 0–100
|
|
227
|
+
handle_color?: string; // hex
|
|
228
|
+
// Layout
|
|
229
|
+
width?: "full" | "contained" | "small" | "fill";
|
|
230
|
+
aspect_ratio?: "auto" | "16:9" | "4:3" | "1:1" | "21:9";
|
|
231
|
+
// Video playback (applies when either side is video)
|
|
232
|
+
video_autoplay?: boolean;
|
|
233
|
+
video_loop?: boolean;
|
|
234
|
+
video_muted?: boolean;
|
|
235
|
+
// Appearance
|
|
236
|
+
border_radius?: string;
|
|
237
|
+
shadow?: boolean;
|
|
238
|
+
enter_animation?: import("../../lib/animation/enter-types").EnterAnimationConfig;
|
|
239
|
+
hover_effect?: import("../../lib/animation/hover-effect-types").HoverEffectConfig;
|
|
240
|
+
layout?: BlockLayout;
|
|
241
|
+
responsive?: ResponsiveOverrides<BeforeAfterBlock>;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface AudioBlock {
|
|
245
|
+
_type: "audioBlock";
|
|
246
|
+
_key: string;
|
|
247
|
+
// Source
|
|
248
|
+
asset_path: string;
|
|
249
|
+
alt?: string;
|
|
250
|
+
// Metadata
|
|
251
|
+
title?: string;
|
|
252
|
+
artist?: string;
|
|
253
|
+
cover_path?: string;
|
|
254
|
+
// Appearance
|
|
255
|
+
accent_color?: string;
|
|
256
|
+
width?: "full" | "contained" | "small" | "fill";
|
|
257
|
+
border_radius?: string;
|
|
258
|
+
shadow?: boolean;
|
|
259
|
+
// Playback
|
|
260
|
+
autoplay?: boolean;
|
|
261
|
+
loop?: boolean;
|
|
262
|
+
muted?: boolean;
|
|
263
|
+
enter_animation?: import("../../lib/animation/enter-types").EnterAnimationConfig;
|
|
264
|
+
hover_effect?: import("../../lib/animation/hover-effect-types").HoverEffectConfig;
|
|
265
|
+
layout?: BlockLayout;
|
|
266
|
+
responsive?: ResponsiveOverrides<AudioBlock>;
|
|
267
|
+
}
|
|
268
|
+
|
|
213
269
|
// ============================================
|
|
214
270
|
// Project Grid Block v2 (template-only, Session 105)
|
|
215
271
|
// ============================================
|
|
@@ -596,6 +652,23 @@ export function isCoverSection(item: ContentItem): item is CoverSection {
|
|
|
596
652
|
return (item as CoverSection)._type === "coverSection";
|
|
597
653
|
}
|
|
598
654
|
|
|
655
|
+
/**
|
|
656
|
+
* Type guard: check if a content item supports column drag-and-drop as a
|
|
657
|
+
* source or target — i.e. it has a top-level `columns` array with the same
|
|
658
|
+
* V2 grid semantics. Used by cross-section column DnD (Session 183).
|
|
659
|
+
*
|
|
660
|
+
* Eligible: PageSectionV2, CoverSection
|
|
661
|
+
* NOT eligible:
|
|
662
|
+
* - ParallaxGroup (columns live per-slide, cross-slide DnD not supported)
|
|
663
|
+
* - CustomSectionInstance (references an external document, mutating it
|
|
664
|
+
* from the page would break the reference contract)
|
|
665
|
+
*/
|
|
666
|
+
export function isColumnarSection(
|
|
667
|
+
item: ContentItem,
|
|
668
|
+
): item is PageSectionV2 | CoverSection {
|
|
669
|
+
return isPageSectionV2(item) || isCoverSection(item);
|
|
670
|
+
}
|
|
671
|
+
|
|
599
672
|
// ============================================
|
|
600
673
|
// Shared references
|
|
601
674
|
// ============================================
|
|
@@ -618,6 +691,8 @@ export type ContentBlock =
|
|
|
618
691
|
| VideoBlock
|
|
619
692
|
| SpacerBlock
|
|
620
693
|
| ButtonBlock
|
|
694
|
+
| BeforeAfterBlock
|
|
695
|
+
| AudioBlock
|
|
621
696
|
| ProjectGridBlock
|
|
622
697
|
| ProjectCarouselBlock;
|
|
623
698
|
|