From 8ddca5647ba5004fbba5761ccf616d7a7b61eeb6 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 23 Jun 2026 14:18:00 +0330 Subject: [PATCH] =?UTF-8?q?feat(studio):=20Phase=203=20=E2=80=94=20scene?= =?UTF-8?q?=20reorder=20+=20numeric=20duration=20+=20FIX/FLEXIBLE=20gating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- messages/en.json | 5 +- messages/fa.json | 5 +- .../studio/timeline/SceneThumbnailBlock.tsx | 91 ++++++++++++-- .../studio/timeline/SceneThumbnailStrip.tsx | 111 ++++++++++++------ src/lib/studio-store.ts | 10 ++ 5 files changed, 172 insertions(+), 50 deletions(-) diff --git a/messages/en.json b/messages/en.json index 6b77818..e626422 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1224,7 +1224,10 @@ "deleteScene": "Delete {name}", "resizeSceneDuration": "Resize {name} duration", "sceneNameLabel": "Scene name", - "doubleClickToRename": "Double-click to rename" + "doubleClickToRename": "Double-click to rename", + "reorderScene": "Reorder {name}", + "durationLabel": "Scene duration (seconds)", + "secondsUnit": "s" }, "componentsStudioTimelineSceneThumbnailStrip": { "browseScenes": "Browse scenes", diff --git a/messages/fa.json b/messages/fa.json index c9132df..401b609 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -1224,7 +1224,10 @@ "deleteScene": "حذف {name}", "resizeSceneDuration": "تغییر مدت زمان {name}", "sceneNameLabel": "نام صحنه", - "doubleClickToRename": "برای تغییر نام، دوبار کلیک کنید" + "doubleClickToRename": "برای تغییر نام، دوبار کلیک کنید", + "reorderScene": "جابه‌جایی {name}", + "durationLabel": "مدت صحنه (ثانیه)", + "secondsUnit": "ث" }, "componentsStudioTimelineSceneThumbnailStrip": { "browseScenes": "مرور صحنه‌ها", diff --git a/src/components/studio/timeline/SceneThumbnailBlock.tsx b/src/components/studio/timeline/SceneThumbnailBlock.tsx index 588d456..70a601b 100644 --- a/src/components/studio/timeline/SceneThumbnailBlock.tsx +++ b/src/components/studio/timeline/SceneThumbnailBlock.tsx @@ -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(null); const [isEditing, setIsEditing] = useState(false); const [editName, setEditName] = useState(scene.name); @@ -106,7 +113,16 @@ export function SceneThumbnailBlock({ ); return ( -
+
{/* ── Thumbnail block ── */}
+ {/* Drag handle (reorder) — left edge, hover-revealed */} + {!locked && ( +
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" + > + +
+ )} + {/* Action buttons — top-right, revealed on hover */} + {!locked && (
+ )} {/* Duration bar — thin colored bar across the bottom */}
{/* Right-edge drag handle to resize duration */} -
{ - 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 && ( +
{ + 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" + /> + )}
{/* ── Scene name below the block ── */} @@ -228,6 +262,37 @@ export function SceneThumbnailBlock({ {scene.name}

)} + + {/* Numeric duration input (commit on blur/Enter); read-only when locked. */} + {locked ? ( +

+ {scene.duration} + {t("secondsUnit")} +

+ ) : ( +
+ 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")} + /> + {t("secondsUnit")} +
+ )}
); } diff --git a/src/components/studio/timeline/SceneThumbnailStrip.tsx b/src/components/studio/timeline/SceneThumbnailStrip.tsx index 440e634..f9a4aea 100644 --- a/src/components/studio/timeline/SceneThumbnailStrip.tsx +++ b/src/components/studio/timeline/SceneThumbnailStrip.tsx @@ -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 */}
- {segments.map(({ scene, index }) => ( - { - setActiveScene(scene.id); - onSceneSelect?.(); - }} - onRename={(name) => updateScene(scene.id, { name })} - onDurationChange={(duration) => updateScene(scene.id, { duration })} - onDuplicate={() => duplicateScene(scene.id)} - onDelete={() => deleteScene(scene.id)} - /> - ))} - - + s.id)} + strategy={horizontalListSortingStrategy} + > + {segments.map(({ scene, index }) => ( + { + setActiveScene(scene.id); + onSceneSelect?.(); + }} + onRename={(name) => updateScene(scene.id, { name })} + onDurationChange={(duration) => updateScene(scene.id, { duration })} + onDuplicate={() => duplicateScene(scene.id)} + onDelete={() => deleteScene(scene.id)} + /> + ))} + + - + {!fixed && ( + <> + + + + + )} ((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 ): Scene | undefined {