@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,354 @@
1
+ "use client";
2
+
3
+ import {
4
+ useRef,
5
+ useEffect,
6
+ useCallback,
7
+ useState,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { useBuilderStore } from "../../lib/builder/store";
11
+ import { DEVICE_WIDTHS } from "../../lib/builder/types";
12
+ import type { DeviceViewport } from "../../lib/builder/types";
13
+ import CanvasToolbar from "./CanvasToolbar";
14
+ import DeviceFrame from "./DeviceFrame";
15
+ import ReadOnlyFrame from "./ReadOnlyFrame";
16
+ import CanvasMinimap from "./CanvasMinimap";
17
+ import { getSiteConfig } from "../../lib/config";
18
+
19
+ // ============================================
20
+ // BuilderCanvas — Infinite canvas with zoom/pan
21
+ // Phase 3: Polish — smooth animations, minimap,
22
+ // double-click zoom, cursor changes, sessionStorage
23
+ // ============================================
24
+
25
+ /** Gap between device frames in canvas pixels */
26
+ const FRAME_GAP = 80;
27
+
28
+ /** Device order for horizontal layout */
29
+ const DEVICE_ORDER: DeviceViewport[] = ["desktop", "tablet", "phone"];
30
+
31
+ /** Total canvas content width including gaps */
32
+ const TOTAL_CONTENT_WIDTH =
33
+ DEVICE_WIDTHS.desktop + FRAME_GAP + DEVICE_WIDTHS.tablet + FRAME_GAP + DEVICE_WIDTHS.phone;
34
+
35
+ /** sessionStorage key prefix for persisting canvas state per page */
36
+ const CANVAS_STORAGE_PREFIX = `${getSiteConfig().storagePrefix}_canvas_`;
37
+
38
+ interface BuilderCanvasProps {
39
+ children: ReactNode;
40
+ }
41
+
42
+ export default function BuilderCanvas({ children }: BuilderCanvasProps) {
43
+ const viewportRef = useRef<HTMLDivElement>(null);
44
+ const [isPanning, setIsPanning] = useState(false);
45
+ const [isSpaceHeld, setIsSpaceHeld] = useState(false);
46
+ const panStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
47
+ /** rAF handle for throttled wheel events — prevents jank on rapid scroll */
48
+ const wheelRafRef = useRef<number | null>(null);
49
+ /** Accumulated wheel deltas between animation frames */
50
+ const wheelAccumRef = useRef<{ deltaX: number; deltaY: number; cursorX: number; cursorY: number; isZoom: boolean } | null>(null);
51
+
52
+ // Whether to animate the next transform change (for smooth zoom on toolbar clicks, double-click, etc.)
53
+ const [isAnimating, setIsAnimating] = useState(false);
54
+ const animTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
55
+
56
+ const zoom = useBuilderStore((s) => s.canvasZoom);
57
+ const panX = useBuilderStore((s) => s.canvasPanX);
58
+ const panY = useBuilderStore((s) => s.canvasPanY);
59
+ const tool = useBuilderStore((s) => s.canvasTool);
60
+ const activeViewport = useBuilderStore((s) => s.activeViewport);
61
+ const setCanvasPan = useBuilderStore((s) => s.setCanvasPan);
62
+ const zoomToPoint = useBuilderStore((s) => s.zoomToPoint);
63
+ const zoomToFit = useBuilderStore((s) => s.zoomToFit);
64
+ const zoomToFrame = useBuilderStore((s) => s.zoomToFrame);
65
+ const setActiveViewport = useBuilderStore((s) => s.setActiveViewport);
66
+ const bgColor = useBuilderStore((s) => s.pageSettings.background_color);
67
+ const pageSlug = useBuilderStore((s) => s.pageSlug);
68
+
69
+ // Refs for stable wheel/pan handlers — avoids re-creating callbacks on every zoom/pan change
70
+ const zoomRef = useRef(zoom);
71
+ const panXRef = useRef(panX);
72
+ const panYRef = useRef(panY);
73
+ useEffect(() => { zoomRef.current = zoom; }, [zoom]);
74
+ useEffect(() => { panXRef.current = panX; }, [panX]);
75
+ useEffect(() => { panYRef.current = panY; }, [panY]);
76
+
77
+ // ---- Trigger smooth animation for programmatic zoom/pan ----
78
+ const triggerAnimation = useCallback(() => {
79
+ setIsAnimating(true);
80
+ if (animTimeoutRef.current) clearTimeout(animTimeoutRef.current);
81
+ animTimeoutRef.current = setTimeout(() => setIsAnimating(false), 300);
82
+ }, []);
83
+
84
+ // ---- Persist to sessionStorage on changes ----
85
+ // BUG-020 fix: Include activeViewport in the storage key so different viewport
86
+ // tabs for the same page don't overwrite each other's canvas state.
87
+ useEffect(() => {
88
+ if (!pageSlug) return;
89
+ const key = `${CANVAS_STORAGE_PREFIX}${pageSlug}_${activeViewport}`;
90
+ try {
91
+ sessionStorage.setItem(key, JSON.stringify({ zoom, panX, panY }));
92
+ } catch {
93
+ // sessionStorage full or unavailable — ignore
94
+ }
95
+ }, [zoom, panX, panY, activeViewport, pageSlug]);
96
+
97
+ // ---- Zoom-to-fit on initial mount and editor mode changes ----
98
+ // Always start with zoom-to-fit when entering a page or switching editor
99
+ // modes (e.g. entering custom section editor) so the user sees the full
100
+ // layout overview. sessionStorage still persists the canvas state for
101
+ // mid-session viewport switches (the save effect above), but on fresh page
102
+ // entry / mode change we always reset to fit.
103
+ const editorMode = useBuilderStore((s) => s.editorMode);
104
+ const [hasInitialized, setHasInitialized] = useState(false);
105
+
106
+ // Reset initialization flag when editor mode changes (page ↔ customSection)
107
+ // so that zoom-to-fit triggers again
108
+ const prevEditorMode = useRef(editorMode);
109
+ useEffect(() => {
110
+ if (prevEditorMode.current !== editorMode) {
111
+ prevEditorMode.current = editorMode;
112
+ setHasInitialized(false);
113
+ }
114
+ }, [editorMode]);
115
+
116
+ useEffect(() => {
117
+ if (hasInitialized) return;
118
+ const el = viewportRef.current;
119
+ if (!el) return;
120
+ const rect = el.getBoundingClientRect();
121
+ if (rect.width <= 0 || rect.height <= 0) return;
122
+
123
+ zoomToFit(rect.width, rect.height);
124
+ setHasInitialized(true);
125
+ }, [hasInitialized, zoomToFit]);
126
+
127
+ // ---- Wheel handler: Ctrl/Cmd+scroll = zoom, plain scroll = pan ----
128
+ // Throttled via requestAnimationFrame to prevent jank on rapid scroll.
129
+ // Deltas accumulate between frames so no input is lost.
130
+
131
+ // Stable flush — reads current values from refs, never re-created
132
+ const flushWheel = useCallback(() => {
133
+ wheelRafRef.current = null;
134
+ const accum = wheelAccumRef.current;
135
+ if (!accum) return;
136
+ wheelAccumRef.current = null;
137
+
138
+ if (accum.isZoom) {
139
+ const zoomDelta = -accum.deltaY * 0.005;
140
+ const newZoom = zoomRef.current * (1 + zoomDelta);
141
+ zoomToPoint(newZoom, accum.cursorX, accum.cursorY);
142
+ } else {
143
+ setCanvasPan(panXRef.current - accum.deltaX, panYRef.current - accum.deltaY);
144
+ }
145
+ }, [zoomToPoint, setCanvasPan]);
146
+
147
+ // Stable wheel handler — never re-subscribed during zoom/pan
148
+ const handleWheel = useCallback(
149
+ (e: WheelEvent) => {
150
+ e.preventDefault();
151
+ const el = viewportRef.current;
152
+ if (!el) return;
153
+ const rect = el.getBoundingClientRect();
154
+ const cursorX = e.clientX - rect.left;
155
+ const cursorY = e.clientY - rect.top;
156
+ const isZoom = e.ctrlKey || e.metaKey;
157
+
158
+ // Accumulate deltas (overwrite cursor pos with latest event)
159
+ const prev = wheelAccumRef.current;
160
+ if (prev && prev.isZoom === isZoom) {
161
+ prev.deltaX += e.deltaX;
162
+ prev.deltaY += e.deltaY;
163
+ prev.cursorX = cursorX;
164
+ prev.cursorY = cursorY;
165
+ } else {
166
+ wheelAccumRef.current = { deltaX: e.deltaX, deltaY: e.deltaY, cursorX, cursorY, isZoom };
167
+ }
168
+
169
+ // Schedule flush on next animation frame (coalesces rapid events)
170
+ if (wheelRafRef.current === null) {
171
+ wheelRafRef.current = requestAnimationFrame(flushWheel);
172
+ }
173
+ },
174
+ [flushWheel]
175
+ );
176
+
177
+ useEffect(() => {
178
+ const el = viewportRef.current;
179
+ if (!el) return;
180
+ el.addEventListener("wheel", handleWheel, { passive: false });
181
+ return () => {
182
+ el.removeEventListener("wheel", handleWheel);
183
+ if (wheelRafRef.current !== null) cancelAnimationFrame(wheelRafRef.current);
184
+ };
185
+ }, [handleWheel]);
186
+
187
+ // ---- Mouse pan (hand tool, middle-click, or space+drag) ----
188
+ const shouldPan = tool === "hand" || isSpaceHeld;
189
+
190
+ const handleMouseDown = useCallback(
191
+ (e: React.MouseEvent) => {
192
+ // Middle-click always pans
193
+ const isMiddle = e.button === 1;
194
+ if (isMiddle || (e.button === 0 && shouldPan)) {
195
+ e.preventDefault();
196
+ setIsPanning(true);
197
+ panStartRef.current = { x: e.clientX, y: e.clientY, panX, panY };
198
+ }
199
+ },
200
+ [shouldPan, panX, panY]
201
+ );
202
+
203
+ const handleMouseMove = useCallback(
204
+ (e: React.MouseEvent) => {
205
+ if (!isPanning || !panStartRef.current) return;
206
+ const dx = e.clientX - panStartRef.current.x;
207
+ const dy = e.clientY - panStartRef.current.y;
208
+ setCanvasPan(panStartRef.current.panX + dx, panStartRef.current.panY + dy);
209
+ },
210
+ [isPanning, setCanvasPan]
211
+ );
212
+
213
+ const handleMouseUp = useCallback(() => {
214
+ setIsPanning(false);
215
+ panStartRef.current = null;
216
+ }, []);
217
+
218
+ // ---- Double-click on frame header to zoom to 100% ----
219
+ const handleFrameDoubleClick = useCallback(
220
+ (device: DeviceViewport) => {
221
+ const el = viewportRef.current;
222
+ if (!el) return;
223
+ const rect = el.getBoundingClientRect();
224
+ triggerAnimation();
225
+ setActiveViewport(device);
226
+ zoomToFrame(device, rect.width, rect.height);
227
+ },
228
+ [zoomToFrame, setActiveViewport, triggerAnimation]
229
+ );
230
+
231
+ // ---- Space key for temporary hand tool ----
232
+ useEffect(() => {
233
+ function handleKeyDown(e: KeyboardEvent) {
234
+ // Skip when inside asset browser modal (portal on document.body)
235
+ if ((e.target as HTMLElement)?.closest?.("[data-asset-modal]")) return;
236
+ if (e.code === "Space" && !e.repeat) {
237
+ const tag = (e.target as HTMLElement)?.tagName;
238
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
239
+ e.preventDefault();
240
+ setIsSpaceHeld(true);
241
+ }
242
+ }
243
+ function handleKeyUp(e: KeyboardEvent) {
244
+ if ((e.target as HTMLElement)?.closest?.("[data-asset-modal]")) return;
245
+ if (e.code === "Space") {
246
+ setIsSpaceHeld(false);
247
+ setIsPanning(false);
248
+ panStartRef.current = null;
249
+ }
250
+ }
251
+ window.addEventListener("keydown", handleKeyDown);
252
+ window.addEventListener("keyup", handleKeyUp);
253
+ return () => {
254
+ window.removeEventListener("keydown", handleKeyDown);
255
+ window.removeEventListener("keyup", handleKeyUp);
256
+ };
257
+ }, []);
258
+
259
+ // ---- Cursor style ----
260
+ const getCursor = () => {
261
+ if (isPanning) return "grabbing";
262
+ if (shouldPan) return "grab";
263
+ return "default";
264
+ };
265
+
266
+ // Dot grid sizing
267
+ const dotSpacing = 24 * zoom;
268
+
269
+ return (
270
+ <div
271
+ ref={viewportRef}
272
+ className="relative flex-1 overflow-hidden"
273
+ style={{
274
+ cursor: getCursor(),
275
+ backgroundColor: "#f0f0f0",
276
+ }}
277
+ onMouseDown={handleMouseDown}
278
+ onMouseMove={handleMouseMove}
279
+ onMouseUp={handleMouseUp}
280
+ onMouseLeave={handleMouseUp}
281
+ >
282
+ {/* Dot grid background */}
283
+ <div
284
+ className="absolute inset-0 pointer-events-none"
285
+ style={{
286
+ backgroundImage: `radial-gradient(circle, rgba(0,0,0,0.12) 1px, transparent 1px)`,
287
+ backgroundSize: `${dotSpacing}px ${dotSpacing}px`,
288
+ backgroundPosition: `${panX % dotSpacing}px ${panY % dotSpacing}px`,
289
+ opacity: Math.min(1, zoom * 1.2),
290
+ }}
291
+ />
292
+
293
+ {/* Canvas layer — GPU-composited via translate3d (no layout reflow on pan/zoom) */}
294
+ <div
295
+ style={{
296
+ position: "absolute",
297
+ left: 0,
298
+ top: 0,
299
+ transform: `translate3d(${panX}px, ${panY}px, 0) scale(${zoom})`,
300
+ transformOrigin: "0 0",
301
+ willChange: "transform",
302
+ transition: isAnimating
303
+ ? "transform 0.3s cubic-bezier(0.25, 0.1, 0.25, 1)"
304
+ : "none",
305
+ }}
306
+ >
307
+ {/* 3 device frames side-by-side */}
308
+ <div className="flex items-start" style={{ gap: FRAME_GAP }}>
309
+ {DEVICE_ORDER.map((device) => {
310
+ const isActive = activeViewport === device;
311
+ return (
312
+ <DeviceFrame
313
+ key={device}
314
+ device={device}
315
+ isActive={isActive}
316
+ onActivate={() => setActiveViewport(device)}
317
+ onDoubleClick={() => handleFrameDoubleClick(device)}
318
+ backgroundColor={bgColor}
319
+ >
320
+ {isActive ? (
321
+ // Active frame: editable — receives the full editor tree
322
+ children
323
+ ) : (
324
+ // Inactive frame: read-only mirror — click anywhere to activate
325
+ <div
326
+ style={{ position: "relative", cursor: "pointer" }}
327
+ onClick={(e) => {
328
+ e.stopPropagation();
329
+ setActiveViewport(device);
330
+ }}
331
+ >
332
+ <div style={{ pointerEvents: "none" }}>
333
+ <ReadOnlyFrame viewport={device} />
334
+ </div>
335
+ </div>
336
+ )}
337
+ </DeviceFrame>
338
+ );
339
+ })}
340
+ </div>
341
+ </div>
342
+
343
+ {/* Floating canvas toolbar */}
344
+ <CanvasToolbar viewportRef={viewportRef} onAnimatedAction={triggerAnimation} />
345
+
346
+ {/* Minimap */}
347
+ <CanvasMinimap
348
+ viewportRef={viewportRef}
349
+ totalContentWidth={TOTAL_CONTENT_WIDTH}
350
+ totalContentHeight={800}
351
+ />
352
+ </div>
353
+ );
354
+ }
@@ -0,0 +1,200 @@
1
+ "use client";
2
+
3
+ import { useCallback, useMemo, useRef, useState } from "react";
4
+ import { useBuilderStore } from "../../lib/builder/store";
5
+ import { DEVICE_WIDTHS } from "../../lib/builder/types";
6
+
7
+ // ============================================
8
+ // CanvasMinimap — Shows viewport position in canvas
9
+ // Phase 3: Optional minimap overlay
10
+ // ============================================
11
+
12
+ /** Scale factor for minimap rendering */
13
+ const MINIMAP_SCALE = 0.04;
14
+ const MINIMAP_WIDTH = 160;
15
+ const MINIMAP_HEIGHT = 100;
16
+
17
+ const FRAME_GAP = 80;
18
+ const DEVICE_ORDER = ["desktop", "tablet", "phone"] as const;
19
+
20
+ interface CanvasMinimapProps {
21
+ viewportRef: React.RefObject<HTMLDivElement | null>;
22
+ totalContentWidth: number;
23
+ totalContentHeight: number;
24
+ }
25
+
26
+ export default function CanvasMinimap({
27
+ viewportRef,
28
+ totalContentWidth,
29
+ totalContentHeight,
30
+ }: CanvasMinimapProps) {
31
+ const zoom = useBuilderStore((s) => s.canvasZoom);
32
+ const panX = useBuilderStore((s) => s.canvasPanX);
33
+ const panY = useBuilderStore((s) => s.canvasPanY);
34
+ const setCanvasPan = useBuilderStore((s) => s.setCanvasPan);
35
+ const activeViewport = useBuilderStore((s) => s.activeViewport);
36
+
37
+ const [isCollapsed, setIsCollapsed] = useState(false);
38
+ const [isDragging, setIsDragging] = useState(false);
39
+ const dragStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
40
+
41
+ const el = viewportRef.current;
42
+ const vpWidth = el?.clientWidth || 800;
43
+ const vpHeight = el?.clientHeight || 600;
44
+
45
+ // Calculate the scale to fit the entire canvas content in the minimap — memoized
46
+ const { scale, contentEstimateHeight } = useMemo(() => {
47
+ const h = Math.max(totalContentHeight, 600);
48
+ const sx = MINIMAP_WIDTH / (totalContentWidth + 200);
49
+ const sy = MINIMAP_HEIGHT / (h + 200);
50
+ return { scale: Math.max(0.01, Math.min(sx, sy, 1)), contentEstimateHeight: h };
51
+ }, [totalContentWidth, totalContentHeight]);
52
+
53
+ // Viewport rectangle in minimap space
54
+ const viewX = (-panX / zoom) * scale;
55
+ const viewY = (-panY / zoom) * scale;
56
+ const viewW = (vpWidth / zoom) * scale;
57
+ const viewH = (vpHeight / zoom) * scale;
58
+
59
+ // Frame rectangles in minimap space — memoized since device widths are static
60
+ const frames = useMemo(() => {
61
+ const result: { x: number; w: number; label: string; active: boolean }[] = [];
62
+ let xPos = 0;
63
+ for (const device of DEVICE_ORDER) {
64
+ result.push({
65
+ x: xPos * scale,
66
+ w: DEVICE_WIDTHS[device] * scale,
67
+ label: device[0].toUpperCase(),
68
+ active: activeViewport === device,
69
+ });
70
+ xPos += DEVICE_WIDTHS[device] + FRAME_GAP;
71
+ }
72
+ return result;
73
+ }, [scale, activeViewport]);
74
+
75
+ // Click on minimap to pan the viewport
76
+ const handleMinimapMouseDown = useCallback(
77
+ (e: React.MouseEvent<HTMLDivElement>) => {
78
+ e.preventDefault();
79
+ e.stopPropagation();
80
+
81
+ const rect = e.currentTarget.getBoundingClientRect();
82
+ const clickX = e.clientX - rect.left;
83
+ const clickY = e.clientY - rect.top;
84
+
85
+ // Convert minimap click to canvas space, then set pan so that point is centered
86
+ const canvasX = clickX / scale;
87
+ const canvasY = clickY / scale;
88
+ const newPanX = vpWidth / 2 - canvasX * zoom;
89
+ const newPanY = vpHeight / 2 - canvasY * zoom;
90
+ setCanvasPan(newPanX, newPanY);
91
+
92
+ // Start drag
93
+ setIsDragging(true);
94
+ dragStartRef.current = { x: e.clientX, y: e.clientY, panX: newPanX, panY: newPanY };
95
+ },
96
+ [scale, zoom, vpWidth, vpHeight, setCanvasPan]
97
+ );
98
+
99
+ const handleMouseMove = useCallback(
100
+ (e: React.MouseEvent) => {
101
+ if (!isDragging || !dragStartRef.current) return;
102
+ const dx = e.clientX - dragStartRef.current.x;
103
+ const dy = e.clientY - dragStartRef.current.y;
104
+ // Minimap drag is inverted relative to zoom scale
105
+ const canvasDx = (dx / scale) * zoom;
106
+ const canvasDy = (dy / scale) * zoom;
107
+ setCanvasPan(
108
+ dragStartRef.current.panX - canvasDx,
109
+ dragStartRef.current.panY - canvasDy
110
+ );
111
+ },
112
+ [isDragging, scale, zoom, setCanvasPan]
113
+ );
114
+
115
+ const handleMouseUp = useCallback(() => {
116
+ setIsDragging(false);
117
+ dragStartRef.current = null;
118
+ }, []);
119
+
120
+ if (isCollapsed) {
121
+ return (
122
+ <button
123
+ onClick={() => setIsCollapsed(false)}
124
+ className="absolute bottom-6 right-4 z-40 w-8 h-8 flex items-center justify-center rounded-lg bg-[#1a1a1a]/80 text-white/60 hover:text-white hover:bg-[#1a1a1a] transition-colors shadow-lg backdrop-blur-sm"
125
+ title="Show minimap"
126
+ aria-label="Show minimap"
127
+ >
128
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
129
+ <rect x="1" y="1" width="12" height="12" rx="1.5" stroke="currentColor" strokeWidth="1.2" />
130
+ <rect x="3" y="3" width="4" height="3" rx="0.5" stroke="currentColor" strokeWidth="0.8" opacity="0.6" />
131
+ </svg>
132
+ </button>
133
+ );
134
+ }
135
+
136
+ return (
137
+ <div
138
+ className="absolute bottom-6 right-4 z-40 rounded-lg overflow-hidden shadow-lg backdrop-blur-sm"
139
+ style={{
140
+ width: MINIMAP_WIDTH,
141
+ height: MINIMAP_HEIGHT,
142
+ backgroundColor: "rgba(26, 26, 26, 0.85)",
143
+ border: "1px solid rgba(255, 255, 255, 0.1)",
144
+ userSelect: "none",
145
+ }}
146
+ onMouseDown={handleMinimapMouseDown}
147
+ onMouseMove={handleMouseMove}
148
+ onMouseUp={handleMouseUp}
149
+ onMouseLeave={handleMouseUp}
150
+ >
151
+ {/* Close/collapse button */}
152
+ <button
153
+ onClick={(e) => {
154
+ e.stopPropagation();
155
+ setIsCollapsed(true);
156
+ }}
157
+ className="absolute top-1 right-1 z-50 w-4 h-4 flex items-center justify-center rounded text-white/40 hover:text-white/80 transition-colors"
158
+ title="Hide minimap"
159
+ aria-label="Hide minimap"
160
+ >
161
+ <svg width="8" height="8" viewBox="0 0 8 8" fill="none">
162
+ <path d="M1 1L7 7M7 1L1 7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
163
+ </svg>
164
+ </button>
165
+
166
+ {/* Device frame outlines */}
167
+ {frames.map((f, i) => (
168
+ <div
169
+ key={i}
170
+ className="absolute"
171
+ style={{
172
+ left: f.x,
173
+ top: 8 * scale,
174
+ width: f.w,
175
+ height: (contentEstimateHeight - 40) * scale,
176
+ border: `1px solid ${f.active ? "rgba(7, 107, 255, 0.6)" : "rgba(255, 255, 255, 0.15)"}`,
177
+ borderRadius: 2,
178
+ backgroundColor: f.active ? "rgba(7, 107, 255, 0.08)" : "rgba(255, 255, 255, 0.03)",
179
+ }}
180
+ />
181
+ ))}
182
+
183
+ {/* Viewport indicator */}
184
+ <div
185
+ className="absolute"
186
+ style={{
187
+ left: Math.max(0, viewX),
188
+ top: Math.max(0, viewY),
189
+ width: Math.min(viewW, MINIMAP_WIDTH),
190
+ height: Math.min(viewH, MINIMAP_HEIGHT),
191
+ border: "1.5px solid rgba(237, 56, 33, 0.7)",
192
+ borderRadius: 2,
193
+ backgroundColor: "rgba(237, 56, 33, 0.06)",
194
+ cursor: isDragging ? "grabbing" : "grab",
195
+ pointerEvents: "none",
196
+ }}
197
+ />
198
+ </div>
199
+ );
200
+ }