@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.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/admin/assets.ts +4 -0
- package/admin/database.ts +4 -0
- package/admin/index.ts +6 -0
- package/admin/login.ts +4 -0
- package/admin/navigation.ts +4 -0
- package/admin/pages-editor.ts +4 -0
- package/admin/pages.ts +4 -0
- package/admin/projects-editor.ts +4 -0
- package/admin/projects.ts +4 -0
- package/admin/settings.ts +4 -0
- package/admin/setup.ts +4 -0
- package/admin/storage.ts +4 -0
- package/admin/styles.ts +4 -0
- package/app/(site)/[slug]/loading.tsx +20 -0
- package/app/(site)/[slug]/page.tsx +83 -0
- package/app/(site)/error.tsx +32 -0
- package/app/(site)/layout.tsx +53 -0
- package/app/(site)/loading.tsx +20 -0
- package/app/(site)/not-found.tsx +41 -0
- package/app/(site)/page.tsx +43 -0
- package/app/(site)/preview/page.tsx +99 -0
- package/app/(site)/work/[slug]/loading.tsx +23 -0
- package/app/(site)/work/[slug]/page.tsx +84 -0
- package/app/admin/assets/page.tsx +573 -0
- package/app/admin/database/page.tsx +302 -0
- package/app/admin/error.tsx +53 -0
- package/app/admin/layout.tsx +273 -0
- package/app/admin/login/page.tsx +88 -0
- package/app/admin/navigation/page.tsx +157 -0
- package/app/admin/page.tsx +17 -0
- package/app/admin/pages/[slug]/page.tsx +849 -0
- package/app/admin/pages/page.tsx +588 -0
- package/app/admin/projects/[slug]/page.tsx +3 -0
- package/app/admin/projects/page.tsx +669 -0
- package/app/admin/settings/page.tsx +132 -0
- package/app/admin/setup/page.tsx +64 -0
- package/app/admin/storage/page.tsx +518 -0
- package/app/admin/styles/page.tsx +243 -0
- package/app/api/admin/assets/file/route.ts +81 -0
- package/app/api/admin/assets/health/route.ts +170 -0
- package/app/api/admin/assets/register/route.ts +163 -0
- package/app/api/admin/assets/registry/route.ts +98 -0
- package/app/api/admin/assets/relink/confirm/route.ts +242 -0
- package/app/api/admin/assets/relink/route.ts +202 -0
- package/app/api/admin/assets/scan/route.ts +271 -0
- package/app/api/admin/auth/route.ts +160 -0
- package/app/api/admin/custom-sections/[slug]/route.ts +159 -0
- package/app/api/admin/custom-sections/route.ts +127 -0
- package/app/api/admin/database/route.ts +53 -0
- package/app/api/admin/pages/[slug]/duplicate/route.ts +91 -0
- package/app/api/admin/pages/[slug]/route.ts +617 -0
- package/app/api/admin/pages/[slug]/set-home/route.ts +76 -0
- package/app/api/admin/pages/route.ts +129 -0
- package/app/api/admin/preview/route.ts +53 -0
- package/app/api/admin/r2/connect/route.ts +181 -0
- package/app/api/admin/r2/delete/route.ts +198 -0
- package/app/api/admin/r2/disconnect/route.ts +42 -0
- package/app/api/admin/r2/rename/route.ts +265 -0
- package/app/api/admin/r2/status/route.ts +106 -0
- package/app/api/admin/r2/upload-url/route.ts +148 -0
- package/app/api/admin/revalidate/route.ts +55 -0
- package/app/api/admin/settings/route.ts +279 -0
- package/app/api/admin/setup/complete/route.ts +51 -0
- package/app/api/admin/setup/route.ts +118 -0
- package/app/api/admin/storage/switch/route.ts +117 -0
- package/app/api/admin/styles/fonts/route.ts +97 -0
- package/app/api/admin/styles/route.ts +304 -0
- package/app/api/assets/[...path]/route.ts +98 -0
- package/app/api/custom-sections/[id]/route.ts +43 -0
- package/app/api/draft-mode/disable/route.ts +10 -0
- package/app/api/draft-mode/enable/route.ts +26 -0
- package/app/api/projects/route.ts +42 -0
- package/app/api/styles/route.ts +88 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +7 -0
- package/app/layout.tsx +53 -0
- package/app/robots.ts +17 -0
- package/app/sitemap.ts +48 -0
- package/app/studio/[[...index]]/page.tsx +8 -0
- package/components/admin/MetadataEditor.tsx +173 -0
- package/components/admin/PublishToggle.tsx +130 -0
- package/components/admin/icons.tsx +40 -0
- package/components/admin/nav-builder/NavBuilder.tsx +182 -0
- package/components/admin/nav-builder/NavBuilderGrid.tsx +326 -0
- package/components/admin/nav-builder/NavGeneralSettings.tsx +275 -0
- package/components/admin/nav-builder/NavGridCell.tsx +48 -0
- package/components/admin/nav-builder/NavGridItem.tsx +189 -0
- package/components/admin/nav-builder/NavItemSettings.tsx +288 -0
- package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -0
- package/components/admin/nav-builder/NavLivePreview.tsx +125 -0
- package/components/admin/nav-builder/NavSettingsFields.tsx +248 -0
- package/components/admin/nav-builder/NavSettingsPanel.tsx +127 -0
- package/components/admin/nav-builder/index.ts +10 -0
- package/components/admin/nav-builder/nav-builder-utils.ts +238 -0
- package/components/admin/setup-wizard/BrandingStep.tsx +218 -0
- package/components/admin/setup-wizard/DatabaseStep.tsx +331 -0
- package/components/admin/setup-wizard/DoneStep.tsx +187 -0
- package/components/admin/setup-wizard/SetupWizard.tsx +166 -0
- package/components/admin/setup-wizard/StorageStep.tsx +308 -0
- package/components/admin/setup-wizard/WelcomeStep.tsx +96 -0
- package/components/admin/setup-wizard/index.ts +9 -0
- package/components/admin/styles/ColorsEditor.tsx +214 -0
- package/components/admin/styles/FontsEditor.tsx +258 -0
- package/components/admin/styles/GridLayoutEditor.tsx +292 -0
- package/components/admin/styles/LinksButtonsEditor.tsx +120 -0
- package/components/admin/styles/TypographyEditor.tsx +266 -0
- package/components/admin/styles/index.ts +9 -0
- package/components/admin/styles/shared.tsx +68 -0
- package/components/blocks/BlockRenderer.tsx +404 -0
- package/components/blocks/ButtonBlockRenderer.tsx +52 -0
- package/components/blocks/CoverBlockRenderer.tsx +239 -0
- package/components/blocks/CustomSectionInstanceRenderer.tsx +82 -0
- package/components/blocks/EnterAnimationWrapper.tsx +140 -0
- package/components/blocks/HoverAnimationWrapper.tsx +308 -0
- package/components/blocks/ImageBlockRenderer.tsx +61 -0
- package/components/blocks/ImageGridBlockRenderer.tsx +545 -0
- package/components/blocks/PageBackground.tsx +28 -0
- package/components/blocks/PageNavAnimation.tsx +35 -0
- package/components/blocks/PageNavColor.tsx +24 -0
- package/components/blocks/PageRenderer.tsx +142 -0
- package/components/blocks/ParallaxGroupRenderer.tsx +448 -0
- package/components/blocks/ParallaxSlideRenderer.tsx +175 -0
- package/components/blocks/ProjectGridBlockRenderer.tsx +556 -0
- package/components/blocks/SectionRenderer.tsx +170 -0
- package/components/blocks/SectionV2Renderer.tsx +330 -0
- package/components/blocks/ShaderCanvas.tsx +392 -0
- package/components/blocks/SpacerBlockRenderer.tsx +17 -0
- package/components/blocks/TextBlockRenderer.tsx +87 -0
- package/components/blocks/TypewriterRichText.tsx +464 -0
- package/components/blocks/TypewriterWrapper.tsx +149 -0
- package/components/blocks/VideoBlockRenderer.tsx +304 -0
- package/components/blocks/index.ts +2 -0
- package/components/builder/AssetBrowser.tsx +2 -0
- package/components/builder/BlockLivePreview.tsx +101 -0
- package/components/builder/BlockTypePicker.tsx +178 -0
- package/components/builder/BuilderCanvas.tsx +354 -0
- package/components/builder/CanvasMinimap.tsx +200 -0
- package/components/builder/CanvasToolbar.tsx +202 -0
- package/components/builder/ColorPicker.tsx +243 -0
- package/components/builder/ColorSwatchPicker.tsx +274 -0
- package/components/builder/ColumnDragContext.tsx +51 -0
- package/components/builder/ColumnDragOverlay.tsx +110 -0
- package/components/builder/CustomSectionInstanceCard.tsx +97 -0
- package/components/builder/DeviceFrame.tsx +123 -0
- package/components/builder/DndWrapper.tsx +337 -0
- package/components/builder/InsertionLines.tsx +186 -0
- package/components/builder/ParallaxGroupCanvas.tsx +228 -0
- package/components/builder/ParallaxSlideHeader.tsx +113 -0
- package/components/builder/ReadOnlyFrame.tsx +417 -0
- package/components/builder/SectionEditorBar.tsx +288 -0
- package/components/builder/SectionTypePicker.tsx +422 -0
- package/components/builder/SectionV2Canvas.tsx +297 -0
- package/components/builder/SectionV2Column.tsx +488 -0
- package/components/builder/SettingsPanel.tsx +911 -0
- package/components/builder/SortableBlock.tsx +230 -0
- package/components/builder/SortableRow.tsx +362 -0
- package/components/builder/VirtualAssetGrid.tsx +397 -0
- package/components/builder/asset-browser/AssetBrowser.tsx +178 -0
- package/components/builder/asset-browser/FileLightbox.tsx +116 -0
- package/components/builder/asset-browser/FolderTreeItem.tsx +55 -0
- package/components/builder/asset-browser/R2BrowserContent.tsx +436 -0
- package/components/builder/asset-browser/R2ContextMenu.tsx +98 -0
- package/components/builder/asset-browser/VideoThumbnail.tsx +63 -0
- package/components/builder/asset-browser/helpers.ts +88 -0
- package/components/builder/asset-browser/index.ts +1 -0
- package/components/builder/asset-browser/types.ts +49 -0
- package/components/builder/asset-browser/useAssetBrowser.ts +344 -0
- package/components/builder/asset-browser/useR2DragDrop.ts +116 -0
- package/components/builder/asset-browser/useR2Operations.ts +189 -0
- package/components/builder/blockStyles.tsx +295 -0
- package/components/builder/editors/ButtonBlockEditor.tsx +184 -0
- package/components/builder/editors/CoverBlockEditor.tsx +488 -0
- package/components/builder/editors/EnterAnimationPicker.tsx +297 -0
- package/components/builder/editors/HoverEffectPicker.tsx +209 -0
- package/components/builder/editors/ImageBlockEditor.tsx +206 -0
- package/components/builder/editors/ImageGridBlockEditor.tsx +386 -0
- package/components/builder/editors/ProjectGridEditor.tsx +648 -0
- package/components/builder/editors/SpacerBlockEditor.tsx +167 -0
- package/components/builder/editors/StaggerSettings.tsx +108 -0
- package/components/builder/editors/TextAlignmentIcons.tsx +39 -0
- package/components/builder/editors/TextBlockEditor.tsx +462 -0
- package/components/builder/editors/TextStylePicker.tsx +183 -0
- package/components/builder/editors/VideoBlockEditor.tsx +278 -0
- package/components/builder/editors/index.ts +10 -0
- package/components/builder/editors/shared.tsx +345 -0
- package/components/builder/hooks/useColumnDrag.ts +472 -0
- package/components/builder/hooks/useColumnResize.ts +221 -0
- package/components/builder/index.ts +12 -0
- package/components/builder/live-preview/LiveButtonPreview.tsx +38 -0
- package/components/builder/live-preview/LiveCoverPreview.tsx +146 -0
- package/components/builder/live-preview/LiveImageGridPreview.tsx +123 -0
- package/components/builder/live-preview/LiveImagePreview.tsx +107 -0
- package/components/builder/live-preview/LiveProjectGridPreview.tsx +1010 -0
- package/components/builder/live-preview/LiveSpacerPreview.tsx +9 -0
- package/components/builder/live-preview/LiveTextEditor.tsx +198 -0
- package/components/builder/live-preview/LiveVideoPreview.tsx +98 -0
- package/components/builder/live-preview/index.ts +10 -0
- package/components/builder/live-preview/shared.tsx +153 -0
- package/components/builder/settings-panel/BlockLayoutTab.tsx +532 -0
- package/components/builder/settings-panel/BlockSettings.tsx +94 -0
- package/components/builder/settings-panel/ColumnV2Settings.tsx +160 -0
- package/components/builder/settings-panel/LayoutTab.tsx +310 -0
- package/components/builder/settings-panel/PageSettings.tsx +200 -0
- package/components/builder/settings-panel/ParallaxGroupSettings.tsx +118 -0
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +178 -0
- package/components/builder/settings-panel/SectionV2AnimationTab.tsx +103 -0
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +312 -0
- package/components/builder/settings-panel/SectionV2Settings.tsx +323 -0
- package/components/builder/settings-panel/TRBLInputs.tsx +51 -0
- package/components/builder/settings-panel/index.ts +19 -0
- package/components/builder/settings-panel/responsive-helpers.ts +524 -0
- package/components/ui/CustomCursor.tsx +118 -0
- package/components/ui/NavContentLightbox.tsx +152 -0
- package/components/ui/Navbar.tsx +582 -0
- package/components/ui/PortfolioTracker.tsx +87 -0
- package/components/ui/ScrollToTop.tsx +47 -0
- package/lib/animation/enter-presets.ts +147 -0
- package/lib/animation/enter-resolve.ts +90 -0
- package/lib/animation/enter-types.ts +128 -0
- package/lib/animation/hover-effect-presets.ts +210 -0
- package/lib/animation/hover-effect-types.ts +126 -0
- package/lib/asset-retry.ts +111 -0
- package/lib/assets.ts +92 -0
- package/lib/audit.ts +35 -0
- package/lib/auth-token.ts +94 -0
- package/lib/auth.ts +13 -0
- package/lib/builder/cascade-helpers.ts +51 -0
- package/lib/builder/cascade.ts +533 -0
- package/lib/builder/constants.ts +103 -0
- package/lib/builder/defaults.ts +182 -0
- package/lib/builder/history.ts +48 -0
- package/lib/builder/index.ts +21 -0
- package/lib/builder/layout-styles.ts +344 -0
- package/lib/builder/masonry.ts +166 -0
- package/lib/builder/responsive.ts +156 -0
- package/lib/builder/serializer.ts +845 -0
- package/lib/builder/store-blocks.ts +193 -0
- package/lib/builder/store-canvas.ts +319 -0
- package/lib/builder/store-helpers.ts +490 -0
- package/lib/builder/store-sections.ts +709 -0
- package/lib/builder/store.ts +333 -0
- package/lib/builder/templates.ts +297 -0
- package/lib/builder/types.ts +374 -0
- package/lib/builder/utils.ts +37 -0
- package/lib/color-utils.ts +116 -0
- package/lib/config/index.ts +57 -0
- package/lib/config/types.ts +122 -0
- package/lib/contexts/AssetContext.tsx +79 -0
- package/lib/contexts/NavAnimationContext.tsx +44 -0
- package/lib/contexts/NavColorContext.tsx +38 -0
- package/lib/contexts/PageExitContext.tsx +194 -0
- package/lib/contexts/ThumbStatusContext.tsx +83 -0
- package/lib/csrf-client.ts +34 -0
- package/lib/csrf.ts +68 -0
- package/lib/format-utils.ts +24 -0
- package/lib/hooks/useViewport.ts +42 -0
- package/lib/logger.ts +81 -0
- package/lib/revalidate.ts +23 -0
- package/lib/sanitize.ts +91 -0
- package/lib/sanity/client.ts +8 -0
- package/lib/sanity/queries.ts +486 -0
- package/lib/sanity/types.ts +869 -0
- package/lib/sanity/writeClient.ts +24 -0
- package/lib/security.ts +402 -0
- package/lib/setup/detect.ts +156 -0
- package/lib/shader/glsl/index.ts +27 -0
- package/lib/shader/glsl/pixelate.ts +51 -0
- package/lib/shader/glsl/rgb-shift.ts +45 -0
- package/lib/shader/glsl/ripple.ts +46 -0
- package/lib/shader/glsl/vertex.ts +14 -0
- package/lib/storage/index.ts +211 -0
- package/lib/storage/r2-adapter.ts +286 -0
- package/lib/storage/types.ts +125 -0
- package/lib/styles/provider.tsx +267 -0
- package/lib/thumbnails/generate.ts +151 -0
- package/lib/utils.ts +6 -0
- package/package.json +212 -0
- package/sanity/compose.ts +65 -0
- package/sanity/sanity.config.ts +126 -0
- package/sanity/schemas/assetRegistry.ts +301 -0
- package/sanity/schemas/blocks/blockLayout.ts +90 -0
- package/sanity/schemas/blocks/buttonBlock.ts +82 -0
- package/sanity/schemas/blocks/coverBlock.ts +229 -0
- package/sanity/schemas/blocks/imageBlock.ts +58 -0
- package/sanity/schemas/blocks/imageGridBlock.ts +112 -0
- package/sanity/schemas/blocks/index.ts +9 -0
- package/sanity/schemas/blocks/projectGridBlock.ts +251 -0
- package/sanity/schemas/blocks/spacerBlock.ts +41 -0
- package/sanity/schemas/blocks/textBlock.ts +139 -0
- package/sanity/schemas/blocks/videoBlock.ts +80 -0
- package/sanity/schemas/customSection.ts +69 -0
- package/sanity/schemas/customSectionInstance.ts +163 -0
- package/sanity/schemas/index.ts +111 -0
- package/sanity/schemas/objects/enterAnimationConfig.ts +72 -0
- package/sanity/schemas/objects/hoverEffectConfig.ts +90 -0
- package/sanity/schemas/objects/parallaxGroup.ts +66 -0
- package/sanity/schemas/objects/parallaxSlide.ts +217 -0
- package/sanity/schemas/objects/typewriterConfig.ts +38 -0
- package/sanity/schemas/page.ts +162 -0
- package/sanity/schemas/pageSection.ts +157 -0
- package/sanity/schemas/pageSectionV2.ts +269 -0
- package/sanity/schemas/siteSettings.ts +256 -0
- package/sanity/schemas/siteStyles.ts +210 -0
- package/site/error.ts +4 -0
- package/site/index.ts +8 -0
- package/site/not-found.ts +4 -0
- package/site/page.ts +4 -0
- package/site/preview.ts +4 -0
- package/site/robots.ts +4 -0
- package/site/sitemap.ts +4 -0
- package/site/work.ts +4 -0
- package/studio/index.ts +4 -0
- package/styles/admin.css +85 -0
- package/styles/animations.css +237 -0
- package/styles/base.css +148 -0
- package/styles/globals.css +10 -0
- 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
|
+
}
|