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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-25 10:51:12 +03:30
parent a36e96d933
commit 6814e64593
+127 -53
View File
@@ -1,39 +1,71 @@
"use client"; "use client";
import { useMemo } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Layer, Scene } from "@/lib/studio-types"; import type { Layer, Scene } from "@/lib/studio-types";
/** /**
* Read-only scene preview for scene-engine (FlexStory/Remotion) templates. The * 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 * Konva editor can't reproduce the real 3D render, so we show the scene's rendered
* rendered still (scene.image) and overlay clickable, labelled HOTSPOTS over the * still (scene.image) and overlay clickable, labelled HOTSPOTS over the editable
* editable fields (logo / text). Clicking a hotspot selects that field so the * fields. Clicking a hotspot selects that field so the sidebar form scrolls to +
* sidebar form scrolls to + highlights it — "click the logo to edit it". * highlights it — "click the logo to edit it".
* *
* Positions are heuristic (the studio doesn't know the exact Remotion coordinates), * The still is FIT (contain) inside the available area — so portrait 9:16 and wide
* tuned for our block layouts: the visual/logo sits centred, text fields stack in * 16:9 scenes both fit without overflow — and the hotspot overlay is anchored to the
* the lower third — which matches LogoMotion3D and the FlexStory blocks closely. * 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 { function fieldLabel(layer: Layer): string {
return layer.name?.trim() || (layer.type === "image" ? "تصویر" : "متن"); 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 images = fields.filter((l) => l.type === "image");
const texts = fields.filter((l) => l.type === "text"); const texts = fields.filter((l) => l.type === "text");
if (layer.type === "image") { if (layer.type === "image") {
const i = images.indexOf(layer); const i = images.indexOf(layer);
const n = Math.max(1, images.length); const n = Math.max(1, images.length);
if (n === 1) return { position: "absolute", left: "30%", top: "24%", width: "40%", height: "34%" }; const top = tall ? 30 : 24;
const w = 76 / n; if (n === 1) {
return { position: "absolute", left: `${12 + i * w}%`, top: "26%", width: `${w - 4}%`, height: "32%" }; 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 i = texts.indexOf(layer);
const n = Math.max(1, texts.length); const n = Math.max(1, texts.length);
const slot = Math.min(30 / n, 12); const band = tall ? 40 : 30; // vertical space the text block occupies
return { position: "absolute", left: "12%", top: `${64 + i * (30 / n)}%`, width: "76%", height: `${slot}%` }; 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 { interface ScenePreviewProps {
@@ -49,46 +81,88 @@ export function ScenePreview({ scene, selectedLayerId, onSelect }: ScenePreviewP
); );
const bg = scene.image ?? scene.thumbnailUrl ?? null; const bg = scene.image ?? scene.thumbnailUrl ?? null;
return ( const areaRef = useRef<HTMLDivElement>(null);
<div className="flex h-full w-full items-center justify-center p-4"> const [area, setArea] = useState({ w: 0, h: 0 });
<div className="relative inline-block w-full max-w-3xl overflow-hidden rounded-lg bg-black shadow-2xl ring-1 ring-gray-700/80"> const [ar, setAr] = useState(16 / 9); // image aspect (w/h), corrected on load
{bg ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={bg} alt="" className="block h-auto w-full select-none" draggable={false} />
) : (
<div className="aspect-video w-full bg-gray-800" />
)}
{fields.map((layer) => { useEffect(() => {
const sel = layer.id === selectedLayerId; const el = areaRef.current;
return ( if (!el) return;
<button const ro = new ResizeObserver(() => setArea({ w: el.clientWidth, h: el.clientHeight }));
key={layer.id} ro.observe(el);
type="button" setArea({ w: el.clientWidth, h: el.clientHeight });
onClick={() => onSelect(layer.id)} return () => ro.disconnect();
style={hotspot(layer, fields)} }, []);
className={cn(
"group flex items-center justify-center rounded-md border-2 border-dashed transition", // Contain the image inside the available area, centred.
sel const fit = useMemo(() => {
? "border-blue-400 bg-blue-500/25" const { w, h } = area;
: "border-white/45 bg-white/[0.03] hover:border-blue-300 hover:bg-blue-400/20" if (!w || !h) return null;
)} let fw = w;
aria-label={fieldLabel(layer)} let fh = w / ar;
> if (fh > h) {
<span fh = h;
className={cn( fw = h * ar;
"pointer-events-none rounded px-2 py-0.5 text-[11px] font-semibold backdrop-blur-sm transition-opacity", }
sel return { width: fw, height: fh, left: (w - fw) / 2, top: (h - fh) / 2 };
? "bg-blue-500 text-white" }, [area, ar]);
: "bg-black/60 text-white/90 opacity-0 group-hover:opacity-100"
)} const tall = ar < 0.85;
>
{fieldLabel(layer)} return (
</span> <div ref={areaRef} className="relative flex h-full w-full items-center justify-center overflow-hidden p-3">
</button> {bg ? (
); fit && (
})} <div
</div> className="absolute overflow-hidden rounded-lg bg-black shadow-2xl ring-1 ring-gray-700/80"
style={{ left: fit.left, top: fit.top, width: fit.width, height: fit.height }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={bg}
alt=""
draggable={false}
onLoad={(e) => {
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 (
<button
key={layer.id}
type="button"
onClick={() => onSelect(layer.id)}
style={hotspot(layer, fields, tall)}
className={cn(
"group flex items-center justify-center rounded-md border-2 border-dashed transition",
sel
? "border-blue-400 bg-blue-500/25"
: "border-white/45 bg-white/[0.03] hover:border-blue-300 hover:bg-blue-400/20"
)}
aria-label={fieldLabel(layer)}
>
<span
className={cn(
"pointer-events-none max-w-full truncate rounded px-2 py-0.5 text-[11px] font-semibold backdrop-blur-sm transition-opacity",
sel
? "bg-blue-500 text-white"
: "bg-black/60 text-white/90 opacity-0 group-hover:opacity-100"
)}
>
{fieldLabel(layer)}
</span>
</button>
);
})}
</div>
)
) : (
<div className="aspect-video w-full max-w-3xl rounded-lg bg-gray-800" />
)}
</div> </div>
); );
} }