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 type Konva from "konva";
|
||||||
|
|
||||||
import { CanvasLayerNode } from "@/components/studio/canvas/CanvasLayerNode";
|
import { CanvasLayerNode } from "@/components/studio/canvas/CanvasLayerNode";
|
||||||
|
import { ScenePreview } from "@/components/studio/canvas/ScenePreview";
|
||||||
import { useCanvasKeyboard } from "@/hooks/useCanvasKeyboard";
|
import { useCanvasKeyboard } from "@/hooks/useCanvasKeyboard";
|
||||||
import { useCanvasPreviewPlayback } from "@/hooks/useCanvasPreviewPlayback";
|
import { useCanvasPreviewPlayback } from "@/hooks/useCanvasPreviewPlayback";
|
||||||
import { useContainerSize } from "@/hooks/useContainerSize";
|
import { useContainerSize } from "@/hooks/useContainerSize";
|
||||||
@@ -177,6 +178,22 @@ export function CanvasEditor() {
|
|||||||
return <div ref={containerRef} className="h-full w-full" />;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { ImagePlus, Type } from "lucide-react";
|
import { ImagePlus, Type } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { getTextProps, mergeLayerProps } from "@/lib/studio-layer-props";
|
import { getTextProps, mergeLayerProps } from "@/lib/studio-layer-props";
|
||||||
import { useStudioStore } from "@/lib/studio-store";
|
import { useStudioStore } from "@/lib/studio-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean per-field content editor for FlexStory (scene-engine) projects. Each
|
* 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 scenes = useStudioStore((s) => s.scenes);
|
||||||
const activeSceneId = useStudioStore((s) => s.activeSceneId);
|
const activeSceneId = useStudioStore((s) => s.activeSceneId);
|
||||||
const updateLayer = useStudioStore((s) => s.updateLayer);
|
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 activeScene = scenes.find((s) => s.id === activeSceneId);
|
||||||
const fields = (activeScene?.layers ?? []).filter(
|
const fields = (activeScene?.layers ?? []).filter(
|
||||||
@@ -52,6 +55,8 @@ export function BlockFieldForm() {
|
|||||||
src={typeof layer.props.src === "string" ? layer.props.src : null}
|
src={typeof layer.props.src === "string" ? layer.props.src : null}
|
||||||
replaceLabel={t("replaceImage")}
|
replaceLabel={t("replaceImage")}
|
||||||
uploadLabel={t("uploadImage")}
|
uploadLabel={t("uploadImage")}
|
||||||
|
active={layer.id === selectedLayerId}
|
||||||
|
onActivate={() => setSelectedLayer(layer.id)}
|
||||||
onReplace={(src) =>
|
onReplace={(src) =>
|
||||||
updateLayer(layer.id, { props: mergeLayerProps(layer.props, { src }) })
|
updateLayer(layer.id, { props: mergeLayerProps(layer.props, { src }) })
|
||||||
}
|
}
|
||||||
@@ -62,6 +67,8 @@ export function BlockFieldForm() {
|
|||||||
label={layer.name || t("fieldFallback", { index: idx + 1 })}
|
label={layer.name || t("fieldFallback", { index: idx + 1 })}
|
||||||
value={getTextProps(layer.props).text}
|
value={getTextProps(layer.props).text}
|
||||||
placeholder={t("textPlaceholder")}
|
placeholder={t("textPlaceholder")}
|
||||||
|
active={layer.id === selectedLayerId}
|
||||||
|
onActivate={() => setSelectedLayer(layer.id)}
|
||||||
onChange={(text) =>
|
onChange={(text) =>
|
||||||
updateLayer(layer.id, { props: mergeLayerProps(layer.props, { text }) })
|
updateLayer(layer.id, { props: mergeLayerProps(layer.props, { text }) })
|
||||||
}
|
}
|
||||||
@@ -79,15 +86,29 @@ function TextField({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
active,
|
||||||
|
onActivate,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
|
active: boolean;
|
||||||
|
onActivate: () => void;
|
||||||
}) {
|
}) {
|
||||||
const MAX = 190;
|
const MAX = 190;
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) ref.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
|
}, [active]);
|
||||||
return (
|
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">
|
<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">
|
<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 />
|
<Type className="h-3 w-3 text-gray-400" aria-hidden />
|
||||||
@@ -108,6 +129,7 @@ function TextField({
|
|||||||
rows={value.length > 60 ? 3 : 2}
|
rows={value.length > 60 ? 3 : 2}
|
||||||
maxLength={MAX}
|
maxLength={MAX}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onFocus={onActivate}
|
||||||
placeholder={placeholder}
|
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"
|
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,
|
onReplace,
|
||||||
replaceLabel,
|
replaceLabel,
|
||||||
uploadLabel,
|
uploadLabel,
|
||||||
|
active,
|
||||||
|
onActivate,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
src: string | null;
|
src: string | null;
|
||||||
onReplace: (src: string) => void;
|
onReplace: (src: string) => void;
|
||||||
replaceLabel: string;
|
replaceLabel: string;
|
||||||
uploadLabel: string;
|
uploadLabel: string;
|
||||||
|
active: boolean;
|
||||||
|
onActivate: () => void;
|
||||||
}) {
|
}) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
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 handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -141,7 +171,13 @@ function ImageField({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<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 />
|
<ImagePlus className="h-3 w-3 text-gray-400" aria-hidden />
|
||||||
{label}
|
{label}
|
||||||
@@ -149,7 +185,10 @@ function ImageField({
|
|||||||
<input ref={inputRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
|
<input ref={inputRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
|
||||||
<button
|
<button
|
||||||
type="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"
|
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 ? (
|
{src ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user