'use client'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { AnimatePresence, Reorder, motion, useReducedMotion } from 'motion/react'; import { PanelLeftClose, PanelLeftOpen } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import { markStagePersistenceDirty, useStageStore } from '@/lib/store/stage'; import { useSettingsStore } from '@/lib/store/settings'; import { useI18n } from '@/lib/hooks/use-i18n'; import { collectStageAssetRefs } from '@/lib/media/collect-stage-asset-refs'; import { createBlankSlideScene, duplicateSlideScene } from '@/lib/edit/slide-defaults'; import { SCENE_CREATION_ENABLED } from '@/lib/edit/scene-creation-enabled'; import { CHROME_DURATION_MS, CHROME_EASE, CHROME_EASE_CSS } from '@/lib/edit/transitions'; import type { Scene } from '@/lib/types/stage'; import { ThumbItem } from './ThumbItem'; import { InsertionZone } from './InsertionZone'; import { opsApiPath } from '@/lib/ops/api-fetch'; const RAIL_COLLAPSED_PX = 56; const RAIL_MIN_PX = 180; const RAIL_MAX_PX = 360; const DELETED_SCENE_UNDO_MS = 5000; /** * Pro mode slide-navigation left rail (Studio Editor aesthetic). * * Layout: a vertical thumbnail strip with monospaced index captions * below each tile, inter-thumb "+" insertion zones revealed on hover, * and a collapse toggle at the rail head. All scene types are * first-class — slides render a live `ThumbnailSlide`, non-slide scenes * get a type-icon stub but stay clickable, draggable, and right-clickable * so page-level management is uniform across the deck. * * Visuals: low-chroma zinc surface + single violet brand accent, no * per-row chrome (rejected `EditModeSidebar` pattern). Drag uses an * explicit grip handle on the thumb so the whole tile remains * click-to-switch. */ export function SlideNavRail() { const { t } = useI18n(); const router = useRouter(); const scenes = useStageStore.use.scenes(); const currentSceneId = useStageStore.use.currentSceneId(); const setCurrentSceneId = useStageStore.use.setCurrentSceneId(); const setScenes = useStageStore.use.setScenes(); const insertSceneAfter = useStageStore.use.insertSceneAfter(); const deleteScene = useStageStore.use.deleteScene(); const stage = useStageStore.use.stage(); const collapsed = useSettingsStore((s) => s.editRailCollapsed); const setCollapsed = useSettingsStore((s) => s.setEditRailCollapsed); const persistedWidth = useSettingsStore((s) => s.editRailWidth); const setPersistedWidth = useSettingsStore((s) => s.setEditRailWidth); const prefersReducedMotion = useReducedMotion(); // Drag-to-resize. // // We mutate the rail's `style.width` directly on the DOM during pointer // move (bypassing React entirely) and only commit the final width to the // settings store on pointer-up. This is what makes the handle feel glued // to the cursor: there's no React render → reconcile → DOM commit // latency between move events and the visible width change. // // Pointer Events (with `setPointerCapture` on the handle) replace the // older `document` mousemove/mouseup binding. With capture, the handle // receives `pointerup` / `pointercancel` even if the cursor leaves the // window, the OS reclaims focus, or a tab switch interrupts the gesture // — none of which fire `document` mouseup, which previously left the // rail stuck in a "drag is still in progress" state until remount. // // `isDragging` is still React state so we can turn off the CSS // `transition: width` for the duration of the gesture — otherwise the // 280ms tween from the collapse/expand animation would fight every // direct width write. const railRef = useRef(null); const dragStateRef = useRef<{ startX: number; startWidth: number; lastWidth: number; pointerId: number; } | null>(null); const [isDragging, setIsDragging] = useState(false); const cleanupDrag = useCallback(() => { dragStateRef.current = null; document.body.style.cursor = ''; document.body.style.userSelect = ''; setIsDragging(false); }, []); const handleResizeStart = useCallback( (e: React.PointerEvent) => { if (collapsed) return; // Only primary button; ignore right-click / middle-click. if (e.button !== 0) return; e.preventDefault(); const target = e.currentTarget; // Pointer capture guarantees this element receives pointermove / // pointerup / pointercancel for the duration of the gesture, even // when the cursor leaves the window. try { target.setPointerCapture(e.pointerId); } catch { // Spec-wise `setPointerCapture` can only throw `InvalidPointerId`, // which shouldn't happen inside the same pointer's `pointerdown`. // This catch is paranoia, NOT a real fallback: if capture // genuinely fails the gesture still tracks for in-window moves // but `pointerup` outside the handle's bbox won't route here and // the rail will stay in `isDragging` until SlideNavRail // unmounts. The pointermove path remains useful so dropping the // throw on the floor is preferable to bailing the gesture. } dragStateRef.current = { startX: e.clientX, startWidth: persistedWidth, lastWidth: persistedWidth, pointerId: e.pointerId, }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; setIsDragging(true); }, [collapsed, persistedWidth], ); const handleResizeMove = useCallback((e: React.PointerEvent) => { const drag = dragStateRef.current; if (!drag || e.pointerId !== drag.pointerId) return; const delta = e.clientX - drag.startX; const next = Math.min(RAIL_MAX_PX, Math.max(RAIL_MIN_PX, drag.startWidth + delta)); drag.lastWidth = next; if (railRef.current) railRef.current.style.width = `${next}px`; }, []); const handleResizeEnd = useCallback( (e: React.PointerEvent) => { const drag = dragStateRef.current; if (!drag || e.pointerId !== drag.pointerId) return; try { e.currentTarget.releasePointerCapture(e.pointerId); } catch { // Capture may already have been released by a pointercancel. } // Commit final width to persisted settings exactly once per gesture. // React will re-render with `style.width = persistedWidth`, which // matches the DOM value we already wrote — no visual jump. setPersistedWidth(drag.lastWidth); cleanupDrag(); }, [cleanupDrag, setPersistedWidth], ); useEffect( () => () => { // Belt and suspenders: clear any document-level overrides on unmount. document.body.style.cursor = ''; document.body.style.userSelect = ''; }, [], ); const slideCount = useMemo(() => scenes.filter((s) => s.type === 'slide').length, [scenes]); // For non-slide scenes (no recreate path), only allow delete if there's // more than one scene overall — otherwise the deck would become empty. const totalScenes = scenes.length; const currentScene = useMemo( () => scenes.find((s) => s.id === currentSceneId) ?? null, [scenes, currentSceneId], ); const onReorderIds = useCallback( (newOrder: string[]) => { const byId = new Map(scenes.map((s) => [s.id, s] as const)); const next: Scene[] = newOrder .map((id) => byId.get(id)) .filter((s): s is Scene => Boolean(s)); if (next.length !== scenes.length) return; const rebalanced = next.map((s, i) => (s.order === i + 1 ? s : { ...s, order: i + 1 })); setScenes(rebalanced); }, [scenes, setScenes], ); const handleActivate = useCallback( (sceneId: string) => { if (sceneId === currentSceneId) return; // Switching to a non-slide scene is fine — useEditModeLock will // auto-exit Pro mode the moment the new scene is uneditable. setCurrentSceneId(sceneId); }, [currentSceneId, setCurrentSceneId], ); /** * Insert a fresh blank slide *before* the given scene. The first * InsertionZone (above the first thumb) calls this with `scenes[0]` * so it ends up at index 0 — `setScenes([blank, ...scenes])` is * used directly there since the `insertSceneAfter` API only supports * insertion after an existing anchor. */ const handleInsertBefore = useCallback( (beforeSceneId: string) => { if (!stage) return; const beforeIndex = scenes.findIndex((s) => s.id === beforeSceneId); if (beforeIndex < 0) return; const blank = createBlankSlideScene(stage.id, t('edit.nav.untitledSlide'), beforeIndex + 1); if (beforeIndex === 0) { // Prepend: setScenes rebalances `order` to match the array index. setScenes([blank, ...scenes]); setCurrentSceneId(blank.id); return; } const anchor = scenes[beforeIndex - 1]; insertSceneAfter(anchor.id, blank); setCurrentSceneId(blank.id); }, [insertSceneAfter, scenes, setCurrentSceneId, setScenes, stage, t], ); const handleInsertAt = useCallback( (afterSceneId: string | null) => { if (!stage) return; const anchor = afterSceneId ? scenes.find((s) => s.id === afterSceneId) : (currentScene ?? scenes[scenes.length - 1]); if (!anchor) return; const anchorIndex = scenes.findIndex((s) => s.id === anchor.id); const newOrder = anchorIndex + 2; const blank = createBlankSlideScene(stage.id, t('edit.nav.untitledSlide'), newOrder); insertSceneAfter(anchor.id, blank); setCurrentSceneId(blank.id); }, [currentScene, insertSceneAfter, scenes, setCurrentSceneId, stage, t], ); const handleDuplicate = useCallback( (sceneId: string) => { const source = scenes.find((s) => s.id === sceneId); if (!source) return; const anchorIndex = scenes.findIndex((s) => s.id === sceneId); const newOrder = anchorIndex + 2; // Slide scenes get a deep clone with reseeded element IDs; non-slide // scenes just get a shallow id + title bump. const copy: Scene = source.type === 'slide' ? duplicateSlideScene(source, t('edit.nav.copySuffix'), newOrder) : { ...source, id: crypto.randomUUID(), title: `${source.title} ${t('edit.nav.copySuffix')}`, order: newOrder, createdAt: Date.now(), updatedAt: Date.now(), }; insertSceneAfter(sceneId, copy); setCurrentSceneId(copy.id); }, [insertSceneAfter, scenes, setCurrentSceneId, t], ); const handleDelete = useCallback( (sceneId: string) => { const source = scenes.find((s) => s.id === sceneId); if (!source || !stage) return; // Hold deck-empty guard at the rail layer; the store doesn't enforce. if (source.type === 'slide' && slideCount <= 1) return; if (totalScenes <= 1) return; const index = scenes.findIndex((s) => s.id === sceneId); const sourceRefs = collectStageAssetRefs( { stage: { ...stage, videoManifest: undefined }, scenes: [source] }, { mediaRows: [], audioRows: [] }, ).referenced; const manifestEntries = Object.fromEntries( Object.entries(stage.videoManifest ?? {}).filter(([ref]) => sourceRefs.has(ref)), ); deleteScene(sceneId); toast(t('edit.nav.deleted'), { description: source.title, duration: DELETED_SCENE_UNDO_MS, action: { label: t('edit.nav.undo'), onClick: () => { // Stage-scope guard: if the user has navigated to a // different stage while the toast was up, the deleted scene // belongs to the previous stage. Drop the undo rather than // inserting it into the wrong deck. const currentStage = useStageStore.getState().stage; if (!currentStage || currentStage.id !== source.stageId) return; if (Object.keys(manifestEntries).length > 0) { useStageStore.setState({ stage: { ...currentStage, videoManifest: { ...currentStage.videoManifest, ...manifestEntries, }, }, }); markStagePersistenceDirty([{ kind: 'stage' }]); } const live = useStageStore.getState().scenes; // Prepend path — `insertSceneAfter` requires an anchor, but // restoring index 0 (the previously-first slide) has no // predecessor to anchor on. Clamping `entry.index - 1` to 0 // and inserting after `live[0]` would land the entry at // position 1 instead of 0. setScenes-with-rebalance // preserves the original "first slide" semantics. if (index === 0 || live.length === 0) { useStageStore.getState().setScenes([source, ...live]); useStageStore.getState().setCurrentSceneId(source.id); return; } const anchorIndex = Math.min(index - 1, live.length - 1); const anchor = live[anchorIndex]; useStageStore.getState().insertSceneAfter(anchor.id, source); useStageStore.getState().setCurrentSceneId(source.id); }, }, }); }, [deleteScene, scenes, slideCount, stage, totalScenes, t], ); const canDeleteAny = totalScenes > 1; const canDeleteSlide = slideCount > 1; // Plain CSS transition mirrors playback `SceneSidebar` exactly: zero // motion.dev overhead, instant width updates while dragging. The earlier // `motion.aside animate={false}` still ran motion's element-tracking // pipeline per frame even with animation off, which produced the // perceptible drag lag the user reported. const widthTransitionCss = isDragging ? 'none' : prefersReducedMotion ? 'none' : `width ${CHROME_DURATION_MS}ms ${CHROME_EASE_CSS}`; return ( ); } interface CollapsedListProps { readonly scenes: readonly Scene[]; readonly currentSceneId: string | null; readonly onActivate: (sceneId: string) => void; } function CollapsedList({ scenes, currentSceneId, onActivate }: CollapsedListProps) { return (
    {scenes.map((scene, index) => { const active = scene.id === currentSceneId; const isSlide = scene.type === 'slide'; return (
  1. ); })}
); }