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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user