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:
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user