feat(studio): scene-engine preview editor — scene image + clickable field hotspots

Replaces the misleading flat Konva canvas (for FLEXIBLE/Remotion templates) with a
real preview the user can edit against:
- ScenePreview shows the scene's rendered still (scene.image) centred, and overlays
  labelled, clickable HOTSPOTS over each editable field (logo / text), positioned by
  a layout heuristic tuned to our blocks (visual centred, text stacked below).
- Clicking a hotspot selects that field; BlockFieldForm highlights + scrolls to the
  matching field (and focusing a field highlights its hotspot) — "click the logo to
  edit it" works both ways.
- CanvasEditor branches to ScenePreview when isFlexStoryProject(); AE/Konva
  templates keep the full editor.

Fixes: (1) clicking a scene now shows its real image centre-screen; (2)/(3) the logo
and text are visible placeholders you can click to edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-24 22:09:36 +03:30
parent 055d8365fe
commit 866edbff8c
3 changed files with 154 additions and 4 deletions
+17
View File
@@ -5,6 +5,7 @@ import { Layer, Rect, Stage, Transformer } from "react-konva";
import type Konva from "konva";
import { CanvasLayerNode } from "@/components/studio/canvas/CanvasLayerNode";
import { ScenePreview } from "@/components/studio/canvas/ScenePreview";
import { useCanvasKeyboard } from "@/hooks/useCanvasKeyboard";
import { useCanvasPreviewPlayback } from "@/hooks/useCanvasPreviewPlayback";
import { useContainerSize } from "@/hooks/useContainerSize";
@@ -177,6 +178,22 @@ export function CanvasEditor() {
return <div ref={containerRef} className="h-full w-full" />;
}
// Scene-engine templates: show the rendered scene still + clickable field
// hotspots instead of the Konva stage (which can't reproduce the 3D render).
if (lockedGeometry) {
return (
<div ref={containerRef} className="h-full w-full">
{activeScene ? (
<ScenePreview
scene={activeScene}
selectedLayerId={selectedLayerId}
onSelect={setSelectedLayer}
/>
) : null}
</div>
);
}
return (
<div
ref={containerRef}
@@ -0,0 +1,94 @@
"use client";
import { useMemo } 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".
*
* 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.
*/
function fieldLabel(layer: Layer): string {
return layer.name?.trim() || (layer.type === "image" ? "تصویر" : "متن");
}
function hotspot(layer: Layer, fields: Layer[]): 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 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}%` };
}
interface ScenePreviewProps {
scene: Scene;
selectedLayerId: string | null;
onSelect: (id: string) => void;
}
export function ScenePreview({ scene, selectedLayerId, onSelect }: ScenePreviewProps) {
const fields = useMemo(
() => scene.layers.filter((l) => l.type === "text" || l.type === "image"),
[scene.layers]
);
const bg = scene.image ?? scene.thumbnailUrl ?? null;
return (
<div className="flex h-full w-full items-center justify-center p-4">
<div className="relative inline-block w-full max-w-3xl overflow-hidden rounded-lg bg-black shadow-2xl ring-1 ring-gray-700/80">
{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) => {
const sel = layer.id === selectedLayerId;
return (
<button
key={layer.id}
type="button"
onClick={() => onSelect(layer.id)}
style={hotspot(layer, fields)}
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 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>
);
}
@@ -1,11 +1,12 @@
"use client";
import { useRef } from "react";
import { useEffect, useRef } from "react";
import { ImagePlus, Type } from "lucide-react";
import { useTranslations } from "next-intl";
import { getTextProps, mergeLayerProps } from "@/lib/studio-layer-props";
import { useStudioStore } from "@/lib/studio-store";
import { cn } from "@/lib/utils";
/**
* Clean per-field content editor for FlexStory (scene-engine) projects. Each
@@ -20,6 +21,8 @@ export function BlockFieldForm() {
const scenes = useStudioStore((s) => s.scenes);
const activeSceneId = useStudioStore((s) => s.activeSceneId);
const updateLayer = useStudioStore((s) => s.updateLayer);
const selectedLayerId = useStudioStore((s) => s.selectedLayerId);
const setSelectedLayer = useStudioStore((s) => s.setSelectedLayer);
const activeScene = scenes.find((s) => s.id === activeSceneId);
const fields = (activeScene?.layers ?? []).filter(
@@ -52,6 +55,8 @@ export function BlockFieldForm() {
src={typeof layer.props.src === "string" ? layer.props.src : null}
replaceLabel={t("replaceImage")}
uploadLabel={t("uploadImage")}
active={layer.id === selectedLayerId}
onActivate={() => setSelectedLayer(layer.id)}
onReplace={(src) =>
updateLayer(layer.id, { props: mergeLayerProps(layer.props, { src }) })
}
@@ -62,6 +67,8 @@ export function BlockFieldForm() {
label={layer.name || t("fieldFallback", { index: idx + 1 })}
value={getTextProps(layer.props).text}
placeholder={t("textPlaceholder")}
active={layer.id === selectedLayerId}
onActivate={() => setSelectedLayer(layer.id)}
onChange={(text) =>
updateLayer(layer.id, { props: mergeLayerProps(layer.props, { text }) })
}
@@ -79,15 +86,29 @@ function TextField({
value,
onChange,
placeholder,
active,
onActivate,
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder: string;
active: boolean;
onActivate: () => void;
}) {
const MAX = 190;
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (active) ref.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [active]);
return (
<div className="border-b border-gray-100 px-4 py-3">
<div
ref={ref}
className={cn(
"border-b border-gray-100 px-4 py-3 transition-colors",
active && "bg-blue-50/70 ring-1 ring-inset ring-blue-300"
)}
>
<div className="mb-1.5 flex items-center justify-between">
<label className="flex items-center gap-1.5 text-[11px] font-semibold text-gray-600">
<Type className="h-3 w-3 text-gray-400" aria-hidden />
@@ -108,6 +129,7 @@ function TextField({
rows={value.length > 60 ? 3 : 2}
maxLength={MAX}
onChange={(e) => onChange(e.target.value)}
onFocus={onActivate}
placeholder={placeholder}
className="w-full resize-none rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:border-blue-400 focus:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
/>
@@ -121,14 +143,22 @@ function ImageField({
onReplace,
replaceLabel,
uploadLabel,
active,
onActivate,
}: {
label: string;
src: string | null;
onReplace: (src: string) => void;
replaceLabel: string;
uploadLabel: string;
active: boolean;
onActivate: () => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (active) ref.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [active]);
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -141,7 +171,13 @@ function ImageField({
};
return (
<div className="border-b border-gray-100 px-4 py-3">
<div
ref={ref}
className={cn(
"border-b border-gray-100 px-4 py-3 transition-colors",
active && "bg-blue-50/70 ring-1 ring-inset ring-blue-300"
)}
>
<label className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold text-gray-600">
<ImagePlus className="h-3 w-3 text-gray-400" aria-hidden />
{label}
@@ -149,7 +185,10 @@ function ImageField({
<input ref={inputRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
<button
type="button"
onClick={() => inputRef.current?.click()}
onClick={() => {
onActivate();
inputRef.current?.click();
}}
className="flex w-full items-center gap-2 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-left text-xs text-gray-600 transition-colors hover:border-blue-300 hover:bg-blue-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
{src ? (