From 6814e645938afd42d0803c8f824054df00e822ec Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 25 Jun 2026 10:51:12 +0330 Subject: [PATCH] fix(studio): responsive scene-preview placeholders that fit the still The hotspot overlay used fixed percentages on a w-full/h-auto image, so a 9:16 scene ballooned vertically and the placeholders (tuned for landscape) floated off the image. Now the still is CONTAIN-fit inside the measured area (portrait + landscape both fit, no overflow) and the hotspot overlay is anchored to the fitted image rectangle, so placeholders always track and scale with the image. Hotspot positions are aspect-aware (tall vs wide) and clamped to stay on the still. Co-Authored-By: Claude Opus 4.8 --- src/components/studio/canvas/ScenePreview.tsx | 180 ++++++++++++------ 1 file changed, 127 insertions(+), 53 deletions(-) diff --git a/src/components/studio/canvas/ScenePreview.tsx b/src/components/studio/canvas/ScenePreview.tsx index fc99738..08a6b2b 100644 --- a/src/components/studio/canvas/ScenePreview.tsx +++ b/src/components/studio/canvas/ScenePreview.tsx @@ -1,39 +1,71 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import type { Layer, Scene } from "@/lib/studio-types"; /** * Read-only scene preview for scene-engine (FlexStory/Remotion) templates. The - * Konva editor can't reproduce the real 3D render, so instead we show the scene's - * rendered still (scene.image) and overlay clickable, labelled HOTSPOTS over the - * editable fields (logo / text). Clicking a hotspot selects that field so the - * sidebar form scrolls to + highlights it — "click the logo to edit it". + * Konva editor can't reproduce the real 3D render, so we show the scene's rendered + * still (scene.image) and overlay clickable, labelled HOTSPOTS over the editable + * fields. Clicking a hotspot selects that field so the sidebar form scrolls to + + * highlights it — "click the logo to edit it". * - * Positions are heuristic (the studio doesn't know the exact Remotion coordinates), - * tuned for our block layouts: the visual/logo sits centred, text fields stack in - * the lower third — which matches LogoMotion3D and the FlexStory blocks closely. + * The still is FIT (contain) inside the available area — so portrait 9:16 and wide + * 16:9 scenes both fit without overflow — and the hotspot overlay is anchored to the + * fitted image rectangle, so the placeholders always line up with and scale to the + * image (responsive). Positions are aspect-aware heuristics tuned to the block + * layouts (visual/logo upper-centre, text fields stacked below), clamped to stay on + * the image. */ function fieldLabel(layer: Layer): string { return layer.name?.trim() || (layer.type === "image" ? "تصویر" : "متن"); } -function hotspot(layer: Layer, fields: Layer[]): React.CSSProperties { +const clamp = (v: number, lo = 3, hi = 97) => Math.max(lo, Math.min(hi, v)); + +/** Normalised (%) hotspot rect for a field, aware of the scene aspect so it stays + * on the image whether it's tall, square or wide. */ +function hotspot(layer: Layer, fields: Layer[], tall: boolean): React.CSSProperties { const images = fields.filter((l) => l.type === "image"); const texts = fields.filter((l) => l.type === "text"); + if (layer.type === "image") { const i = images.indexOf(layer); const n = Math.max(1, images.length); - if (n === 1) return { position: "absolute", left: "30%", top: "24%", width: "40%", height: "34%" }; - const w = 76 / n; - return { position: "absolute", left: `${12 + i * w}%`, top: "26%", width: `${w - 4}%`, height: "32%" }; + const top = tall ? 30 : 24; + if (n === 1) { + const w = tall ? 56 : 40; + return { position: "absolute", left: `${clamp(50 - w / 2)}%`, top: `${top}%`, width: `${w}%`, height: tall ? "26%" : "34%" }; + } + // multiple images → a row of even cells + const cols = Math.min(n, 3); + const cw = 88 / cols; + const col = i % cols; + const row = Math.floor(i / cols); + return { + position: "absolute", + left: `${clamp(6 + col * cw, 3, 97 - cw)}%`, + top: `${top + row * 22}%`, + width: `${cw - 4}%`, + height: "18%", + }; } + const i = texts.indexOf(layer); const n = Math.max(1, texts.length); - const slot = Math.min(30 / n, 12); - return { position: "absolute", left: "12%", top: `${64 + i * (30 / n)}%`, width: "76%", height: `${slot}%` }; + const band = tall ? 40 : 30; // vertical space the text block occupies + const startTop = tall ? 56 : 62; + const step = band / n; + const h = Math.min(step - 1.5, tall ? 9 : 11); + return { + position: "absolute", + left: "10%", + top: `${clamp(startTop + i * step, 3, 96)}%`, + width: "80%", + height: `${Math.max(5, h)}%`, + }; } interface ScenePreviewProps { @@ -49,46 +81,88 @@ export function ScenePreview({ scene, selectedLayerId, onSelect }: ScenePreviewP ); const bg = scene.image ?? scene.thumbnailUrl ?? null; - return ( -
-
- {bg ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : ( -
- )} + const areaRef = useRef(null); + const [area, setArea] = useState({ w: 0, h: 0 }); + const [ar, setAr] = useState(16 / 9); // image aspect (w/h), corrected on load - {fields.map((layer) => { - const sel = layer.id === selectedLayerId; - return ( - - ); - })} -
+ useEffect(() => { + const el = areaRef.current; + if (!el) return; + const ro = new ResizeObserver(() => setArea({ w: el.clientWidth, h: el.clientHeight })); + ro.observe(el); + setArea({ w: el.clientWidth, h: el.clientHeight }); + return () => ro.disconnect(); + }, []); + + // Contain the image inside the available area, centred. + const fit = useMemo(() => { + const { w, h } = area; + if (!w || !h) return null; + let fw = w; + let fh = w / ar; + if (fh > h) { + fh = h; + fw = h * ar; + } + return { width: fw, height: fh, left: (w - fw) / 2, top: (h - fh) / 2 }; + }, [area, ar]); + + const tall = ar < 0.85; + + return ( +
+ {bg ? ( + fit && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + { + const el = e.currentTarget; + if (el.naturalWidth && el.naturalHeight) setAr(el.naturalWidth / el.naturalHeight); + }} + className="block h-full w-full select-none object-cover" + /> + + {fields.map((layer) => { + const sel = layer.id === selectedLayerId; + return ( + + ); + })} +
+ ) + ) : ( +
+ )}
); }