import { Button, Center, VTooltip } from "@vertesia/ui/core"; import clsx from "clsx"; import { ChevronsDown, ChevronsUp, Image, Loader2, Maximize, Minus, Plus, ScanSearch } from "lucide-react"; import { useRef, KeyboardEvent, useState, useEffect, useCallback } from "react"; import { useUITranslation } from '../../i18n/index.js'; import { ImageType, useMagicPdfContext } from "./MagicPdfProvider"; // Zoom levels as percentages (100 = fit to width) const ZOOM_LEVELS = [50, 75, 100, 125, 150, 200, 300]; const DEFAULT_ZOOM = 100; // Default aspect ratio (letter size) used before first image loads const DEFAULT_ASPECT_RATIO = 11 / 8.5; // height / width // Generate page order radiating outward from current page // e.g., if current=5 and total=10: [5, 6, 4, 7, 3, 8, 2, 9, 1, 10] function getPageLoadOrder(currentPage: number, totalPages: number): number[] { const order: number[] = [currentPage]; let offset = 1; while (order.length < totalPages) { const next = currentPage + offset; const prev = currentPage - offset; if (next <= totalPages) order.push(next); if (prev >= 1) order.push(prev); offset++; } return order; } interface AnnotatedImageSliderProps { currentPage: number; onChange: (pageNumber: number) => void; className?: string; } /** * Image-based page slider that displays annotated/instrumented page images. * Progressively loads images starting from current page and radiating outward. * Loads first image immediately to determine aspect ratio for stable layout. */ export function AnnotatedImageSlider({ className, currentPage, onChange }: AnnotatedImageSliderProps) { const { t } = useUITranslation(); const [imageType, setImageType] = useState(ImageType.instrumented); const [aspectRatio, setAspectRatio] = useState(DEFAULT_ASPECT_RATIO); const [loadedUrls, setLoadedUrls] = useState>(new Map()); const [zoom, setZoom] = useState(DEFAULT_ZOOM); const loadedPagesRef = useRef>(new Set()); const ref = useRef(null); const scrollContainerRef = useRef(null); const isProgrammaticScrollRef = useRef(false); const { imageProvider, count } = useMagicPdfContext(); const zoomIn = useCallback(() => { let currentIndex = ZOOM_LEVELS.findIndex(level => level >= zoom); if (currentIndex === -1) { currentIndex = ZOOM_LEVELS.length - 1; } const nextIndex = Math.min(currentIndex + 1, ZOOM_LEVELS.length - 1); setZoom(ZOOM_LEVELS[nextIndex]); }, [zoom]); const zoomOut = useCallback(() => { let currentIndex = ZOOM_LEVELS.findIndex(level => level >= zoom); if (currentIndex === -1) { currentIndex = ZOOM_LEVELS.length - 1; } const prevIndex = Math.max(currentIndex - 1, 0); setZoom(ZOOM_LEVELS[prevIndex]); }, [zoom]); const fitToView = useCallback(() => { setZoom(DEFAULT_ZOOM); }, []); // Load first image to determine aspect ratio useEffect(() => { imageProvider.getPageImageUrl(1, imageType) .then((url) => { const img = new window.Image(); img.onload = () => { if (img.width > 0 && img.height > 0) { setAspectRatio(img.height / img.width); } }; img.src = url; }) .catch(() => { // Keep default aspect ratio on error }); }, [imageProvider, imageType]); // Track the current imageType for change detection const prevImageTypeRef = useRef(imageType); // Progressive loading: load pages in parallel, prioritized from current page outward useEffect(() => { let cancelled = false; // Check if imageType changed - if so, reset and reload all pages const imageTypeChanged = prevImageTypeRef.current !== imageType; if (imageTypeChanged) { prevImageTypeRef.current = imageType; loadedPagesRef.current = new Set(); setLoadedUrls(new Map()); } const loadOrder = getPageLoadOrder(currentPage, count); // Load all pages in parallel, but they're already prioritized by loadOrder // The imageProvider handles deduplication via its pending map const loadPage = async (page: number) => { if (cancelled || loadedPagesRef.current.has(page)) return; try { const url = await imageProvider.getPageImageUrl(page, imageType); if (!cancelled) { loadedPagesRef.current.add(page); setLoadedUrls(prev => new Map(prev).set(page, url)); } } catch { // Skip failed pages } }; // Start all loads in parallel - prioritized pages will update state first // since they're fetched first in the loadOrder loadOrder.forEach(page => loadPage(page)); return () => { cancelled = true; }; }, [currentPage, count, imageType, imageProvider]); // Scroll to current page when zoom changes to preserve position const prevZoomRef = useRef(zoom); useEffect(() => { if (prevZoomRef.current !== zoom && scrollContainerRef.current) { prevZoomRef.current = zoom; // Mark as programmatic scroll to avoid triggering onChange isProgrammaticScrollRef.current = true; const thumbnail = scrollContainerRef.current.querySelector(`[data-page="${currentPage}"]`); if (thumbnail) { // Use requestAnimationFrame to wait for DOM to update with new sizes requestAnimationFrame(() => { thumbnail.scrollIntoView({ behavior: 'instant', block: 'center', }); // Reset after scroll completes requestAnimationFrame(() => { isProgrammaticScrollRef.current = false; }); }); } else { isProgrammaticScrollRef.current = false; } } }, [zoom, currentPage]); // Jump to current page when it changes (user navigation via buttons/input) const prevPageRef = useRef(currentPage); useEffect(() => { if (prevPageRef.current !== currentPage && scrollContainerRef.current) { prevPageRef.current = currentPage; // Mark as programmatic scroll to avoid triggering onChange isProgrammaticScrollRef.current = true; const thumbnail = scrollContainerRef.current.querySelector(`[data-page="${currentPage}"]`); if (thumbnail) { thumbnail.scrollIntoView({ behavior: 'instant', block: 'nearest', }); } // Reset after a short delay to allow scroll event to fire requestAnimationFrame(() => { isProgrammaticScrollRef.current = false; }); } }, [currentPage]); // Update current page based on scroll position (when user scrolls manually) useEffect(() => { const container = scrollContainerRef.current; if (!container) return; let scrollDebounceTimer: ReturnType | null = null; const handleScroll = () => { // Skip if this is a programmatic scroll if (isProgrammaticScrollRef.current) return; // Debounce scroll updates if (scrollDebounceTimer) clearTimeout(scrollDebounceTimer); scrollDebounceTimer = setTimeout(() => { // Find the page element closest to the center of the viewport const containerRect = container.getBoundingClientRect(); const containerCenter = containerRect.top + containerRect.height / 2; let closestPage = currentPage; let closestDistance = Infinity; for (let i = 1; i <= count; i++) { const pageEl = container.querySelector(`[data-page="${i}"]`); if (pageEl) { const pageRect = pageEl.getBoundingClientRect(); const pageCenter = pageRect.top + pageRect.height / 2; const distance = Math.abs(pageCenter - containerCenter); if (distance < closestDistance) { closestDistance = distance; closestPage = i; } } } if (closestPage !== currentPage) { prevPageRef.current = closestPage; onChange(closestPage); } }, 50); }; container.addEventListener('scroll', handleScroll, { passive: true }); return () => { if (scrollDebounceTimer) clearTimeout(scrollDebounceTimer); container.removeEventListener('scroll', handleScroll); }; }, [count, currentPage, onChange]); const goPrev = () => { if (currentPage > 1) { onChange(currentPage - 1); } }; const goNext = () => { if (currentPage < count) { onChange(currentPage + 1); } }; return (
setImageType(ImageType.original)} icon={} tooltip={t('pdf.originalImages')} /> setImageType(ImageType.instrumented)} icon={} tooltip={t('pdf.instrumentedImages')} />
ZOOM_LEVELS[0]} />
{Array.from({ length: count }, (_, index) => ( onChange(index + 1)} /> ))}
); } interface ImageTypeButtonProps { type: ImageType; currentType: ImageType; onClick: () => void; icon: React.ReactNode; tooltip: string; } function ImageTypeButton({ type, currentType, onClick, icon, tooltip }: ImageTypeButtonProps) { const isSelected = type === currentType; return ( ); } interface ZoomControlsProps { zoom: number; onZoomIn: () => void; onZoomOut: () => void; onFitToView: () => void; canZoomIn: boolean; canZoomOut: boolean; } function ZoomControls({ zoom, onZoomIn, onZoomOut, onFitToView, canZoomIn, canZoomOut }: ZoomControlsProps) { const { t } = useUITranslation(); return (
{zoom}%
); } interface PageThumbnailProps { pageNumber: number; currentPage: number; aspectRatio: number; zoom: number; url?: string; onSelect: () => void; } function PageThumbnail({ pageNumber, currentPage, aspectRatio, zoom, url, onSelect }: PageThumbnailProps) { const isSelected = pageNumber === currentPage; const widthPercent = zoom; return (
{url ? ( {`Page ) : ( )}
{pageNumber}
); } interface PageNavigatorProps { currentPage: number; totalPages: number; onChange: (page: number) => void; } function PageNavigator({ currentPage, totalPages, onChange }: PageNavigatorProps) { const { t } = useUITranslation(); const inputRef = useRef(null); const [inputValue, setInputValue] = useState(String(currentPage)); // Sync input value when currentPage changes externally useEffect(() => { setInputValue(String(currentPage)); }, [currentPage]); const handleSubmit = () => { const page = parseInt(inputValue, 10); if (!isNaN(page) && page >= 1 && page <= totalPages) { onChange(page); } else { // Reset to current page if invalid setInputValue(String(currentPage)); } }; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter') { handleSubmit(); inputRef.current?.blur(); } else if (e.key === 'Escape') { setInputValue(String(currentPage)); inputRef.current?.blur(); } }; const handleBlur = () => { handleSubmit(); }; return (
{t('pdf.page')} setInputValue(e.target.value)} onKeyDown={handleKeyDown} onBlur={handleBlur} className="w-8 h-5 text-center text-xs px-1 py-0 bg-background border border-border rounded focus:outline-none focus:border-primary" /> / {totalPages}
); }