@morphika/webframe 0.1.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.
Files changed (319) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/admin/assets.ts +4 -0
  4. package/admin/database.ts +4 -0
  5. package/admin/index.ts +6 -0
  6. package/admin/login.ts +4 -0
  7. package/admin/navigation.ts +4 -0
  8. package/admin/pages-editor.ts +4 -0
  9. package/admin/pages.ts +4 -0
  10. package/admin/projects-editor.ts +4 -0
  11. package/admin/projects.ts +4 -0
  12. package/admin/settings.ts +4 -0
  13. package/admin/setup.ts +4 -0
  14. package/admin/storage.ts +4 -0
  15. package/admin/styles.ts +4 -0
  16. package/app/(site)/[slug]/loading.tsx +20 -0
  17. package/app/(site)/[slug]/page.tsx +83 -0
  18. package/app/(site)/error.tsx +32 -0
  19. package/app/(site)/layout.tsx +53 -0
  20. package/app/(site)/loading.tsx +20 -0
  21. package/app/(site)/not-found.tsx +41 -0
  22. package/app/(site)/page.tsx +43 -0
  23. package/app/(site)/preview/page.tsx +99 -0
  24. package/app/(site)/work/[slug]/loading.tsx +23 -0
  25. package/app/(site)/work/[slug]/page.tsx +84 -0
  26. package/app/admin/assets/page.tsx +573 -0
  27. package/app/admin/database/page.tsx +302 -0
  28. package/app/admin/error.tsx +53 -0
  29. package/app/admin/layout.tsx +273 -0
  30. package/app/admin/login/page.tsx +88 -0
  31. package/app/admin/navigation/page.tsx +157 -0
  32. package/app/admin/page.tsx +17 -0
  33. package/app/admin/pages/[slug]/page.tsx +849 -0
  34. package/app/admin/pages/page.tsx +588 -0
  35. package/app/admin/projects/[slug]/page.tsx +3 -0
  36. package/app/admin/projects/page.tsx +669 -0
  37. package/app/admin/settings/page.tsx +132 -0
  38. package/app/admin/setup/page.tsx +64 -0
  39. package/app/admin/storage/page.tsx +518 -0
  40. package/app/admin/styles/page.tsx +243 -0
  41. package/app/api/admin/assets/file/route.ts +81 -0
  42. package/app/api/admin/assets/health/route.ts +170 -0
  43. package/app/api/admin/assets/register/route.ts +163 -0
  44. package/app/api/admin/assets/registry/route.ts +98 -0
  45. package/app/api/admin/assets/relink/confirm/route.ts +242 -0
  46. package/app/api/admin/assets/relink/route.ts +202 -0
  47. package/app/api/admin/assets/scan/route.ts +271 -0
  48. package/app/api/admin/auth/route.ts +160 -0
  49. package/app/api/admin/custom-sections/[slug]/route.ts +159 -0
  50. package/app/api/admin/custom-sections/route.ts +127 -0
  51. package/app/api/admin/database/route.ts +53 -0
  52. package/app/api/admin/pages/[slug]/duplicate/route.ts +91 -0
  53. package/app/api/admin/pages/[slug]/route.ts +617 -0
  54. package/app/api/admin/pages/[slug]/set-home/route.ts +76 -0
  55. package/app/api/admin/pages/route.ts +129 -0
  56. package/app/api/admin/preview/route.ts +53 -0
  57. package/app/api/admin/r2/connect/route.ts +181 -0
  58. package/app/api/admin/r2/delete/route.ts +198 -0
  59. package/app/api/admin/r2/disconnect/route.ts +42 -0
  60. package/app/api/admin/r2/rename/route.ts +265 -0
  61. package/app/api/admin/r2/status/route.ts +106 -0
  62. package/app/api/admin/r2/upload-url/route.ts +148 -0
  63. package/app/api/admin/revalidate/route.ts +55 -0
  64. package/app/api/admin/settings/route.ts +279 -0
  65. package/app/api/admin/setup/complete/route.ts +51 -0
  66. package/app/api/admin/setup/route.ts +118 -0
  67. package/app/api/admin/storage/switch/route.ts +117 -0
  68. package/app/api/admin/styles/fonts/route.ts +97 -0
  69. package/app/api/admin/styles/route.ts +304 -0
  70. package/app/api/assets/[...path]/route.ts +98 -0
  71. package/app/api/custom-sections/[id]/route.ts +43 -0
  72. package/app/api/draft-mode/disable/route.ts +10 -0
  73. package/app/api/draft-mode/enable/route.ts +26 -0
  74. package/app/api/projects/route.ts +42 -0
  75. package/app/api/styles/route.ts +88 -0
  76. package/app/favicon.ico +0 -0
  77. package/app/globals.css +7 -0
  78. package/app/layout.tsx +53 -0
  79. package/app/robots.ts +17 -0
  80. package/app/sitemap.ts +48 -0
  81. package/app/studio/[[...index]]/page.tsx +8 -0
  82. package/components/admin/MetadataEditor.tsx +173 -0
  83. package/components/admin/PublishToggle.tsx +130 -0
  84. package/components/admin/icons.tsx +40 -0
  85. package/components/admin/nav-builder/NavBuilder.tsx +182 -0
  86. package/components/admin/nav-builder/NavBuilderGrid.tsx +326 -0
  87. package/components/admin/nav-builder/NavGeneralSettings.tsx +275 -0
  88. package/components/admin/nav-builder/NavGridCell.tsx +48 -0
  89. package/components/admin/nav-builder/NavGridItem.tsx +189 -0
  90. package/components/admin/nav-builder/NavItemSettings.tsx +288 -0
  91. package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -0
  92. package/components/admin/nav-builder/NavLivePreview.tsx +125 -0
  93. package/components/admin/nav-builder/NavSettingsFields.tsx +248 -0
  94. package/components/admin/nav-builder/NavSettingsPanel.tsx +127 -0
  95. package/components/admin/nav-builder/index.ts +10 -0
  96. package/components/admin/nav-builder/nav-builder-utils.ts +238 -0
  97. package/components/admin/setup-wizard/BrandingStep.tsx +218 -0
  98. package/components/admin/setup-wizard/DatabaseStep.tsx +331 -0
  99. package/components/admin/setup-wizard/DoneStep.tsx +187 -0
  100. package/components/admin/setup-wizard/SetupWizard.tsx +166 -0
  101. package/components/admin/setup-wizard/StorageStep.tsx +308 -0
  102. package/components/admin/setup-wizard/WelcomeStep.tsx +96 -0
  103. package/components/admin/setup-wizard/index.ts +9 -0
  104. package/components/admin/styles/ColorsEditor.tsx +214 -0
  105. package/components/admin/styles/FontsEditor.tsx +258 -0
  106. package/components/admin/styles/GridLayoutEditor.tsx +292 -0
  107. package/components/admin/styles/LinksButtonsEditor.tsx +120 -0
  108. package/components/admin/styles/TypographyEditor.tsx +266 -0
  109. package/components/admin/styles/index.ts +9 -0
  110. package/components/admin/styles/shared.tsx +68 -0
  111. package/components/blocks/BlockRenderer.tsx +404 -0
  112. package/components/blocks/ButtonBlockRenderer.tsx +52 -0
  113. package/components/blocks/CoverBlockRenderer.tsx +239 -0
  114. package/components/blocks/CustomSectionInstanceRenderer.tsx +82 -0
  115. package/components/blocks/EnterAnimationWrapper.tsx +140 -0
  116. package/components/blocks/HoverAnimationWrapper.tsx +308 -0
  117. package/components/blocks/ImageBlockRenderer.tsx +61 -0
  118. package/components/blocks/ImageGridBlockRenderer.tsx +545 -0
  119. package/components/blocks/PageBackground.tsx +28 -0
  120. package/components/blocks/PageNavAnimation.tsx +35 -0
  121. package/components/blocks/PageNavColor.tsx +24 -0
  122. package/components/blocks/PageRenderer.tsx +142 -0
  123. package/components/blocks/ParallaxGroupRenderer.tsx +448 -0
  124. package/components/blocks/ParallaxSlideRenderer.tsx +175 -0
  125. package/components/blocks/ProjectGridBlockRenderer.tsx +556 -0
  126. package/components/blocks/SectionRenderer.tsx +170 -0
  127. package/components/blocks/SectionV2Renderer.tsx +330 -0
  128. package/components/blocks/ShaderCanvas.tsx +392 -0
  129. package/components/blocks/SpacerBlockRenderer.tsx +17 -0
  130. package/components/blocks/TextBlockRenderer.tsx +87 -0
  131. package/components/blocks/TypewriterRichText.tsx +464 -0
  132. package/components/blocks/TypewriterWrapper.tsx +149 -0
  133. package/components/blocks/VideoBlockRenderer.tsx +304 -0
  134. package/components/blocks/index.ts +2 -0
  135. package/components/builder/AssetBrowser.tsx +2 -0
  136. package/components/builder/BlockLivePreview.tsx +101 -0
  137. package/components/builder/BlockTypePicker.tsx +178 -0
  138. package/components/builder/BuilderCanvas.tsx +354 -0
  139. package/components/builder/CanvasMinimap.tsx +200 -0
  140. package/components/builder/CanvasToolbar.tsx +202 -0
  141. package/components/builder/ColorPicker.tsx +243 -0
  142. package/components/builder/ColorSwatchPicker.tsx +274 -0
  143. package/components/builder/ColumnDragContext.tsx +51 -0
  144. package/components/builder/ColumnDragOverlay.tsx +110 -0
  145. package/components/builder/CustomSectionInstanceCard.tsx +97 -0
  146. package/components/builder/DeviceFrame.tsx +123 -0
  147. package/components/builder/DndWrapper.tsx +337 -0
  148. package/components/builder/InsertionLines.tsx +186 -0
  149. package/components/builder/ParallaxGroupCanvas.tsx +228 -0
  150. package/components/builder/ParallaxSlideHeader.tsx +113 -0
  151. package/components/builder/ReadOnlyFrame.tsx +417 -0
  152. package/components/builder/SectionEditorBar.tsx +288 -0
  153. package/components/builder/SectionTypePicker.tsx +422 -0
  154. package/components/builder/SectionV2Canvas.tsx +297 -0
  155. package/components/builder/SectionV2Column.tsx +488 -0
  156. package/components/builder/SettingsPanel.tsx +911 -0
  157. package/components/builder/SortableBlock.tsx +230 -0
  158. package/components/builder/SortableRow.tsx +362 -0
  159. package/components/builder/VirtualAssetGrid.tsx +397 -0
  160. package/components/builder/asset-browser/AssetBrowser.tsx +178 -0
  161. package/components/builder/asset-browser/FileLightbox.tsx +116 -0
  162. package/components/builder/asset-browser/FolderTreeItem.tsx +55 -0
  163. package/components/builder/asset-browser/R2BrowserContent.tsx +436 -0
  164. package/components/builder/asset-browser/R2ContextMenu.tsx +98 -0
  165. package/components/builder/asset-browser/VideoThumbnail.tsx +63 -0
  166. package/components/builder/asset-browser/helpers.ts +88 -0
  167. package/components/builder/asset-browser/index.ts +1 -0
  168. package/components/builder/asset-browser/types.ts +49 -0
  169. package/components/builder/asset-browser/useAssetBrowser.ts +344 -0
  170. package/components/builder/asset-browser/useR2DragDrop.ts +116 -0
  171. package/components/builder/asset-browser/useR2Operations.ts +189 -0
  172. package/components/builder/blockStyles.tsx +295 -0
  173. package/components/builder/editors/ButtonBlockEditor.tsx +184 -0
  174. package/components/builder/editors/CoverBlockEditor.tsx +488 -0
  175. package/components/builder/editors/EnterAnimationPicker.tsx +297 -0
  176. package/components/builder/editors/HoverEffectPicker.tsx +209 -0
  177. package/components/builder/editors/ImageBlockEditor.tsx +206 -0
  178. package/components/builder/editors/ImageGridBlockEditor.tsx +386 -0
  179. package/components/builder/editors/ProjectGridEditor.tsx +648 -0
  180. package/components/builder/editors/SpacerBlockEditor.tsx +167 -0
  181. package/components/builder/editors/StaggerSettings.tsx +108 -0
  182. package/components/builder/editors/TextAlignmentIcons.tsx +39 -0
  183. package/components/builder/editors/TextBlockEditor.tsx +462 -0
  184. package/components/builder/editors/TextStylePicker.tsx +183 -0
  185. package/components/builder/editors/VideoBlockEditor.tsx +278 -0
  186. package/components/builder/editors/index.ts +10 -0
  187. package/components/builder/editors/shared.tsx +345 -0
  188. package/components/builder/hooks/useColumnDrag.ts +472 -0
  189. package/components/builder/hooks/useColumnResize.ts +221 -0
  190. package/components/builder/index.ts +12 -0
  191. package/components/builder/live-preview/LiveButtonPreview.tsx +38 -0
  192. package/components/builder/live-preview/LiveCoverPreview.tsx +146 -0
  193. package/components/builder/live-preview/LiveImageGridPreview.tsx +123 -0
  194. package/components/builder/live-preview/LiveImagePreview.tsx +107 -0
  195. package/components/builder/live-preview/LiveProjectGridPreview.tsx +1010 -0
  196. package/components/builder/live-preview/LiveSpacerPreview.tsx +9 -0
  197. package/components/builder/live-preview/LiveTextEditor.tsx +198 -0
  198. package/components/builder/live-preview/LiveVideoPreview.tsx +98 -0
  199. package/components/builder/live-preview/index.ts +10 -0
  200. package/components/builder/live-preview/shared.tsx +153 -0
  201. package/components/builder/settings-panel/BlockLayoutTab.tsx +532 -0
  202. package/components/builder/settings-panel/BlockSettings.tsx +94 -0
  203. package/components/builder/settings-panel/ColumnV2Settings.tsx +160 -0
  204. package/components/builder/settings-panel/LayoutTab.tsx +310 -0
  205. package/components/builder/settings-panel/PageSettings.tsx +200 -0
  206. package/components/builder/settings-panel/ParallaxGroupSettings.tsx +118 -0
  207. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +178 -0
  208. package/components/builder/settings-panel/SectionV2AnimationTab.tsx +103 -0
  209. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +312 -0
  210. package/components/builder/settings-panel/SectionV2Settings.tsx +323 -0
  211. package/components/builder/settings-panel/TRBLInputs.tsx +51 -0
  212. package/components/builder/settings-panel/index.ts +19 -0
  213. package/components/builder/settings-panel/responsive-helpers.ts +524 -0
  214. package/components/ui/CustomCursor.tsx +118 -0
  215. package/components/ui/NavContentLightbox.tsx +152 -0
  216. package/components/ui/Navbar.tsx +582 -0
  217. package/components/ui/PortfolioTracker.tsx +87 -0
  218. package/components/ui/ScrollToTop.tsx +47 -0
  219. package/lib/animation/enter-presets.ts +147 -0
  220. package/lib/animation/enter-resolve.ts +90 -0
  221. package/lib/animation/enter-types.ts +128 -0
  222. package/lib/animation/hover-effect-presets.ts +210 -0
  223. package/lib/animation/hover-effect-types.ts +126 -0
  224. package/lib/asset-retry.ts +111 -0
  225. package/lib/assets.ts +92 -0
  226. package/lib/audit.ts +35 -0
  227. package/lib/auth-token.ts +94 -0
  228. package/lib/auth.ts +13 -0
  229. package/lib/builder/cascade-helpers.ts +51 -0
  230. package/lib/builder/cascade.ts +533 -0
  231. package/lib/builder/constants.ts +103 -0
  232. package/lib/builder/defaults.ts +182 -0
  233. package/lib/builder/history.ts +48 -0
  234. package/lib/builder/index.ts +21 -0
  235. package/lib/builder/layout-styles.ts +344 -0
  236. package/lib/builder/masonry.ts +166 -0
  237. package/lib/builder/responsive.ts +156 -0
  238. package/lib/builder/serializer.ts +845 -0
  239. package/lib/builder/store-blocks.ts +193 -0
  240. package/lib/builder/store-canvas.ts +319 -0
  241. package/lib/builder/store-helpers.ts +490 -0
  242. package/lib/builder/store-sections.ts +709 -0
  243. package/lib/builder/store.ts +333 -0
  244. package/lib/builder/templates.ts +297 -0
  245. package/lib/builder/types.ts +374 -0
  246. package/lib/builder/utils.ts +37 -0
  247. package/lib/color-utils.ts +116 -0
  248. package/lib/config/index.ts +57 -0
  249. package/lib/config/types.ts +122 -0
  250. package/lib/contexts/AssetContext.tsx +79 -0
  251. package/lib/contexts/NavAnimationContext.tsx +44 -0
  252. package/lib/contexts/NavColorContext.tsx +38 -0
  253. package/lib/contexts/PageExitContext.tsx +194 -0
  254. package/lib/contexts/ThumbStatusContext.tsx +83 -0
  255. package/lib/csrf-client.ts +34 -0
  256. package/lib/csrf.ts +68 -0
  257. package/lib/format-utils.ts +24 -0
  258. package/lib/hooks/useViewport.ts +42 -0
  259. package/lib/logger.ts +81 -0
  260. package/lib/revalidate.ts +23 -0
  261. package/lib/sanitize.ts +91 -0
  262. package/lib/sanity/client.ts +8 -0
  263. package/lib/sanity/queries.ts +486 -0
  264. package/lib/sanity/types.ts +869 -0
  265. package/lib/sanity/writeClient.ts +24 -0
  266. package/lib/security.ts +402 -0
  267. package/lib/setup/detect.ts +156 -0
  268. package/lib/shader/glsl/index.ts +27 -0
  269. package/lib/shader/glsl/pixelate.ts +51 -0
  270. package/lib/shader/glsl/rgb-shift.ts +45 -0
  271. package/lib/shader/glsl/ripple.ts +46 -0
  272. package/lib/shader/glsl/vertex.ts +14 -0
  273. package/lib/storage/index.ts +211 -0
  274. package/lib/storage/r2-adapter.ts +286 -0
  275. package/lib/storage/types.ts +125 -0
  276. package/lib/styles/provider.tsx +267 -0
  277. package/lib/thumbnails/generate.ts +151 -0
  278. package/lib/utils.ts +6 -0
  279. package/package.json +212 -0
  280. package/sanity/compose.ts +65 -0
  281. package/sanity/sanity.config.ts +126 -0
  282. package/sanity/schemas/assetRegistry.ts +301 -0
  283. package/sanity/schemas/blocks/blockLayout.ts +90 -0
  284. package/sanity/schemas/blocks/buttonBlock.ts +82 -0
  285. package/sanity/schemas/blocks/coverBlock.ts +229 -0
  286. package/sanity/schemas/blocks/imageBlock.ts +58 -0
  287. package/sanity/schemas/blocks/imageGridBlock.ts +112 -0
  288. package/sanity/schemas/blocks/index.ts +9 -0
  289. package/sanity/schemas/blocks/projectGridBlock.ts +251 -0
  290. package/sanity/schemas/blocks/spacerBlock.ts +41 -0
  291. package/sanity/schemas/blocks/textBlock.ts +139 -0
  292. package/sanity/schemas/blocks/videoBlock.ts +80 -0
  293. package/sanity/schemas/customSection.ts +69 -0
  294. package/sanity/schemas/customSectionInstance.ts +163 -0
  295. package/sanity/schemas/index.ts +111 -0
  296. package/sanity/schemas/objects/enterAnimationConfig.ts +72 -0
  297. package/sanity/schemas/objects/hoverEffectConfig.ts +90 -0
  298. package/sanity/schemas/objects/parallaxGroup.ts +66 -0
  299. package/sanity/schemas/objects/parallaxSlide.ts +217 -0
  300. package/sanity/schemas/objects/typewriterConfig.ts +38 -0
  301. package/sanity/schemas/page.ts +162 -0
  302. package/sanity/schemas/pageSection.ts +157 -0
  303. package/sanity/schemas/pageSectionV2.ts +269 -0
  304. package/sanity/schemas/siteSettings.ts +256 -0
  305. package/sanity/schemas/siteStyles.ts +210 -0
  306. package/site/error.ts +4 -0
  307. package/site/index.ts +8 -0
  308. package/site/not-found.ts +4 -0
  309. package/site/page.ts +4 -0
  310. package/site/preview.ts +4 -0
  311. package/site/robots.ts +4 -0
  312. package/site/sitemap.ts +4 -0
  313. package/site/work.ts +4 -0
  314. package/studio/index.ts +4 -0
  315. package/styles/admin.css +85 -0
  316. package/styles/animations.css +237 -0
  317. package/styles/base.css +148 -0
  318. package/styles/globals.css +10 -0
  319. package/tsconfig.json +25 -0
@@ -0,0 +1,472 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback, useRef, useEffect } from "react";
4
+ import { useBuilderStore } from "../../../lib/builder/store";
5
+ import type { PageSectionV2 } from "../../../lib/sanity/types";
6
+ import { isPageSectionV2 } from "../../../lib/sanity/types";
7
+ import { getEffectiveColumnsV2, buildColumnV2Overrides } from "../settings-panel/responsive-helpers";
8
+ import { moveColumn as cascadeMoveColumn } from "../../../lib/builder/cascade";
9
+ import type { DeviceViewport } from "../../../lib/builder/types";
10
+
11
+ // ============================================
12
+ // Types
13
+ // ============================================
14
+
15
+ export interface DropTarget {
16
+ type: "swap" | "gap" | "insert";
17
+ sectionKey: string;
18
+ /** swap: the column to swap with */
19
+ columnKey?: string;
20
+ /** gap: position and span of the empty gap */
21
+ gapRow?: number;
22
+ gapCol?: number;
23
+ gapSpan?: number;
24
+ /** insert: grid position where the column will be inserted */
25
+ insertRow?: number;
26
+ insertCol?: number;
27
+ }
28
+
29
+ export interface InsertBetween {
30
+ leftKey: string;
31
+ rightKey: string;
32
+ }
33
+
34
+ export interface UseColumnDragReturn {
35
+ isDragging: boolean;
36
+ draggedColumnKey: string | null;
37
+ draggedSectionKey: string | null;
38
+ dropTarget: DropTarget | null;
39
+ insertBetween: InsertBetween | null;
40
+ overlayPosition: { x: number; y: number } | null;
41
+ startDrag: (e: React.MouseEvent, sectionKey: string, columnKey: string) => void;
42
+ }
43
+
44
+ // ============================================
45
+ // Responsive Helper Functions (private)
46
+ // ============================================
47
+
48
+ /**
49
+ * Compute swap on effective columns and write as responsive overrides.
50
+ * Follows the same pattern as doMoveColumnV2 in DndWrapper.tsx.
51
+ */
52
+ function executeResponsiveSwap(
53
+ sectionKey: string,
54
+ draggedKey: string,
55
+ targetKey: string,
56
+ viewport: DeviceViewport,
57
+ updateSectionV2Responsive: (sectionKey: string, responsive: PageSectionV2["responsive"]) => void
58
+ ): void {
59
+ const rows = useBuilderStore.getState().rows;
60
+ const section = rows.find((r) => r._key === sectionKey);
61
+ if (!section || !isPageSectionV2(section)) return;
62
+ const v2Section = section as PageSectionV2;
63
+
64
+ const effectiveCols = getEffectiveColumnsV2(v2Section, viewport);
65
+ const draggedCol = effectiveCols.find((c) => c._key === draggedKey);
66
+ const targetCol = effectiveCols.find((c) => c._key === targetKey);
67
+ if (!draggedCol || !targetCol) return;
68
+
69
+ // Swap positions in effective columns
70
+ const columnOverrides = effectiveCols.map((c) => {
71
+ if (c._key === draggedKey) {
72
+ return {
73
+ _key: c._key,
74
+ grid_column: targetCol.grid_column,
75
+ grid_row: targetCol.grid_row,
76
+ span: targetCol.span,
77
+ };
78
+ }
79
+ if (c._key === targetKey) {
80
+ return {
81
+ _key: c._key,
82
+ grid_column: draggedCol.grid_column,
83
+ grid_row: draggedCol.grid_row,
84
+ span: draggedCol.span,
85
+ };
86
+ }
87
+ return {
88
+ _key: c._key,
89
+ grid_column: c.grid_column,
90
+ grid_row: c.grid_row,
91
+ span: c.span,
92
+ };
93
+ });
94
+
95
+ const responsive = buildColumnV2Overrides(v2Section, viewport, columnOverrides);
96
+ updateSectionV2Responsive(sectionKey, responsive);
97
+ }
98
+
99
+ /**
100
+ * Compute gap move on effective columns and write as responsive overrides.
101
+ */
102
+ function executeResponsiveGapMove(
103
+ sectionKey: string,
104
+ columnKey: string,
105
+ targetRow: number,
106
+ targetCol: number,
107
+ targetSpan: number,
108
+ viewport: DeviceViewport,
109
+ updateSectionV2Responsive: (sectionKey: string, responsive: PageSectionV2["responsive"]) => void
110
+ ): void {
111
+ const rows = useBuilderStore.getState().rows;
112
+ const section = rows.find((r) => r._key === sectionKey);
113
+ if (!section || !isPageSectionV2(section)) return;
114
+ const v2Section = section as PageSectionV2;
115
+
116
+ const effectiveCols = getEffectiveColumnsV2(v2Section, viewport);
117
+ const columnOverrides = effectiveCols.map((c) => {
118
+ if (c._key === columnKey) {
119
+ return {
120
+ _key: c._key,
121
+ grid_column: targetCol,
122
+ grid_row: targetRow,
123
+ span: targetSpan,
124
+ };
125
+ }
126
+ return {
127
+ _key: c._key,
128
+ grid_column: c.grid_column,
129
+ grid_row: c.grid_row,
130
+ span: c.span,
131
+ };
132
+ });
133
+
134
+ const responsive = buildColumnV2Overrides(v2Section, viewport, columnOverrides);
135
+ updateSectionV2Responsive(sectionKey, responsive);
136
+ }
137
+
138
+ /**
139
+ * Compute insert via cascadeMoveColumn on effective columns and write as overrides.
140
+ */
141
+ function executeResponsiveInsert(
142
+ sectionKey: string,
143
+ columnKey: string,
144
+ targetRow: number,
145
+ targetCol: number,
146
+ viewport: DeviceViewport,
147
+ updateSectionV2Responsive: (sectionKey: string, responsive: PageSectionV2["responsive"]) => void
148
+ ): void {
149
+ const rows = useBuilderStore.getState().rows;
150
+ const section = rows.find((r) => r._key === sectionKey);
151
+ if (!section || !isPageSectionV2(section)) return;
152
+ const v2Section = section as PageSectionV2;
153
+
154
+ const effectiveCols = getEffectiveColumnsV2(v2Section, viewport);
155
+ const cascadeResult = cascadeMoveColumn(effectiveCols, columnKey, targetRow, targetCol, v2Section.settings.grid_columns);
156
+
157
+ const columnOverrides = cascadeResult.map((c) => ({
158
+ _key: c._key,
159
+ grid_column: c.grid_column,
160
+ grid_row: c.grid_row,
161
+ span: c.span,
162
+ }));
163
+
164
+ const responsive = buildColumnV2Overrides(v2Section, viewport, columnOverrides);
165
+ updateSectionV2Responsive(sectionKey, responsive);
166
+ }
167
+
168
+ // ============================================
169
+ // useColumnDrag Hook
170
+ // ============================================
171
+
172
+ export function useColumnDrag(): UseColumnDragReturn {
173
+ const [isDragging, setIsDragging] = useState(false);
174
+ const [draggedColumnKey, setDraggedColumnKey] = useState<string | null>(null);
175
+ const [draggedSectionKey, setDraggedSectionKey] = useState<string | null>(null);
176
+ const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
177
+ const [insertBetween, setInsertBetween] = useState<InsertBetween | null>(null);
178
+ const [overlayPosition, setOverlayPosition] = useState<{ x: number; y: number } | null>(null);
179
+
180
+ // --- Mutable ref for drag state + store actions (stale closure prevention) ---
181
+ const dragRef = useRef({
182
+ sectionKey: "",
183
+ columnKey: "",
184
+ active: false,
185
+ pending: false, // mousedown captured, waiting for 8px threshold
186
+ startX: 0,
187
+ startY: 0,
188
+ draggedEl: null as HTMLElement | null, // pointer-events guard
189
+ // Store actions — updated every render via useEffect
190
+ swapColumnV2: null as ((s: string, d: string, t: string) => void) | null,
191
+ moveColumnToGapV2: null as ((s: string, c: string, r: number, col: number, sp: number) => void) | null,
192
+ moveColumnV2: null as ((s: string, c: string, r: number, col: number) => void) | null,
193
+ updateSectionV2Responsive: null as ((s: string, r: PageSectionV2["responsive"]) => void) | null,
194
+ });
195
+
196
+ // Keep store actions fresh in the ref
197
+ const swapColumnV2 = useBuilderStore((s) => s.swapColumnV2);
198
+ const moveColumnToGapV2 = useBuilderStore((s) => s.moveColumnToGapV2);
199
+ const moveColumnV2 = useBuilderStore((s) => s.moveColumnV2);
200
+ const updateSectionV2Responsive = useBuilderStore((s) => s.updateSectionV2Responsive);
201
+
202
+ // Update ref every render — no deps array
203
+ useEffect(() => {
204
+ dragRef.current.swapColumnV2 = swapColumnV2;
205
+ dragRef.current.moveColumnToGapV2 = moveColumnToGapV2;
206
+ dragRef.current.moveColumnV2 = moveColumnV2;
207
+ dragRef.current.updateSectionV2Responsive = updateSectionV2Responsive;
208
+ });
209
+
210
+ // --- Stable mousemove handler (empty deps — delegates to dragRef) ---
211
+ const stableMouseMove = useCallback((e: MouseEvent) => {
212
+ // Activation distance: 8px dead zone before drag starts
213
+ if (dragRef.current.pending && !dragRef.current.active) {
214
+ const dist = Math.hypot(
215
+ e.clientX - dragRef.current.startX,
216
+ e.clientY - dragRef.current.startY
217
+ );
218
+ if (dist < 8) return; // Still within dead zone
219
+
220
+ // Threshold met — activate drag
221
+ dragRef.current.pending = false;
222
+ dragRef.current.active = true;
223
+
224
+ const { sectionKey, columnKey } = dragRef.current;
225
+ setDraggedColumnKey(columnKey);
226
+ setDraggedSectionKey(sectionKey);
227
+ setOverlayPosition({ x: e.clientX, y: e.clientY });
228
+ setIsDragging(true);
229
+
230
+ document.body.style.cursor = "grabbing";
231
+ document.body.style.userSelect = "none";
232
+
233
+ // Pointer-events guard: prevent dragged column from capturing elementsFromPoint
234
+ const draggedEl = document.querySelector(
235
+ `[data-col-v2-droptarget][data-section-key="${sectionKey}"][data-column-key="${columnKey}"]`
236
+ ) as HTMLElement | null;
237
+ if (draggedEl) {
238
+ dragRef.current.draggedEl = draggedEl;
239
+ draggedEl.style.pointerEvents = "none";
240
+ }
241
+ }
242
+
243
+ if (!dragRef.current.active) return;
244
+
245
+ // 1. Update overlay position (viewport coordinates — no zoom compensation)
246
+ setOverlayPosition({ x: e.clientX, y: e.clientY });
247
+
248
+ const { sectionKey, columnKey } = dragRef.current;
249
+
250
+ // 2. Hit-test via elementsFromPoint
251
+ const elements = document.elementsFromPoint(e.clientX, e.clientY);
252
+ let newTarget: DropTarget | null = null;
253
+ let newInsert: InsertBetween | null = null;
254
+
255
+ for (const el of elements) {
256
+ const htmlEl = el as HTMLElement;
257
+
258
+ // Priority 1: Insert zone
259
+ if (htmlEl.dataset.colV2Insert !== undefined) {
260
+ const targetSection = htmlEl.dataset.sectionKey;
261
+ if (targetSection !== sectionKey) continue;
262
+ newTarget = {
263
+ type: "insert",
264
+ sectionKey: targetSection,
265
+ insertRow: parseInt(htmlEl.dataset.insertRow!, 10),
266
+ insertCol: parseInt(htmlEl.dataset.insertCol!, 10),
267
+ };
268
+ const leftKey = htmlEl.dataset.insertLeftKey;
269
+ const rightKey = htmlEl.dataset.insertRightKey;
270
+ if (leftKey && rightKey) {
271
+ newInsert = { leftKey, rightKey };
272
+ }
273
+ break;
274
+ }
275
+
276
+ // Priority 2: Gap
277
+ if (htmlEl.dataset.colV2Gap !== undefined) {
278
+ const targetSection = htmlEl.dataset.sectionKey;
279
+ if (targetSection !== sectionKey) continue;
280
+ newTarget = {
281
+ type: "gap",
282
+ sectionKey: targetSection,
283
+ gapRow: parseInt(htmlEl.dataset.gapRow!, 10),
284
+ gapCol: parseInt(htmlEl.dataset.gapCol!, 10),
285
+ gapSpan: parseInt(htmlEl.dataset.gapSpan!, 10),
286
+ };
287
+ break;
288
+ }
289
+
290
+ // Priority 3: Column (swap)
291
+ if (htmlEl.dataset.colV2Droptarget !== undefined) {
292
+ const targetSection = htmlEl.dataset.sectionKey;
293
+ const targetColumn = htmlEl.dataset.columnKey;
294
+ if (targetSection !== sectionKey) continue;
295
+ if (targetColumn === columnKey) continue; // skip self
296
+ newTarget = {
297
+ type: "swap",
298
+ sectionKey: targetSection,
299
+ columnKey: targetColumn,
300
+ };
301
+ break;
302
+ }
303
+ }
304
+
305
+ setDropTarget(newTarget);
306
+ setInsertBetween(newTarget?.type === "insert" ? newInsert : null);
307
+ }, []);
308
+
309
+ // --- Stable mouseup handler ---
310
+ const stableMouseUp = useCallback((e: MouseEvent) => {
311
+ document.removeEventListener("mousemove", stableMouseMove);
312
+ document.removeEventListener("mouseup", stableMouseUp);
313
+
314
+ // Restore pointer-events on dragged element
315
+ if (dragRef.current.draggedEl) {
316
+ dragRef.current.draggedEl.style.pointerEvents = "";
317
+ dragRef.current.draggedEl = null;
318
+ }
319
+
320
+ // If mouseup before activation threshold — clean up without executing any drop
321
+ if (dragRef.current.pending && !dragRef.current.active) {
322
+ dragRef.current.pending = false;
323
+ return;
324
+ }
325
+
326
+ document.body.style.cursor = "";
327
+ document.body.style.userSelect = "";
328
+
329
+ const { sectionKey, columnKey } = dragRef.current;
330
+ dragRef.current.active = false;
331
+
332
+ // Final hit-test at mouseup position
333
+ const elements = document.elementsFromPoint(e.clientX, e.clientY);
334
+ let finalTarget: DropTarget | null = null;
335
+
336
+ for (const el of elements) {
337
+ const htmlEl = el as HTMLElement;
338
+
339
+ // Priority 1: Insert zone
340
+ if (htmlEl.dataset.colV2Insert !== undefined) {
341
+ const ts = htmlEl.dataset.sectionKey;
342
+ if (ts !== sectionKey) continue;
343
+ finalTarget = {
344
+ type: "insert",
345
+ sectionKey: ts,
346
+ insertRow: parseInt(htmlEl.dataset.insertRow!, 10),
347
+ insertCol: parseInt(htmlEl.dataset.insertCol!, 10),
348
+ };
349
+ break;
350
+ }
351
+
352
+ // Priority 2: Gap
353
+ if (htmlEl.dataset.colV2Gap !== undefined) {
354
+ const ts = htmlEl.dataset.sectionKey;
355
+ if (ts !== sectionKey) continue;
356
+ finalTarget = {
357
+ type: "gap",
358
+ sectionKey: ts,
359
+ gapRow: parseInt(htmlEl.dataset.gapRow!, 10),
360
+ gapCol: parseInt(htmlEl.dataset.gapCol!, 10),
361
+ gapSpan: parseInt(htmlEl.dataset.gapSpan!, 10),
362
+ };
363
+ break;
364
+ }
365
+
366
+ // Priority 3: Column (swap)
367
+ if (htmlEl.dataset.colV2Droptarget !== undefined) {
368
+ const ts = htmlEl.dataset.sectionKey;
369
+ const tc = htmlEl.dataset.columnKey;
370
+ if (ts !== sectionKey || tc === columnKey) continue;
371
+ finalTarget = { type: "swap", sectionKey: ts, columnKey: tc };
372
+ break;
373
+ }
374
+ }
375
+
376
+ // Execute the drop action
377
+ if (finalTarget) {
378
+ const activeViewport = useBuilderStore.getState().activeViewport;
379
+ const isResponsive = activeViewport !== "desktop";
380
+
381
+ if (finalTarget.type === "swap" && finalTarget.columnKey) {
382
+ if (!isResponsive) {
383
+ dragRef.current.swapColumnV2?.(sectionKey, columnKey, finalTarget.columnKey);
384
+ } else {
385
+ executeResponsiveSwap(
386
+ sectionKey, columnKey, finalTarget.columnKey, activeViewport,
387
+ dragRef.current.updateSectionV2Responsive!
388
+ );
389
+ }
390
+ } else if (finalTarget.type === "gap") {
391
+ if (!isResponsive) {
392
+ dragRef.current.moveColumnToGapV2?.(
393
+ sectionKey, columnKey,
394
+ finalTarget.gapRow!, finalTarget.gapCol!, finalTarget.gapSpan!
395
+ );
396
+ } else {
397
+ executeResponsiveGapMove(
398
+ sectionKey, columnKey,
399
+ finalTarget.gapRow!, finalTarget.gapCol!, finalTarget.gapSpan!,
400
+ activeViewport,
401
+ dragRef.current.updateSectionV2Responsive!
402
+ );
403
+ }
404
+ } else if (finalTarget.type === "insert") {
405
+ if (!isResponsive) {
406
+ dragRef.current.moveColumnV2?.(
407
+ sectionKey, columnKey,
408
+ finalTarget.insertRow!, finalTarget.insertCol!
409
+ );
410
+ } else {
411
+ executeResponsiveInsert(
412
+ sectionKey, columnKey,
413
+ finalTarget.insertRow!, finalTarget.insertCol!,
414
+ activeViewport,
415
+ dragRef.current.updateSectionV2Responsive!
416
+ );
417
+ }
418
+ }
419
+ }
420
+
421
+ // Reset all React state
422
+ setIsDragging(false);
423
+ setDraggedColumnKey(null);
424
+ setDraggedSectionKey(null);
425
+ setDropTarget(null);
426
+ setInsertBetween(null);
427
+ setOverlayPosition(null);
428
+ }, [stableMouseMove]);
429
+
430
+ // --- startDrag ---
431
+ const startDrag = useCallback((e: React.MouseEvent, sectionKey: string, columnKey: string) => {
432
+ e.preventDefault();
433
+ e.stopPropagation();
434
+
435
+ dragRef.current.sectionKey = sectionKey;
436
+ dragRef.current.columnKey = columnKey;
437
+ dragRef.current.active = false;
438
+ dragRef.current.pending = true;
439
+ dragRef.current.startX = e.clientX;
440
+ dragRef.current.startY = e.clientY;
441
+
442
+ // Don't set React state yet — wait for 8px activation threshold in mousemove
443
+
444
+ document.addEventListener("mousemove", stableMouseMove);
445
+ document.addEventListener("mouseup", stableMouseUp);
446
+ }, [stableMouseMove, stableMouseUp]);
447
+
448
+ // Cleanup on unmount (safety — if component unmounts mid-drag)
449
+ useEffect(() => {
450
+ return () => {
451
+ document.removeEventListener("mousemove", stableMouseMove);
452
+ document.removeEventListener("mouseup", stableMouseUp);
453
+ document.body.style.cursor = "";
454
+ document.body.style.userSelect = "";
455
+ if (dragRef.current.draggedEl) {
456
+ // eslint-disable-next-line react-hooks/exhaustive-deps
457
+ dragRef.current.draggedEl.style.pointerEvents = "";
458
+ dragRef.current.draggedEl = null;
459
+ }
460
+ };
461
+ }, [stableMouseMove, stableMouseUp]);
462
+
463
+ return {
464
+ isDragging,
465
+ draggedColumnKey,
466
+ draggedSectionKey,
467
+ dropTarget,
468
+ insertBetween,
469
+ overlayPosition,
470
+ startDrag,
471
+ };
472
+ }
@@ -0,0 +1,221 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback, useRef, useEffect } from "react";
4
+ import {
5
+ resizeColumn as cascadeResizeColumn,
6
+ resizeColumnLeft as cascadeResizeLeft,
7
+ type ResizeLeftResult,
8
+ } from "../../../lib/builder/cascade";
9
+ import { toCascadeColumns } from "../../../lib/builder/cascade-helpers";
10
+ import type { CascadeColumn } from "../../../lib/builder/cascade-helpers";
11
+ import type { PageSectionV2 } from "../../../lib/sanity/types";
12
+ import type { DeviceViewport } from "../../../lib/builder/types";
13
+ import { buildColumnV2Overrides } from "../settings-panel/responsive-helpers";
14
+
15
+ // ============================================
16
+ // useColumnResize — Extracted from SectionV2Canvas
17
+ //
18
+ // Handles both left and right edge resizing for V2 grid columns.
19
+ // Uses a shared startResize() that parameterizes by direction,
20
+ // eliminating the two near-identical useCallback closures.
21
+ // ============================================
22
+
23
+ interface UseColumnResizeParams {
24
+ section: PageSectionV2;
25
+ gridColumns: number;
26
+ colGap: number;
27
+ canvasZoom: number;
28
+ effectiveCols: CascadeColumn[];
29
+ isResponsive: boolean;
30
+ activeViewport: DeviceViewport;
31
+ // Store actions
32
+ resizeColumnV2: (sectionKey: string, columnKey: string, newSpan: number) => void;
33
+ resizeColumnV2Left: (sectionKey: string, columnKey: string, newGridColumn: number) => void;
34
+ updateSectionV2Responsive: (sectionKey: string, responsive: Record<string, Record<string, unknown>>) => void;
35
+ }
36
+
37
+ interface SnappingInfo {
38
+ columnKey: string;
39
+ edge: "left" | "right";
40
+ willOverflow: boolean;
41
+ /** Key of the left neighbor being compressed during left-edge resize */
42
+ compressedNeighborKey?: string | null;
43
+ /** True when the left neighbor has reached minimum span (1) — visual warning */
44
+ neighborAtMinimum?: boolean;
45
+ }
46
+
47
+ interface UseColumnResizeReturn {
48
+ resizePreview: CascadeColumn[] | null;
49
+ snappingInfo: SnappingInfo | null;
50
+ handleResizeRight: (columnKey: string, startX: number, startSpan: number, containerEl: HTMLElement) => void;
51
+ handleResizeLeft: (columnKey: string, startX: number, startGridCol: number, startSpan: number, containerEl: HTMLElement) => void;
52
+ }
53
+
54
+ /** Compute pixel width of one grid column unit */
55
+ function computeColUnitWidth(containerWidth: number, gridColumns: number, colGap: number): number {
56
+ const totalGaps = (gridColumns - 1) * colGap;
57
+ return (containerWidth - totalGaps) / gridColumns;
58
+ }
59
+
60
+ export function useColumnResize({
61
+ section,
62
+ gridColumns,
63
+ colGap,
64
+ canvasZoom,
65
+ effectiveCols,
66
+ isResponsive,
67
+ activeViewport,
68
+ resizeColumnV2,
69
+ resizeColumnV2Left,
70
+ updateSectionV2Responsive,
71
+ }: UseColumnResizeParams): UseColumnResizeReturn {
72
+ const [resizePreview, setResizePreview] = useState<CascadeColumn[] | null>(null);
73
+ const [snappingInfo, setSnappingInfo] = useState<SnappingInfo | null>(null);
74
+
75
+ // Ref to access the latest preview in mouseup handlers (closure escape)
76
+ const latestPreviewRef = useRef<CascadeColumn[] | null>(null);
77
+ useEffect(() => {
78
+ latestPreviewRef.current = resizePreview;
79
+ }, [resizePreview]);
80
+
81
+ /** Commit the resize result to the store (shared by both edges) */
82
+ const commitResize = useCallback(
83
+ (columnKey: string, startGridCol: number, startSpan: number, edge: "left" | "right") => {
84
+ const preview = latestPreviewRef.current;
85
+ if (!preview) return;
86
+
87
+ const finalCol = preview.find((c) => c._key === columnKey);
88
+ if (!finalCol) return;
89
+
90
+ // Check if anything actually changed
91
+ const changed =
92
+ edge === "right"
93
+ ? finalCol.span !== startSpan
94
+ : finalCol.grid_column !== startGridCol || finalCol.span !== startSpan;
95
+
96
+ if (!changed) return;
97
+
98
+ if (isResponsive) {
99
+ // Tablet/Phone: write ALL cascade-affected columns as responsive overrides
100
+ const columnOverrides = preview.map((c) => ({
101
+ _key: c._key,
102
+ grid_column: c.grid_column,
103
+ grid_row: c.grid_row,
104
+ span: c.span,
105
+ }));
106
+ const responsive = buildColumnV2Overrides(section, activeViewport, columnOverrides);
107
+ if (responsive) {
108
+ updateSectionV2Responsive(section._key, responsive as Record<string, Record<string, unknown>>);
109
+ }
110
+ } else if (edge === "right") {
111
+ resizeColumnV2(section._key, columnKey, finalCol.span);
112
+ } else {
113
+ resizeColumnV2Left(section._key, columnKey, finalCol.grid_column);
114
+ }
115
+ },
116
+ [isResponsive, section, activeViewport, resizeColumnV2, resizeColumnV2Left, updateSectionV2Responsive]
117
+ );
118
+
119
+ /** Shared cleanup for mouseup */
120
+ const cleanupResize = useCallback(() => {
121
+ document.body.style.cursor = "";
122
+ document.body.style.userSelect = "";
123
+ setResizePreview(null);
124
+ setSnappingInfo(null);
125
+ }, []);
126
+
127
+ // ---- Resize right handler (drag on right edge) ----
128
+ const handleResizeRight = useCallback(
129
+ (columnKey: string, startX: number, startSpan: number, containerEl: HTMLElement) => {
130
+ const cWidth = containerEl.getBoundingClientRect().width;
131
+ const colUnitWidth = computeColUnitWidth(cWidth, gridColumns, colGap);
132
+ const baseCols = isResponsive ? effectiveCols : toCascadeColumns(section.columns);
133
+ let lastPreviewedSpan = startSpan;
134
+
135
+ const handleMouseMove = (e: MouseEvent) => {
136
+ const deltaX = (e.clientX - startX) / canvasZoom;
137
+ const deltaCols = Math.round(deltaX / (colUnitWidth + colGap));
138
+ const newSpan = Math.max(1, Math.min(gridColumns, startSpan + deltaCols));
139
+
140
+ if (newSpan === lastPreviewedSpan) return;
141
+ lastPreviewedSpan = newSpan;
142
+
143
+ const previewCascade = cascadeResizeColumn(baseCols, columnKey, newSpan, gridColumns);
144
+ setResizePreview(previewCascade);
145
+
146
+ const originalMaxRow = baseCols.length > 0 ? Math.max(...baseCols.map((c) => c.grid_row)) : 1;
147
+ const newMaxRow = previewCascade.length > 0 ? Math.max(...previewCascade.map((c) => c.grid_row)) : 1;
148
+ const willOverflow = newMaxRow > originalMaxRow;
149
+ setSnappingInfo(willOverflow ? { columnKey, edge: "right", willOverflow: true } : null);
150
+ };
151
+
152
+ const handleMouseUp = () => {
153
+ document.removeEventListener("mousemove", handleMouseMove);
154
+ document.removeEventListener("mouseup", handleMouseUp);
155
+ commitResize(columnKey, 0, startSpan, "right");
156
+ cleanupResize();
157
+ };
158
+
159
+ document.body.style.cursor = "col-resize";
160
+ document.body.style.userSelect = "none";
161
+ document.addEventListener("mousemove", handleMouseMove);
162
+ document.addEventListener("mouseup", handleMouseUp);
163
+ },
164
+ [gridColumns, colGap, canvasZoom, section, isResponsive, effectiveCols, commitResize, cleanupResize]
165
+ );
166
+
167
+ // ---- Resize left handler (drag on left edge) ----
168
+ const handleResizeLeft = useCallback(
169
+ (columnKey: string, startX: number, startGridCol: number, startSpan: number, containerEl: HTMLElement) => {
170
+ const cWidth = containerEl.getBoundingClientRect().width;
171
+ const colUnitWidth = computeColUnitWidth(cWidth, gridColumns, colGap);
172
+ const baseCols = isResponsive ? effectiveCols : toCascadeColumns(section.columns);
173
+ let lastPreviewedGridCol = startGridCol;
174
+
175
+ const handleMouseMove = (e: MouseEvent) => {
176
+ const deltaX = (e.clientX - startX) / canvasZoom;
177
+ const deltaCols = Math.round(deltaX / (colUnitWidth + colGap));
178
+ const newGridColumn = Math.max(1, startGridCol + deltaCols);
179
+ if (newGridColumn >= startGridCol + startSpan) return;
180
+
181
+ if (newGridColumn === lastPreviewedGridCol) return;
182
+ lastPreviewedGridCol = newGridColumn;
183
+
184
+ const previewResult = cascadeResizeLeft(baseCols, columnKey, newGridColumn, gridColumns);
185
+ if (previewResult) {
186
+ setResizePreview(previewResult.columns);
187
+ // Surface neighbor compression state for visual feedback
188
+ if (previewResult.compressedNeighborKey) {
189
+ setSnappingInfo({
190
+ columnKey,
191
+ edge: "left",
192
+ willOverflow: false,
193
+ compressedNeighborKey: previewResult.compressedNeighborKey,
194
+ neighborAtMinimum: previewResult.neighborAtMinimum,
195
+ });
196
+ } else {
197
+ setSnappingInfo(null);
198
+ }
199
+ } else {
200
+ // Completely blocked (should be rare now — only if column not found)
201
+ setSnappingInfo({ columnKey, edge: "left", willOverflow: false });
202
+ }
203
+ };
204
+
205
+ const handleMouseUp = () => {
206
+ document.removeEventListener("mousemove", handleMouseMove);
207
+ document.removeEventListener("mouseup", handleMouseUp);
208
+ commitResize(columnKey, startGridCol, startSpan, "left");
209
+ cleanupResize();
210
+ };
211
+
212
+ document.body.style.cursor = "col-resize";
213
+ document.body.style.userSelect = "none";
214
+ document.addEventListener("mousemove", handleMouseMove);
215
+ document.addEventListener("mouseup", handleMouseUp);
216
+ },
217
+ [gridColumns, colGap, canvasZoom, section, isResponsive, effectiveCols, commitResize, cleanupResize]
218
+ );
219
+
220
+ return { resizePreview, snappingInfo, handleResizeRight, handleResizeLeft };
221
+ }