/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { useCallback, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'; import { ChevronLeft, ChevronRight, Equal, Eye, EyeOff, GripVertical, Minus, Pencil, Play, Plus, RotateCcw, Save, Square, Timer, Trash2, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; import { useViewerStore } from '@/store'; import { useDraggablePanel } from '@/hooks/useDraggablePanel'; import { executeBasketSet, executeBasketAdd, executeBasketRemove, executeBasketSaveView, executeBasketClear, } from '@/store/basket/basketCommands'; import { getSmartBasketInputFromStore, isBasketIsolationActiveFromStore } from '@/store/basketVisibleSet'; export function BasketPresentationDock() { const [savingThumbnail, setSavingThumbnail] = useState(false); const [editingViewId, setEditingViewId] = useState(null); const [editingName, setEditingName] = useState(''); const [playingAll, setPlayingAll] = useState(false); const stripRef = useRef(null); const stopPlayRef = useRef(false); const loopPlayRef = useRef(false); // Drag-to-move + width resize for the presentation dock (issue #1107). const panelRef = useRef(null); const drag = useDraggablePanel(panelRef); const [panelWidth, setPanelWidth] = useState(null); const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null); const handleResizeStart = useCallback((e: ReactMouseEvent) => { e.preventDefault(); e.stopPropagation(); resizeRef.current = { startX: e.clientX, startWidth: panelRef.current?.offsetWidth ?? 980 }; const onMove = (ev: MouseEvent) => { if (!resizeRef.current) return; // While still centred (no drag yet), the panel grows from both edges, so // the right edge tracks the cursor at half speed — double the delta so it // follows. Once moved (top/left anchored) it grows rightward 1:1. const factor = drag.position === null ? 2 : 1; const dx = (ev.clientX - resizeRef.current.startX) * factor; // Clamp against the offset parent (the viewport panel, ~58% of the window), // not the window — otherwise dragging past the visual cap keeps inflating // width invisibly and you have to drag back through the overshoot to shrink. const parentW = (panelRef.current?.offsetParent as HTMLElement | null)?.clientWidth ?? window.innerWidth; const next = Math.max(480, Math.min(parentW - 32, resizeRef.current.startWidth + dx)); setPanelWidth(next); }; const onUp = () => { resizeRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }, [drag.position]); const pinboardEntities = useViewerStore((s) => s.pinboardEntities); const isolatedEntities = useViewerStore((s) => s.isolatedEntities); const basketViews = useViewerStore((s) => s.basketViews); const activeBasketViewId = useViewerStore((s) => s.activeBasketViewId); const basketPresentationVisible = useViewerStore((s) => s.basketPresentationVisible); const isMobile = useViewerStore((s) => s.isMobile); const showPinboard = useViewerStore((s) => s.showPinboard); const clearIsolation = useViewerStore((s) => s.clearIsolation); const setBasketPresentationVisible = useViewerStore((s) => s.setBasketPresentationVisible); const removeBasketView = useViewerStore((s) => s.removeBasketView); const renameBasketView = useViewerStore((s) => s.renameBasketView); const setBasketViewTransitionMs = useViewerStore((s) => s.setBasketViewTransitionMs); const basketIsVisible = useMemo( () => pinboardEntities.size > 0 && isolatedEntities !== null && isBasketIsolationActiveFromStore(), [pinboardEntities, isolatedEntities], ); const applySource = useCallback((mode: 'set' | 'add' | 'remove') => { if (mode === 'set') executeBasketSet(); else if (mode === 'add') executeBasketAdd(); else executeBasketRemove(); }, []); const handleSaveCurrent = useCallback(async () => { if (pinboardEntities.size === 0 || savingThumbnail) return; setSavingThumbnail(true); try { const { source } = getSmartBasketInputFromStore(); await executeBasketSaveView(source === 'empty' ? 'manual' : source); } finally { setSavingThumbnail(false); } }, [pinboardEntities, savingThumbnail]); const startRename = useCallback((viewId: string, name: string) => { setEditingViewId(viewId); setEditingName(name); }, []); const cancelRename = useCallback(() => { setEditingViewId(null); setEditingName(''); }, []); const commitRename = useCallback(() => { if (!editingViewId) return; const nextName = editingName.trim(); if (nextName.length > 0) { renameBasketView(editingViewId, nextName); } setEditingViewId(null); setEditingName(''); }, [editingViewId, editingName, renameBasketView]); const scrollStrip = useCallback((delta: number) => { stripRef.current?.scrollBy({ left: delta, behavior: 'smooth' }); }, []); const toTransitionMs = useCallback((value: number | null | undefined) => { if (!value || !Number.isFinite(value) || value <= 0) return 700; return Math.max(150, Math.min(15000, Math.round(value))); }, []); const wait = useCallback((ms: number) => new Promise((resolve) => { window.setTimeout(resolve, ms); }), []); const stopPlayAll = useCallback(() => { stopPlayRef.current = true; loopPlayRef.current = false; setPlayingAll(false); }, []); const activateSavedView = useCallback(async (viewId: string) => { const { activateBasketViewFromStore } = await import('@/store/basket/basketViewActivator'); activateBasketViewFromStore(viewId); }, []); const startPlayAll = useCallback(async (loop = false) => { if (playingAll || basketViews.length === 0) return; stopPlayRef.current = false; loopPlayRef.current = loop; setPlayingAll(true); try { const orderedViews = [...basketViews]; do { for (const view of orderedViews) { if (stopPlayRef.current) break; await activateSavedView(view.id); const transitionMs = toTransitionMs(view.transitionMs); await wait(transitionMs + 180); } } while (loopPlayRef.current && !stopPlayRef.current && orderedViews.length > 0); } finally { loopPlayRef.current = false; setPlayingAll(false); } }, [activateSavedView, basketViews, playingAll, toTransitionMs, wait]); const setViewTransitionDuration = useCallback((viewId: string, currentTransitionMs: number | null) => { const defaultSeconds = currentTransitionMs && currentTransitionMs > 0 ? (currentTransitionMs / 1000).toFixed(1) : ''; const input = window.prompt( 'Transition duration in seconds (optional). Leave empty for default smooth transition.', defaultSeconds, ); if (input === null) return; const trimmed = input.trim(); if (!trimmed) { setBasketViewTransitionMs(viewId, null); return; } const seconds = Number(trimmed); if (!Number.isFinite(seconds) || seconds <= 0) return; setBasketViewTransitionMs(viewId, Math.round(seconds * 1000)); }, [setBasketViewTransitionMs]); if (isMobile) return null; if (!basketPresentationVisible) { return (
); } return (
Presentation
{pinboardEntities.size} in basket {basketViews.length} views
{basketViews.length === 0 && (
Save basket views here. Click any card to restore both visibility and viewpoint.
)} {basketViews.map((view) => (
e.stopPropagation()} > {editingViewId === view.id ? ( setEditingName(e.target.value)} onBlur={commitRename} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitRename(); } else if (e.key === 'Escape') { e.preventDefault(); cancelRename(); } }} className="h-6 bg-black/40 text-xs border-white/30 text-white placeholder:text-white/60" /> ) : ( <>
{view.name}
{view.entityRefs.length} objects {view.transitionMs ? ` · ${(view.transitionMs / 1000).toFixed(1)}s` : ''}
)}
))}
{/* Width resize handle on the right edge (issue #1107). */}
); }