@morphika/andami 0.2.11 → 0.2.13

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.
Files changed (49) hide show
  1. package/README.md +2 -1
  2. package/app/admin/pages/[slug]/page.tsx +39 -2
  3. package/components/blocks/BlockRenderer.tsx +0 -7
  4. package/components/blocks/CoverSectionRenderer.tsx +295 -0
  5. package/components/blocks/PageRenderer.tsx +13 -9
  6. package/components/builder/BlockLivePreview.tsx +0 -5
  7. package/components/builder/BlockTypePicker.tsx +0 -1
  8. package/components/builder/ColorSwatchPicker.tsx +2 -2
  9. package/components/builder/CoverRowResizeHandle.tsx +180 -0
  10. package/components/builder/CoverSectionCanvas.tsx +260 -0
  11. package/components/builder/ReadOnlyFrame.tsx +127 -3
  12. package/components/builder/SectionTypePicker.tsx +29 -0
  13. package/components/builder/SectionV2Canvas.tsx +4 -1
  14. package/components/builder/SectionV2Column.tsx +15 -20
  15. package/components/builder/SettingsPanel.tsx +14 -0
  16. package/components/builder/SortableRow.tsx +7 -21
  17. package/components/builder/blockStyles.tsx +13 -14
  18. package/components/builder/editors/index.ts +0 -1
  19. package/components/builder/index.ts +1 -0
  20. package/components/builder/live-preview/RichTextEditor.tsx +23 -2
  21. package/components/builder/live-preview/index.ts +0 -1
  22. package/components/builder/settings-panel/BlockSettings.tsx +0 -7
  23. package/components/builder/settings-panel/CoverSectionSettings.tsx +296 -0
  24. package/components/builder/settings-panel/index.ts +1 -0
  25. package/components/builder/settings-panel/useSettingsPanelSelection.ts +36 -2
  26. package/lib/animation/enter-types.ts +0 -1
  27. package/lib/animation/hover-effect-types.ts +0 -1
  28. package/lib/builder/defaults.ts +43 -22
  29. package/lib/builder/serializer/normalizers.ts +34 -1
  30. package/lib/builder/serializer/serializers.ts +39 -2
  31. package/lib/builder/store-blocks.ts +11 -3
  32. package/lib/builder/store-cover.ts +220 -0
  33. package/lib/builder/store-helpers.ts +81 -4
  34. package/lib/builder/store-sections.ts +12 -2
  35. package/lib/builder/store.ts +11 -2
  36. package/lib/builder/types.ts +15 -2
  37. package/lib/sanity/types.ts +79 -43
  38. package/lib/version.ts +1 -1
  39. package/package.json +1 -1
  40. package/sanity/schemas/blocks/index.ts +1 -2
  41. package/sanity/schemas/index.ts +5 -3
  42. package/sanity/schemas/objects/coverSection.ts +317 -0
  43. package/sanity/schemas/objects/parallaxSlide.ts +0 -1
  44. package/sanity/schemas/page.ts +1 -1
  45. package/sanity/schemas/pageSectionV2.ts +0 -1
  46. package/components/blocks/CoverBlockRenderer.tsx +0 -261
  47. package/components/builder/editors/CoverBlockEditor.tsx +0 -550
  48. package/components/builder/live-preview/LiveCoverPreview.tsx +0 -146
  49. package/sanity/schemas/blocks/coverBlock.ts +0 -229
@@ -16,6 +16,7 @@ export { SectionV2AnimationTab } from "./SectionV2AnimationTab";
16
16
  export { default as ColumnV2Settings } from "./ColumnV2Settings";
17
17
  export { default as ParallaxSlideSettings } from "./ParallaxSlideSettings";
18
18
  export { default as ParallaxGroupSettings } from "./ParallaxGroupSettings";
19
+ export { default as CoverSectionSettings } from "./CoverSectionSettings";
19
20
  export { useSettingsPanelSelection } from "./useSettingsPanelSelection";
20
21
  export type { SelectedBlockInfo, SelectedParallaxSlideInfo } from "./useSettingsPanelSelection";
21
22
  export { AnimationTab, getBlockHoverEffect } from "./AnimationTab";
@@ -16,11 +16,13 @@ import type {
16
16
  ParallaxGroup,
17
17
  ParallaxSlideV2,
18
18
  SectionColumn,
19
+ CoverSection,
19
20
  } from "../../../lib/sanity/types";
20
21
  import {
21
22
  isPageSectionV2,
22
23
  isCustomSectionInstance,
23
24
  isParallaxGroup,
25
+ isCoverSection,
24
26
  } from "../../../lib/sanity/types";
25
27
 
26
28
  export interface SelectedBlockInfo {
@@ -43,6 +45,7 @@ export function useSettingsPanelSelection() {
43
45
  const selectedItem: ContentItem | undefined = store.rows.find((r) => r._key === store.selectedRowKey);
44
46
  const selectedSectionV2: PageSectionV2 | null = selectedItem && isPageSectionV2(selectedItem) ? selectedItem : null;
45
47
  const selectedCustomSectionInstance: CustomSectionInstance | null = selectedItem && isCustomSectionInstance(selectedItem) ? selectedItem as CustomSectionInstance : null;
48
+ const selectedCoverSection: CoverSection | null = selectedItem && isCoverSection(selectedItem) ? selectedItem as CoverSection : null;
46
49
 
47
50
  // Parallax detection: group selected directly, or slide selected (search inside groups)
48
51
  const selectedParallaxGroup: ParallaxGroup | null = selectedItem && isParallaxGroup(selectedItem) ? selectedItem as ParallaxGroup : null;
@@ -67,8 +70,22 @@ export function useSettingsPanelSelection() {
67
70
  return null;
68
71
  })();
69
72
 
70
- // V2 column: when a V2 section (or parallax slide) is selected and a column key is set
71
- const effectiveSectionV2 = selectedSectionV2 || selectedParallaxSlide?.virtualSection || null;
73
+ // Virtual section for cover sections (enables column/block selection routing)
74
+ const coverVirtualSection: PageSectionV2 | null = selectedCoverSection ? {
75
+ _type: "pageSectionV2",
76
+ _key: selectedCoverSection._key,
77
+ section_type: "empty-v2",
78
+ columns: selectedCoverSection.columns,
79
+ settings: {
80
+ preset: "custom",
81
+ grid_columns: selectedCoverSection.settings.grid_columns || 12,
82
+ col_gap: selectedCoverSection.settings.col_gap ?? 20,
83
+ row_gap: selectedCoverSection.settings.row_gap ?? 20,
84
+ },
85
+ } : null;
86
+
87
+ // V2 column: when a V2 section (or parallax slide or cover section) is selected and a column key is set
88
+ const effectiveSectionV2 = selectedSectionV2 || selectedParallaxSlide?.virtualSection || coverVirtualSection || null;
72
89
  const selectedColumnV2: SectionColumn | null = effectiveSectionV2 && store.selectedColumnKey
73
90
  ? effectiveSectionV2.columns.find((c) => c._key === store.selectedColumnKey) || null
74
91
  : null;
@@ -86,6 +103,15 @@ export function useSettingsPanelSelection() {
86
103
  if (block) return { block, rowKey: item._key, colKey: col._key, isSection: false };
87
104
  }
88
105
  }
106
+ // Cover sections: search inside columns
107
+ if (isCoverSection(item)) {
108
+ for (const col of (item as CoverSection).columns || []) {
109
+ const block = (col.blocks || []).find(
110
+ (b) => b._key === store.selectedBlockKey
111
+ );
112
+ if (block) return { block, rowKey: item._key, colKey: col._key, isSection: false };
113
+ }
114
+ }
89
115
  // Parallax groups: search inside slide columns
90
116
  if (isParallaxGroup(item)) {
91
117
  const group = item as ParallaxGroup;
@@ -118,6 +144,8 @@ export function useSettingsPanelSelection() {
118
144
  ? "Parallax Showcase"
119
145
  : selectedCustomSectionInstance
120
146
  ? (selectedCustomSectionInstance.custom_section_title || "Saved Section")
147
+ : selectedCoverSection
148
+ ? "Cover Section"
121
149
  : selectedSectionV2
122
150
  ? "Section"
123
151
  : "Page";
@@ -131,6 +159,8 @@ export function useSettingsPanelSelection() {
131
159
  ? "parallaxGroup"
132
160
  : selectedCustomSectionInstance
133
161
  ? "customSectionInstance"
162
+ : selectedCoverSection
163
+ ? "coverSection"
134
164
  : selectedSectionV2
135
165
  ? "row"
136
166
  : "page";
@@ -146,11 +176,14 @@ export function useSettingsPanelSelection() {
146
176
  const isCustomSectionOnly = !!(selectedCustomSectionInstance && !selectedBlock);
147
177
  // Page level: nothing selected — show Settings + SEO + Animation (no Layout)
148
178
  const isPageLevel = !hasSelection;
179
+ // Cover section: show all 3 tabs (Settings, Layout, Animation)
180
+ const isCoverSectionOnly = !!(selectedCoverSection && !selectedColumnV2 && !selectedBlock);
149
181
 
150
182
  return {
151
183
  selectedItem,
152
184
  selectedSectionV2,
153
185
  selectedCustomSectionInstance,
186
+ selectedCoverSection,
154
187
  selectedParallaxGroup,
155
188
  selectedParallaxSlide,
156
189
  effectiveSectionV2,
@@ -164,6 +197,7 @@ export function useSettingsPanelSelection() {
164
197
  isColumnOnly,
165
198
  isParallaxGroupOnly,
166
199
  isCustomSectionOnly,
200
+ isCoverSectionOnly,
167
201
  isPageLevel,
168
202
  };
169
203
  }
@@ -73,7 +73,6 @@ 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
- coverBlock: ["fade", "blur", "reveal", "scale"],
77
76
  spacerBlock: [], // invisible — no animation
78
77
  projectGridBlock: [], // uses card_entrance system
79
78
  };
@@ -66,7 +66,6 @@ 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
- coverBlock: ["scale-up", "lift", "ripple", "rgb-shift", "pixelate"],
70
69
  spacerBlock: [], // invisible
71
70
  projectGridBlock: ["scale-up", "lift"], // per-card effect
72
71
  };
@@ -1,4 +1,4 @@
1
- import type { ContentBlock, ParallaxSlideV2, SectionV2Settings } from "../../lib/sanity/types";
1
+ import type { ContentBlock, ParallaxSlideV2, SectionV2Settings, CoverSection, CoverSectionSettings, CoverRow } from "../../lib/sanity/types";
2
2
  import type { BlockType } from "./types";
3
3
  import { generateKey } from "./utils";
4
4
  import type { EnterAnimationConfig, TypewriterConfig } from "../../lib/animation/enter-types";
@@ -38,6 +38,48 @@ export function createDefaultParallaxSlide(): ParallaxSlideV2 {
38
38
  };
39
39
  }
40
40
 
41
+ // ============================================
42
+ // Cover section defaults (Session 176)
43
+ // ============================================
44
+
45
+ export const DEFAULT_COVER_SECTION_SETTINGS: CoverSectionSettings = {
46
+ grid_columns: 12,
47
+ col_gap: 20,
48
+ row_gap: 20,
49
+ spacing_top: "0",
50
+ spacing_bottom: "0",
51
+ };
52
+
53
+ export function createDefaultCoverRow(heightPercent: number = 100): CoverRow {
54
+ return {
55
+ _key: generateKey(),
56
+ height_percent: heightPercent,
57
+ vertical_align: "start",
58
+ };
59
+ }
60
+
61
+ export function createDefaultCoverSection(): CoverSection {
62
+ return {
63
+ _type: "coverSection",
64
+ _key: generateKey(),
65
+ background_type: "image",
66
+ background_position: "center center",
67
+ background_size: "cover",
68
+ background_overlay_color: "#000000",
69
+ background_overlay_opacity: 0,
70
+ height: "100vh",
71
+ cover_rows: [createDefaultCoverRow(100)],
72
+ columns: [{
73
+ _key: generateKey(),
74
+ grid_column: 1,
75
+ grid_row: 1,
76
+ span: 12,
77
+ blocks: [],
78
+ }],
79
+ settings: { ...DEFAULT_COVER_SECTION_SETTINGS },
80
+ };
81
+ }
82
+
41
83
  // ============================================
42
84
  // Default animation configs per block type (Session 117)
43
85
  // ============================================
@@ -138,27 +180,6 @@ export function createDefaultBlock(blockType: BlockType): ContentBlock {
138
180
  style: "primary",
139
181
  size: "medium",
140
182
  };
141
- case "coverBlock":
142
- return {
143
- _type: "coverBlock",
144
- _key,
145
- headline: "",
146
- subheadline: "",
147
- media_type: "image",
148
- media_path: "",
149
- background_size: "cover",
150
- background_position: "center center",
151
- background_repeat: "no-repeat",
152
- overlay: "dark",
153
- overlay_opacity: 50,
154
- content_align_h: "center",
155
- content_align_v: "center",
156
- content_max_width: "800px",
157
- height: "100vh",
158
- mobile_height: "same",
159
- text_color: "#ffffff",
160
- show_scroll_indicator: false,
161
- };
162
183
  case "projectGridBlock":
163
184
  return {
164
185
  _type: "projectGridBlock",
@@ -5,7 +5,7 @@
5
5
  * Extracted from serializer.ts in Session 162.
6
6
  */
7
7
 
8
- import type { Page, ContentBlock, ContentItem, PageSectionV2, SectionColumn, SectionV2Settings, CustomSectionInstance, ParallaxGroup } from "../../../lib/sanity/types";
8
+ import type { Page, ContentBlock, ContentItem, PageSectionV2, SectionColumn, SectionV2Settings, CustomSectionInstance, ParallaxGroup, CoverSection } from "../../../lib/sanity/types";
9
9
  import type { BuilderState, PageSettings } from "../types";
10
10
  import { generateKey } from "../utils";
11
11
  import { DEFAULT_BG_COLOR, DEFAULT_TEXT_COLOR, DEFAULT_GRID_WIDTH } from "../constants";
@@ -175,6 +175,39 @@ export function migrateContentItem(item: Record<string, unknown>): ContentItem {
175
175
  return normalizeParallaxGroup(item as unknown as Partial<ParallaxGroup> & { _key: string });
176
176
  }
177
177
 
178
+ // CoverSection — normalize with defaults for null fields (Session 176)
179
+ if (item._type === "coverSection") {
180
+ const raw = item as unknown as CoverSection;
181
+ const coverRows = (raw.cover_rows ?? []).map((r) => ({
182
+ ...r,
183
+ _key: r._key || generateKey(),
184
+ height_percent: r.height_percent ?? 100,
185
+ vertical_align: r.vertical_align ?? "start",
186
+ }));
187
+ return {
188
+ ...raw,
189
+ _key: (raw._key as string) || generateKey(),
190
+ background_type: raw.background_type ?? "image",
191
+ background_position: raw.background_position ?? "center center",
192
+ background_size: raw.background_size ?? "cover",
193
+ background_overlay_color: raw.background_overlay_color ?? "#000000",
194
+ background_overlay_opacity: raw.background_overlay_opacity ?? 0,
195
+ height: raw.height ?? "100vh",
196
+ cover_rows: coverRows.length > 0 ? coverRows : [{ _key: generateKey(), height_percent: 100, vertical_align: "start" as const }],
197
+ columns: (raw.columns ?? []).map((col) => ({
198
+ ...col,
199
+ _key: col._key || generateKey(),
200
+ blocks: ensureKeys(col.blocks ?? []) as ContentBlock[],
201
+ })),
202
+ settings: {
203
+ ...(raw.settings ?? {}),
204
+ grid_columns: (raw.settings?.grid_columns as number) ?? 12,
205
+ col_gap: (raw.settings?.col_gap as number) ?? 20,
206
+ row_gap: (raw.settings?.row_gap as number) ?? 20,
207
+ },
208
+ } as CoverSection;
209
+ }
210
+
178
211
  // Unknown type — warn and skip
179
212
  console.warn(`[Serializer] Unknown content item type: ${item._type}, skipping`);
180
213
  // Return a dummy PageSectionV2 as fallback
@@ -5,8 +5,8 @@
5
5
  * Extracted from serializer.ts in Session 162.
6
6
  */
7
7
 
8
- import type { ContentBlock, ContentItem, PageSectionV2, ParallaxGroup } from "../../../lib/sanity/types";
9
- import { isPageSectionV2, isCustomSectionInstance, isParallaxGroup } from "../../../lib/sanity/types";
8
+ import type { ContentBlock, ContentItem, PageSectionV2, ParallaxGroup, CoverSection } from "../../../lib/sanity/types";
9
+ import { isPageSectionV2, isCustomSectionInstance, isParallaxGroup, isCoverSection } from "../../../lib/sanity/types";
10
10
  import type { BuilderState } from "../types";
11
11
  import { DEFAULT_BG_COLOR, DEFAULT_TEXT_COLOR } from "../constants";
12
12
 
@@ -210,6 +210,39 @@ function serializeParallaxGroup(group: ParallaxGroup): Record<string, unknown> {
210
210
  };
211
211
  }
212
212
 
213
+ // ============================================
214
+ // Cover Section Serialization (Session 176)
215
+ // ============================================
216
+
217
+ function serializeCoverSection(section: CoverSection): Record<string, unknown> {
218
+ const columns = (section.columns || []).map((col) => stripUndefined({
219
+ _type: "sectionColumn",
220
+ _key: col._key,
221
+ grid_column: col.grid_column,
222
+ grid_row: col.grid_row,
223
+ span: col.span,
224
+ blocks: (col.blocks || []).map((b) => serializeBlock(b)),
225
+ enter_animation: col.enter_animation,
226
+ } as Record<string, unknown>));
227
+
228
+ return stripUndefined({
229
+ _type: "coverSection",
230
+ _key: section._key,
231
+ background_type: section.background_type,
232
+ background_image: section.background_image,
233
+ background_video: section.background_video,
234
+ background_position: section.background_position,
235
+ background_size: section.background_size,
236
+ background_overlay_color: section.background_overlay_color,
237
+ background_overlay_opacity: section.background_overlay_opacity,
238
+ height: section.height,
239
+ cover_rows: section.cover_rows,
240
+ columns,
241
+ settings: stripUndefined(section.settings as unknown as Record<string, unknown>),
242
+ responsive: section.responsive,
243
+ } as Record<string, unknown>);
244
+ }
245
+
213
246
  // ============================================
214
247
  // Content Item Dispatcher
215
248
  // ============================================
@@ -248,6 +281,10 @@ function serializeContentItem(item: ContentItem): Record<string, unknown> {
248
281
  if (isParallaxGroup(item)) {
249
282
  return serializeParallaxGroup(item);
250
283
  }
284
+ // CoverSection — serialize background, rows, columns, settings (Session 176)
285
+ if (isCoverSection(item)) {
286
+ return serializeCoverSection(item as CoverSection);
287
+ }
251
288
  // Should never reach here
252
289
  throw new Error(`[Serializer] Unknown content item type during serialization`);
253
290
  }
@@ -1,6 +1,6 @@
1
1
  import type { BuilderStore, BuilderState } from "./types";
2
- import type { ContentBlock, ContentItem, PageSectionV2, ParallaxGroup } from "../../lib/sanity/types";
3
- import { isPageSectionV2, isParallaxGroup } from "../../lib/sanity/types";
2
+ import type { ContentBlock, ContentItem, PageSectionV2, ParallaxGroup, CoverSection } from "../../lib/sanity/types";
3
+ import { isPageSectionV2, isParallaxGroup, isCoverSection } from "../../lib/sanity/types";
4
4
  import { createDefaultBlock } from "./defaults";
5
5
  import { generateKey } from "./utils";
6
6
  import { findSectionPath, updateSectionAtPath, moveBlockInState } from "./store-helpers";
@@ -30,6 +30,9 @@ function applyBlockUpdate(
30
30
  if (isPageSectionV2(item)) {
31
31
  return { ...item, columns: updateColumns(item.columns) };
32
32
  }
33
+ if (isCoverSection(item)) {
34
+ return { ...item, columns: updateColumns((item as CoverSection).columns) } as ContentItem;
35
+ }
33
36
  if (isParallaxGroup(item)) {
34
37
  return {
35
38
  ...item,
@@ -61,11 +64,13 @@ export function createBlockActions(set: StoreSet, get: StoreGet) {
61
64
  cols.map((c) => ({ ...c, blocks: c.blocks.filter((b) => b._key !== blockKey) }));
62
65
 
63
66
  set((state) => {
64
- // Handle V2 Sections + ParallaxGroup slides
65
67
  const finalRows = state.rows.map((item) => {
66
68
  if (isPageSectionV2(item)) {
67
69
  return { ...item, columns: filterBlocks(item.columns) } as ContentItem;
68
70
  }
71
+ if (isCoverSection(item)) {
72
+ return { ...item, columns: filterBlocks((item as CoverSection).columns) } as ContentItem;
73
+ }
69
74
  if (isParallaxGroup(item)) {
70
75
  return {
71
76
  ...item,
@@ -105,6 +110,9 @@ export function createBlockActions(set: StoreSet, get: StoreGet) {
105
110
  if (isPageSectionV2(item)) {
106
111
  return { ...item, columns: dupInColumns(item.columns) } as ContentItem;
107
112
  }
113
+ if (isCoverSection(item)) {
114
+ return { ...item, columns: dupInColumns((item as CoverSection).columns) } as ContentItem;
115
+ }
108
116
  if (isParallaxGroup(item)) {
109
117
  return {
110
118
  ...item,
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Cover Section store actions.
3
+ *
4
+ * Manages cover-section-specific operations: row CRUD, row resize,
5
+ * background/settings updates. Column and block operations within
6
+ * cover sections are handled by the existing V2 actions via
7
+ * findSectionPath() which now supports cover sections.
8
+ *
9
+ * Session 176: Cover Sections — Phase 3 (Store).
10
+ */
11
+
12
+ import type { BuilderState } from "./types";
13
+ import type {
14
+ ContentItem,
15
+ CoverSection,
16
+ CoverSectionSettings,
17
+ CoverRow,
18
+ } from "../../lib/sanity/types";
19
+ import { isCoverSection } from "../../lib/sanity/types";
20
+ import { generateKey } from "./utils";
21
+ import { createDefaultCoverSection, createDefaultCoverRow } from "./defaults";
22
+
23
+ type StoreSet = (
24
+ partial: Partial<BuilderState> | ((state: BuilderState) => Partial<BuilderState>)
25
+ ) => void;
26
+ type StoreGet = () => BuilderState & { _pushSnapshot: () => void };
27
+
28
+ const MIN_ROW_PERCENT = 10;
29
+
30
+ function updateCoverInRows(
31
+ rows: ContentItem[],
32
+ sectionKey: string,
33
+ updater: (section: CoverSection) => CoverSection
34
+ ): ContentItem[] {
35
+ return rows.map((item) => {
36
+ if (item._key === sectionKey && isCoverSection(item)) {
37
+ return updater(item as CoverSection) as ContentItem;
38
+ }
39
+ return item;
40
+ });
41
+ }
42
+
43
+ export function createCoverActions(set: StoreSet, get: StoreGet) {
44
+ return {
45
+ addCoverSection: (afterRowKey?: string | null): void => {
46
+ get()._pushSnapshot();
47
+ const section = createDefaultCoverSection();
48
+ set((state) => {
49
+ const rows = [...state.rows];
50
+ if (afterRowKey) {
51
+ const idx = rows.findIndex((r) => r._key === afterRowKey);
52
+ if (idx !== -1) {
53
+ rows.splice(idx + 1, 0, section);
54
+ } else {
55
+ rows.push(section);
56
+ }
57
+ } else {
58
+ rows.push(section);
59
+ }
60
+ return { rows, isDirty: true, selectedRowKey: section._key };
61
+ });
62
+ },
63
+
64
+ addCoverRow: (sectionKey: string): void => {
65
+ get()._pushSnapshot();
66
+ set((state) => ({
67
+ rows: updateCoverInRows(state.rows, sectionKey, (section) => {
68
+ if (section.cover_rows.length >= 5) return section;
69
+ const totalRows = section.cover_rows.length + 1;
70
+ const equalPercent = Math.floor(100 / totalRows);
71
+ const remainder = 100 - equalPercent * totalRows;
72
+ const newRows = section.cover_rows.map((r, i) => ({
73
+ ...r,
74
+ height_percent: equalPercent + (i === 0 ? remainder : 0),
75
+ }));
76
+ newRows.push(createDefaultCoverRow(equalPercent));
77
+ return { ...section, cover_rows: newRows };
78
+ }),
79
+ isDirty: true,
80
+ }));
81
+ },
82
+
83
+ removeCoverRow: (sectionKey: string, rowKey: string): void => {
84
+ get()._pushSnapshot();
85
+ set((state) => ({
86
+ rows: updateCoverInRows(state.rows, sectionKey, (section) => {
87
+ if (section.cover_rows.length <= 1) return section;
88
+ const rowIndex = section.cover_rows.findIndex((r) => r._key === rowKey);
89
+ if (rowIndex === -1) return section;
90
+ const removedPercent = section.cover_rows[rowIndex].height_percent;
91
+ const remaining = section.cover_rows.filter((r) => r._key !== rowKey);
92
+
93
+ const neighborIndex = rowIndex > 0 ? rowIndex - 1 : 0;
94
+ const redistributed = remaining.map((r, i) => {
95
+ if (remaining.length === 1) {
96
+ return { ...r, height_percent: 100 };
97
+ }
98
+ if (i === neighborIndex) {
99
+ return { ...r, height_percent: r.height_percent + removedPercent };
100
+ }
101
+ return r;
102
+ });
103
+
104
+ const filteredColumns = section.columns.filter(
105
+ (c) => c.grid_row !== rowIndex + 1
106
+ );
107
+ const reindexedColumns = filteredColumns.map((c) => ({
108
+ ...c,
109
+ grid_row: c.grid_row > rowIndex + 1 ? c.grid_row - 1 : c.grid_row,
110
+ }));
111
+
112
+ return {
113
+ ...section,
114
+ cover_rows: redistributed,
115
+ columns: reindexedColumns,
116
+ };
117
+ }),
118
+ isDirty: true,
119
+ }));
120
+ },
121
+
122
+ /**
123
+ * Resize cover rows by dragging the handle between row[handleIndex] and row[handleIndex+1].
124
+ * deltaPercent is the total delta from the start of the drag.
125
+ * startAbove/startBelow are the original percentages captured at mousedown —
126
+ * computation is always from these start values to avoid compounding.
127
+ */
128
+ resizeCoverRow: (sectionKey: string, handleIndex: number, deltaPercent: number, startAbove: number, startBelow: number): void => {
129
+ set((state) => ({
130
+ rows: updateCoverInRows(state.rows, sectionKey, (section) => {
131
+ const rows = section.cover_rows;
132
+ if (handleIndex < 0 || handleIndex >= rows.length - 1) return section;
133
+
134
+ const total = startAbove + startBelow;
135
+ let newAbove = startAbove + deltaPercent;
136
+ let newBelow = startBelow - deltaPercent;
137
+
138
+ if (newAbove < MIN_ROW_PERCENT) {
139
+ newAbove = MIN_ROW_PERCENT;
140
+ newBelow = total - MIN_ROW_PERCENT;
141
+ }
142
+ if (newBelow < MIN_ROW_PERCENT) {
143
+ newBelow = MIN_ROW_PERCENT;
144
+ newAbove = total - MIN_ROW_PERCENT;
145
+ }
146
+
147
+ const updatedRows = rows.map((r, i) => {
148
+ if (i === handleIndex) return { ...r, height_percent: newAbove };
149
+ if (i === handleIndex + 1) return { ...r, height_percent: newBelow };
150
+ return r;
151
+ });
152
+
153
+ return { ...section, cover_rows: updatedRows };
154
+ }),
155
+ isDirty: true,
156
+ }));
157
+ },
158
+
159
+ updateCoverRowAlign: (
160
+ sectionKey: string,
161
+ rowKey: string,
162
+ align: CoverRow["vertical_align"]
163
+ ): void => {
164
+ set((state) => ({
165
+ rows: updateCoverInRows(state.rows, sectionKey, (section) => ({
166
+ ...section,
167
+ cover_rows: section.cover_rows.map((r) =>
168
+ r._key === rowKey ? { ...r, vertical_align: align } : r
169
+ ),
170
+ })),
171
+ isDirty: true,
172
+ }));
173
+ },
174
+
175
+ updateCoverBackground: (
176
+ sectionKey: string,
177
+ fields: Partial<Pick<CoverSection,
178
+ "background_type" | "background_image" | "background_video" |
179
+ "background_position" | "background_size" |
180
+ "background_overlay_color" | "background_overlay_opacity"
181
+ >>
182
+ ): void => {
183
+ get()._pushSnapshot();
184
+ set((state) => ({
185
+ rows: updateCoverInRows(state.rows, sectionKey, (section) => ({
186
+ ...section,
187
+ ...fields,
188
+ })),
189
+ isDirty: true,
190
+ }));
191
+ },
192
+
193
+ updateCoverSettings: (
194
+ sectionKey: string,
195
+ settings: Partial<CoverSectionSettings>
196
+ ): void => {
197
+ set((state) => ({
198
+ rows: updateCoverInRows(state.rows, sectionKey, (section) => ({
199
+ ...section,
200
+ settings: { ...section.settings, ...settings },
201
+ })),
202
+ isDirty: true,
203
+ }));
204
+ },
205
+
206
+ updateCoverHeight: (
207
+ sectionKey: string,
208
+ height: CoverSection["height"]
209
+ ): void => {
210
+ get()._pushSnapshot();
211
+ set((state) => ({
212
+ rows: updateCoverInRows(state.rows, sectionKey, (section) => ({
213
+ ...section,
214
+ height,
215
+ })),
216
+ isDirty: true,
217
+ }));
218
+ },
219
+ };
220
+ }