From 866edbff8cfea11f3593cabcb628d1fd846d9f4c Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Wed, 24 Jun 2026 22:09:36 +0330 Subject: [PATCH] =?UTF-8?q?feat(studio):=20scene-engine=20preview=20editor?= =?UTF-8?q?=20=E2=80=94=20scene=20image=20+=20clickable=20field=20hotspots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/studio/CanvasEditor.tsx | 17 ++++ src/components/studio/canvas/ScenePreview.tsx | 94 +++++++++++++++++++ .../studio/sidebar/BlockFieldForm.tsx | 47 +++++++++- 3 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 src/components/studio/canvas/ScenePreview.tsx diff --git a/src/components/studio/CanvasEditor.tsx b/src/components/studio/CanvasEditor.tsx index 88bf792..6faf7d8 100644 --- a/src/components/studio/CanvasEditor.tsx +++ b/src/components/studio/CanvasEditor.tsx @@ -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
; } + // 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 ( +
+ {activeScene ? ( + + ) : null} +
+ ); + } + return (
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 ( +
+
+ {bg ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ )} + + {fields.map((layer) => { + const sel = layer.id === selectedLayerId; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/studio/sidebar/BlockFieldForm.tsx b/src/components/studio/sidebar/BlockFieldForm.tsx index 2fc90f4..fc6a298 100644 --- a/src/components/studio/sidebar/BlockFieldForm.tsx +++ b/src/components/studio/sidebar/BlockFieldForm.tsx @@ -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(null); + useEffect(() => { + if (active) ref.current?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + }, [active]); return ( -
+