feat(studio): Phase 3 — scene reorder + numeric duration + FIX/FLEXIBLE gating
Wires the scene-list operations users asked for into the existing timeline (model-agnostic — works for any scene, layer- or block-based): - SceneThumbnailBlock: now sortable (@dnd-kit useSortable) with a left-edge grip handle (listeners only on the handle so select/rename/resize still work); adds a numeric per-scene duration input (commit on blur/Enter, clamped 1–30s) next to the drag-resize; a `locked` prop makes it read-only. - SceneThumbnailStrip: wraps the blocks in DndContext + SortableContext (horizontal, 6px pointer-activation so clicks/resize aren't hijacked) and calls the existing reorderScenes store action; gates add/browse + reorder/duplicate/ delete/duration behind isFixedSceneMode(chooseMode). - studio-store: isFixedSceneMode() helper (single source for FIX vs FLEXIBLE). - i18n: reorderScene / durationLabel / secondsUnit in fa + en. Verified with `npm run build` (rules-of-hooks clean). NOTE: a THEME PICKER and FlexStory block-FIELD editing are deferred — the studio editor is Konva-layer- centric, so both need a FlexStory-aware editing path (a follow-up), not this phase. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -1224,7 +1224,10 @@
|
|||||||
"deleteScene": "Delete {name}",
|
"deleteScene": "Delete {name}",
|
||||||
"resizeSceneDuration": "Resize {name} duration",
|
"resizeSceneDuration": "Resize {name} duration",
|
||||||
"sceneNameLabel": "Scene name",
|
"sceneNameLabel": "Scene name",
|
||||||
"doubleClickToRename": "Double-click to rename"
|
"doubleClickToRename": "Double-click to rename",
|
||||||
|
"reorderScene": "Reorder {name}",
|
||||||
|
"durationLabel": "Scene duration (seconds)",
|
||||||
|
"secondsUnit": "s"
|
||||||
},
|
},
|
||||||
"componentsStudioTimelineSceneThumbnailStrip": {
|
"componentsStudioTimelineSceneThumbnailStrip": {
|
||||||
"browseScenes": "Browse scenes",
|
"browseScenes": "Browse scenes",
|
||||||
|
|||||||
+4
-1
@@ -1224,7 +1224,10 @@
|
|||||||
"deleteScene": "حذف {name}",
|
"deleteScene": "حذف {name}",
|
||||||
"resizeSceneDuration": "تغییر مدت زمان {name}",
|
"resizeSceneDuration": "تغییر مدت زمان {name}",
|
||||||
"sceneNameLabel": "نام صحنه",
|
"sceneNameLabel": "نام صحنه",
|
||||||
"doubleClickToRename": "برای تغییر نام، دوبار کلیک کنید"
|
"doubleClickToRename": "برای تغییر نام، دوبار کلیک کنید",
|
||||||
|
"reorderScene": "جابهجایی {name}",
|
||||||
|
"durationLabel": "مدت صحنه (ثانیه)",
|
||||||
|
"secondsUnit": "ث"
|
||||||
},
|
},
|
||||||
"componentsStudioTimelineSceneThumbnailStrip": {
|
"componentsStudioTimelineSceneThumbnailStrip": {
|
||||||
"browseScenes": "مرور صحنهها",
|
"browseScenes": "مرور صحنهها",
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Copy, Trash2 } from "lucide-react";
|
import { Copy, GripVertical, Trash2 } from "lucide-react";
|
||||||
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
clampSceneDuration,
|
clampSceneDuration,
|
||||||
@@ -19,6 +21,8 @@ interface SceneThumbnailBlockProps {
|
|||||||
colorIndex: number;
|
colorIndex: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
|
/** FIX-mode lock: hides reorder/duplicate/delete + makes duration read-only. */
|
||||||
|
locked?: boolean;
|
||||||
pxPerSecond?: number;
|
pxPerSecond?: number;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
onRename: (name: string) => void;
|
onRename: (name: string) => void;
|
||||||
@@ -32,6 +36,7 @@ export function SceneThumbnailBlock({
|
|||||||
colorIndex,
|
colorIndex,
|
||||||
isActive,
|
isActive,
|
||||||
canDelete,
|
canDelete,
|
||||||
|
locked = false,
|
||||||
pxPerSecond = STRIP_PX_PER_SECOND,
|
pxPerSecond = STRIP_PX_PER_SECOND,
|
||||||
onSelect,
|
onSelect,
|
||||||
onRename,
|
onRename,
|
||||||
@@ -40,6 +45,8 @@ export function SceneThumbnailBlock({
|
|||||||
onDelete,
|
onDelete,
|
||||||
}: SceneThumbnailBlockProps) {
|
}: SceneThumbnailBlockProps) {
|
||||||
const t = useTranslations("auto.componentsStudioTimelineSceneThumbnailBlock");
|
const t = useTranslations("auto.componentsStudioTimelineSceneThumbnailBlock");
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||||
|
useSortable({ id: scene.id, disabled: locked });
|
||||||
const [resizeDuration, setResizeDuration] = useState<number | null>(null);
|
const [resizeDuration, setResizeDuration] = useState<number | null>(null);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editName, setEditName] = useState(scene.name);
|
const [editName, setEditName] = useState(scene.name);
|
||||||
@@ -106,7 +113,16 @@ export function SceneThumbnailBlock({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0" style={{ width }}>
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* ── Thumbnail block ── */}
|
{/* ── Thumbnail block ── */}
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
@@ -147,7 +163,22 @@ export function SceneThumbnailBlock({
|
|||||||
{durationLabel}
|
{durationLabel}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* Drag handle (reorder) — left edge, hover-revealed */}
|
||||||
|
{!locked && (
|
||||||
|
<div
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
role="button"
|
||||||
|
aria-label={t("reorderScene", { name: scene.name })}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
className="absolute left-0 top-0 z-30 flex h-full w-4 cursor-grab items-center justify-center opacity-0 transition-opacity hover:bg-black/20 group-hover:opacity-100 active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-3.5 w-3.5 text-white/90" aria-hidden />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Action buttons — top-right, revealed on hover */}
|
{/* Action buttons — top-right, revealed on hover */}
|
||||||
|
{!locked && (
|
||||||
<div className="absolute right-1 top-1 z-10 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="absolute right-1 top-1 z-10 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -174,6 +205,7 @@ export function SceneThumbnailBlock({
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Duration bar — thin colored bar across the bottom */}
|
{/* Duration bar — thin colored bar across the bottom */}
|
||||||
<div
|
<div
|
||||||
@@ -183,17 +215,19 @@ export function SceneThumbnailBlock({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Right-edge drag handle to resize duration */}
|
{/* Right-edge drag handle to resize duration */}
|
||||||
<div
|
{!locked && (
|
||||||
role="separator"
|
<div
|
||||||
aria-orientation="vertical"
|
role="separator"
|
||||||
aria-label={t("resizeSceneDuration", { name: scene.name })}
|
aria-orientation="vertical"
|
||||||
onMouseDown={(event) => {
|
aria-label={t("resizeSceneDuration", { name: scene.name })}
|
||||||
event.stopPropagation();
|
onMouseDown={(event) => {
|
||||||
event.preventDefault();
|
event.stopPropagation();
|
||||||
handleResizeStart(event.clientX);
|
event.preventDefault();
|
||||||
}}
|
handleResizeStart(event.clientX);
|
||||||
className="absolute right-0 top-0 z-20 h-full w-2 cursor-ew-resize hover:bg-white/20"
|
}}
|
||||||
/>
|
className="absolute right-0 top-0 z-20 h-full w-2 cursor-ew-resize hover:bg-white/20"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Scene name below the block ── */}
|
{/* ── Scene name below the block ── */}
|
||||||
@@ -228,6 +262,37 @@ export function SceneThumbnailBlock({
|
|||||||
{scene.name}
|
{scene.name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Numeric duration input (commit on blur/Enter); read-only when locked. */}
|
||||||
|
{locked ? (
|
||||||
|
<p className="mt-0.5 text-center text-[10px] tabular-nums text-gray-400">
|
||||||
|
{scene.duration}
|
||||||
|
{t("secondsUnit")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-0.5 flex items-center justify-center gap-1">
|
||||||
|
<input
|
||||||
|
key={scene.duration}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={30}
|
||||||
|
step={0.1}
|
||||||
|
defaultValue={scene.duration}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onBlur={(event) =>
|
||||||
|
onDurationChange(
|
||||||
|
clampSceneDuration(parseFloat(event.target.value) || scene.duration)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") (event.target as HTMLInputElement).blur();
|
||||||
|
}}
|
||||||
|
className="w-11 rounded border border-gray-200 bg-white px-1 py-0.5 text-center text-[10px] tabular-nums text-gray-700 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500"
|
||||||
|
aria-label={t("durationLabel")}
|
||||||
|
/>
|
||||||
|
<span className="text-[9px] text-gray-400">{t("secondsUnit")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,15 @@
|
|||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { LayoutGrid, Plus } from "lucide-react";
|
import { LayoutGrid, Plus } from "lucide-react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import { SortableContext, horizontalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
|
|
||||||
import { SceneBrowserModal } from "@/components/studio/SceneBrowserModal";
|
import { SceneBrowserModal } from "@/components/studio/SceneBrowserModal";
|
||||||
import { SceneThumbnailBlock } from "@/components/studio/timeline/SceneThumbnailBlock";
|
import { SceneThumbnailBlock } from "@/components/studio/timeline/SceneThumbnailBlock";
|
||||||
@@ -13,7 +22,7 @@ import {
|
|||||||
getSceneTimelineSegments,
|
getSceneTimelineSegments,
|
||||||
STRIP_PX_PER_SECOND,
|
STRIP_PX_PER_SECOND,
|
||||||
} from "@/lib/studio-timeline";
|
} from "@/lib/studio-timeline";
|
||||||
import { useStudioStore } from "@/lib/studio-store";
|
import { useStudioStore, isFixedSceneMode } from "@/lib/studio-store";
|
||||||
|
|
||||||
interface SceneThumbnailStripProps {
|
interface SceneThumbnailStripProps {
|
||||||
onSceneSelect?: () => void;
|
onSceneSelect?: () => void;
|
||||||
@@ -34,6 +43,22 @@ export function SceneThumbnailStrip({ onSceneSelect }: SceneThumbnailStripProps)
|
|||||||
const updateScene = useStudioStore((state) => state.updateScene);
|
const updateScene = useStudioStore((state) => state.updateScene);
|
||||||
const duplicateScene = useStudioStore((state) => state.duplicateScene);
|
const duplicateScene = useStudioStore((state) => state.duplicateScene);
|
||||||
const deleteScene = useStudioStore((state) => state.deleteScene);
|
const deleteScene = useStudioStore((state) => state.deleteScene);
|
||||||
|
const reorderScenes = useStudioStore((state) => state.reorderScenes);
|
||||||
|
const chooseMode = useStudioStore((state) => state.chooseMode);
|
||||||
|
|
||||||
|
// FIX-mode templates have an immutable scene structure (from AE layer names);
|
||||||
|
// FLEXIBLE (incl. the scene-engine) lets users add/remove/reorder/retime.
|
||||||
|
const fixed = isFixedSceneMode(chooseMode);
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 6 } })
|
||||||
|
);
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
const from = scenes.findIndex((s) => s.id === active.id);
|
||||||
|
const to = scenes.findIndex((s) => s.id === over.id);
|
||||||
|
if (from !== -1 && to !== -1) reorderScenes(from, to);
|
||||||
|
};
|
||||||
|
|
||||||
const segments = useMemo(() => getSceneTimelineSegments(scenes), [scenes]);
|
const segments = useMemo(() => getSceneTimelineSegments(scenes), [scenes]);
|
||||||
const totalDuration = useMemo(() => getProjectDuration(scenes), [scenes]);
|
const totalDuration = useMemo(() => getProjectDuration(scenes), [scenes]);
|
||||||
@@ -63,42 +88,58 @@ export function SceneThumbnailStrip({ onSceneSelect }: SceneThumbnailStripProps)
|
|||||||
|
|
||||||
{/* Scene blocks */}
|
{/* Scene blocks */}
|
||||||
<div className="relative flex items-end gap-1.5 px-2 pb-1 pt-1">
|
<div className="relative flex items-end gap-1.5 px-2 pb-1 pt-1">
|
||||||
{segments.map(({ scene, index }) => (
|
<DndContext
|
||||||
<SceneThumbnailBlock
|
sensors={sensors}
|
||||||
key={scene.id}
|
collisionDetection={closestCenter}
|
||||||
scene={scene}
|
onDragEnd={handleDragEnd}
|
||||||
pxPerSecond={STRIP_PX_PER_SECOND}
|
|
||||||
colorIndex={index}
|
|
||||||
isActive={scene.id === activeSceneId}
|
|
||||||
canDelete={canDelete}
|
|
||||||
onSelect={() => {
|
|
||||||
setActiveScene(scene.id);
|
|
||||||
onSceneSelect?.();
|
|
||||||
}}
|
|
||||||
onRename={(name) => updateScene(scene.id, { name })}
|
|
||||||
onDurationChange={(duration) => updateScene(scene.id, { duration })}
|
|
||||||
onDuplicate={() => duplicateScene(scene.id)}
|
|
||||||
onDelete={() => deleteScene(scene.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setBrowserOpen(true)}
|
|
||||||
className="mb-5 flex h-20 w-10 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-400 transition-colors hover:border-blue-400 hover:bg-blue-50 hover:text-blue-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
|
||||||
aria-label={t("browseScenes")}
|
|
||||||
>
|
>
|
||||||
<LayoutGrid className="h-5 w-5" aria-hidden />
|
<SortableContext
|
||||||
</button>
|
items={scenes.map((s) => s.id)}
|
||||||
|
strategy={horizontalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{segments.map(({ scene, index }) => (
|
||||||
|
<SceneThumbnailBlock
|
||||||
|
key={scene.id}
|
||||||
|
scene={scene}
|
||||||
|
pxPerSecond={STRIP_PX_PER_SECOND}
|
||||||
|
colorIndex={index}
|
||||||
|
isActive={scene.id === activeSceneId}
|
||||||
|
canDelete={canDelete}
|
||||||
|
locked={fixed}
|
||||||
|
onSelect={() => {
|
||||||
|
setActiveScene(scene.id);
|
||||||
|
onSceneSelect?.();
|
||||||
|
}}
|
||||||
|
onRename={(name) => updateScene(scene.id, { name })}
|
||||||
|
onDurationChange={(duration) => updateScene(scene.id, { duration })}
|
||||||
|
onDuplicate={() => duplicateScene(scene.id)}
|
||||||
|
onDelete={() => deleteScene(scene.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
<button
|
{!fixed && (
|
||||||
type="button"
|
<>
|
||||||
onClick={() => addScene()}
|
<button
|
||||||
className="mb-5 flex h-20 w-10 shrink-0 items-center justify-center rounded-lg border-2 border-dashed border-gray-300 text-gray-300 transition-colors hover:border-blue-400 hover:text-blue-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
type="button"
|
||||||
aria-label={t("addScene")}
|
onClick={() => setBrowserOpen(true)}
|
||||||
>
|
className="mb-5 flex h-20 w-10 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-400 transition-colors hover:border-blue-400 hover:bg-blue-50 hover:text-blue-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||||
<Plus className="h-5 w-5" aria-hidden />
|
aria-label={t("browseScenes")}
|
||||||
</button>
|
>
|
||||||
|
<LayoutGrid className="h-5 w-5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addScene()}
|
||||||
|
className="mb-5 flex h-20 w-10 shrink-0 items-center justify-center rounded-lg border-2 border-dashed border-gray-300 text-gray-300 transition-colors hover:border-blue-400 hover:text-blue-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||||
|
aria-label={t("addScene")}
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<TimelinePlayhead
|
<TimelinePlayhead
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
|
|||||||
@@ -784,6 +784,16 @@ export const useStudioStore = create<StudioStore>((set, get) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIXED scene modes have an immutable scene structure (scenes come from AE comp /
|
||||||
|
* layer names), so the studio hides add/duplicate/delete/reorder + duration
|
||||||
|
* editing. FLEXIBLE (incl. the FlexStory scene engine) allows all of them.
|
||||||
|
*/
|
||||||
|
const FIXED_SCENE_MODES = new Set(["fix", "musicvisualizer"]);
|
||||||
|
export function isFixedSceneMode(chooseMode: string | null | undefined): boolean {
|
||||||
|
return FIXED_SCENE_MODES.has((chooseMode ?? "").toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
export function getActiveScene(
|
export function getActiveScene(
|
||||||
state: Pick<StudioState, "scenes" | "activeSceneId">
|
state: Pick<StudioState, "scenes" | "activeSceneId">
|
||||||
): Scene | undefined {
|
): Scene | undefined {
|
||||||
|
|||||||
Reference in New Issue
Block a user