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
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 (
+
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)}
+ >
+
+ {fieldLabel(layer)}
+
+
+ );
+ })}
+
+
+ );
+}
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 (
-
+
@@ -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(null);
+ const ref = useRef(null);
+ useEffect(() => {
+ if (active) ref.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
+ }, [active]);
const handleFile = (e: React.ChangeEvent) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -141,7 +171,13 @@ function ImageField({
};
return (
-