'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 { ChevronLeft, ChevronRight } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import { useBrand, useIsDesktop } from '@/lib/brand/brand-context'; import { useStageStore } from '@/lib/store'; import { useSettingsStore } from '@/lib/store/settings'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useDeletedSceneRecycle } from '@/lib/edit/deleted-scene-recycle'; import { duplicateSlideScene } from '@/lib/edit/slide-defaults'; import { createBlankEditableScene, insertSceneAtIndex, type EditableSceneType, } from '@/lib/edit/scene-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 { useInWorkbenchPanel } from '@/lib/workbench/panel-context'; import type { Scene } from '@/lib/types/stage'; import { ThumbItem } from './ThumbItem'; import { InsertionZone } from './InsertionZone'; // Collapsed, the rail is a slim edge handle — just wide enough to hold the // expand chevron — rather than a narrow column of page numbers. The point of // collapsing is to give the horizontal space back to the canvas, so it keeps // almost none; the handle is the only thing that survives, so the rail can be // brought back. const RAIL_HANDLE_PX = 16; const RAIL_MIN_PX = 180; const RAIL_MAX_PX = 360; /** * 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 chevron on the rail/slide boundary. 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 brand = useBrand(); const isDesktop = useIsDesktop(); const inWorkbenchPanel = useInWorkbenchPanel(); 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 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 — Stage will auto-exit Pro // mode the moment the new scene is uneditable. setCurrentSceneId(sceneId); }, [currentSceneId, setCurrentSceneId], ); const handleInsertAt = useCallback( (insertIndex: number, type: EditableSceneType) => { if (!stage) return; const title = type === 'slide' ? t('edit.nav.untitledSlide') : t('edit.sceneType.quiz'); const scene = createBlankEditableScene(type, stage.id, title, insertIndex + 1); setScenes(insertSceneAtIndex(scenes, scene, insertIndex)); setCurrentSceneId(scene.id); }, [scenes, setCurrentSceneId, setScenes, 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) 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); useDeletedSceneRecycle.getState().capture(source, index); deleteScene(sceneId); toast(t('edit.nav.deleted'), { description: source.title, duration: 5000, action: { label: t('edit.nav.undo'), onClick: () => { const entry = useDeletedSceneRecycle.getState().consume(); if (!entry) return; // Stage-scope guard: if the user has navigated to a // different stage while the toast was up, the recycle // entry belongs to the previous stage and `insertSceneAfter` // would reject it on stage-id mismatch (silently losing the // deleted scene). Drop the undo when stages don't match // rather than blasting the entry into the wrong deck. const currentStage = useStageStore.getState().stage; if (!currentStage || currentStage.id !== entry.stageId) return; 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 (entry.index === 0 || live.length === 0) { useStageStore.getState().setScenes([entry.scene, ...live]); useStageStore.getState().setCurrentSceneId(entry.scene.id); return; } const anchorIndex = Math.min(entry.index - 1, live.length - 1); const anchor = live[anchorIndex]; useStageStore.getState().insertSceneAfter(anchor.id, entry.scene); useStageStore.getState().setCurrentSceneId(entry.scene.id); }, }, onDismiss: () => useDeletedSceneRecycle.getState().clear(), onAutoClose: () => useDeletedSceneRecycle.getState().clear(), }); }, [deleteScene, scenes, slideCount, 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 ( ); }