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:
@@ -3,7 +3,9 @@
|
||||
import Image from "next/image";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
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 {
|
||||
clampSceneDuration,
|
||||
@@ -19,6 +21,8 @@ interface SceneThumbnailBlockProps {
|
||||
colorIndex: number;
|
||||
isActive: boolean;
|
||||
canDelete: boolean;
|
||||
/** FIX-mode lock: hides reorder/duplicate/delete + makes duration read-only. */
|
||||
locked?: boolean;
|
||||
pxPerSecond?: number;
|
||||
onSelect: () => void;
|
||||
onRename: (name: string) => void;
|
||||
@@ -32,6 +36,7 @@ export function SceneThumbnailBlock({
|
||||
colorIndex,
|
||||
isActive,
|
||||
canDelete,
|
||||
locked = false,
|
||||
pxPerSecond = STRIP_PX_PER_SECOND,
|
||||
onSelect,
|
||||
onRename,
|
||||
@@ -40,6 +45,8 @@ export function SceneThumbnailBlock({
|
||||
onDelete,
|
||||
}: SceneThumbnailBlockProps) {
|
||||
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 [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState(scene.name);
|
||||
@@ -106,7 +113,16 @@ export function SceneThumbnailBlock({
|
||||
);
|
||||
|
||||
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 ── */}
|
||||
<div
|
||||
role="button"
|
||||
@@ -147,7 +163,22 @@ export function SceneThumbnailBlock({
|
||||
{durationLabel}
|
||||
</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 */}
|
||||
{!locked && (
|
||||
<div className="absolute right-1 top-1 z-10 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
@@ -174,6 +205,7 @@ export function SceneThumbnailBlock({
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Duration bar — thin colored bar across the bottom */}
|
||||
<div
|
||||
@@ -183,17 +215,19 @@ export function SceneThumbnailBlock({
|
||||
/>
|
||||
|
||||
{/* Right-edge drag handle to resize duration */}
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t("resizeSceneDuration", { name: scene.name })}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handleResizeStart(event.clientX);
|
||||
}}
|
||||
className="absolute right-0 top-0 z-20 h-full w-2 cursor-ew-resize hover:bg-white/20"
|
||||
/>
|
||||
{!locked && (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t("resizeSceneDuration", { name: scene.name })}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handleResizeStart(event.clientX);
|
||||
}}
|
||||
className="absolute right-0 top-0 z-20 h-full w-2 cursor-ew-resize hover:bg-white/20"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Scene name below the block ── */}
|
||||
@@ -228,6 +262,37 @@ export function SceneThumbnailBlock({
|
||||
{scene.name}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
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 { SceneThumbnailBlock } from "@/components/studio/timeline/SceneThumbnailBlock";
|
||||
@@ -13,7 +22,7 @@ import {
|
||||
getSceneTimelineSegments,
|
||||
STRIP_PX_PER_SECOND,
|
||||
} from "@/lib/studio-timeline";
|
||||
import { useStudioStore } from "@/lib/studio-store";
|
||||
import { useStudioStore, isFixedSceneMode } from "@/lib/studio-store";
|
||||
|
||||
interface SceneThumbnailStripProps {
|
||||
onSceneSelect?: () => void;
|
||||
@@ -34,6 +43,22 @@ export function SceneThumbnailStrip({ onSceneSelect }: SceneThumbnailStripProps)
|
||||
const updateScene = useStudioStore((state) => state.updateScene);
|
||||
const duplicateScene = useStudioStore((state) => state.duplicateScene);
|
||||
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 totalDuration = useMemo(() => getProjectDuration(scenes), [scenes]);
|
||||
@@ -63,42 +88,58 @@ export function SceneThumbnailStrip({ onSceneSelect }: SceneThumbnailStripProps)
|
||||
|
||||
{/* Scene blocks */}
|
||||
<div className="relative flex items-end gap-1.5 px-2 pb-1 pt-1">
|
||||
{segments.map(({ scene, index }) => (
|
||||
<SceneThumbnailBlock
|
||||
key={scene.id}
|
||||
scene={scene}
|
||||
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")}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<LayoutGrid className="h-5 w-5" aria-hidden />
|
||||
</button>
|
||||
<SortableContext
|
||||
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
|
||||
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>
|
||||
{!fixed && (
|
||||
<>
|
||||
<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 />
|
||||
</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
|
||||
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(
|
||||
state: Pick<StudioState, "scenes" | "activeSceneId">
|
||||
): Scene | undefined {
|
||||
|
||||
Reference in New Issue
Block a user