@nyazkhan/react-pdf-viewer
Version:
A comprehensive React TypeScript component library for viewing and interacting with PDF files using Mozilla PDF.js. Features include text selection, highlighting, search, sidebar, multiple view modes, and complete PDF.js web viewer functionality.
1 lines âĸ 145 kB
Source Map (JSON)
{"version":3,"sources":["../src/lib/PDFViewer.tsx","../src/lib/PDFToolbar.tsx","../src/lib/PDFSidebar.tsx","../src/lib/PDFPage.tsx","../src/lib/PDFHighlight.tsx","../src/lib/PDFLoader.ts","../src/lib/useKeyboardShortcuts.ts","../src/lib/utils.ts"],"sourcesContent":["import React, { useEffect, useRef, useState, useCallback } from 'react'\nimport * as pdfjsLib from 'pdfjs-dist'\nimport type { \n PDFViewerProps, \n PDFDocumentProxy, \n PDFLoadingTask,\n ToolbarTool,\n ViewMode,\n SidebarView,\n ZoomMode,\n PDFOutlineItem,\n PDFAttachment,\n PDFDocumentInfo\n} from './types'\nimport { PDFToolbar } from './PDFToolbar'\nimport { PDFSidebar } from './PDFSidebar'\nimport { PDFPage } from './PDFPage'\nimport { PDFLoader } from './PDFLoader'\nimport { useKeyboardShortcuts } from './useKeyboardShortcuts'\nimport './worker' // Auto-configure worker on import\n\nconst PDFViewerComponent: React.FC<PDFViewerProps> = ({\n file,\n page = 1,\n scale = 1.0,\n rotation = 0,\n viewMode = 'single',\n sidebarView = 'none',\n zoomMode = 'auto',\n activeTool = 'none',\n enableKeyboardShortcuts = true,\n onDocumentLoad,\n onPageChange,\n onScaleChange,\n onRotationChange,\n onViewModeChange,\n onSidebarToggle,\n onSidebarViewChange,\n onToolChange,\n onError,\n onSearch,\n onPrint,\n onDownload,\n onOpenFile,\n onPresentationMode,\n onDocumentInfo,\n className = '',\n style,\n enableTextSelection = true,\n highlights = [],\n renderToolbar = true,\n renderSidebar = true,\n customToolbar,\n width = '100%',\n height = '600px',\n enableAnnotations = true,\n enableForms = true,\n enableSearch = true,\n enableThumbnails = true,\n showPageControls = true,\n showZoomControls = true,\n showRotateControls = true,\n showViewModeControls = true,\n showOpenOption = true,\n showSearchOption = true,\n showPrintOption = true,\n showDownloadOption = true,\n showToolSelection = true,\n showFitOptions = true,\n showPresentationMode = true,\n}) => {\n // Core PDF state\n const [pdf, setPdf] = useState<PDFDocumentProxy | null>(null)\n const [currentPage, setCurrentPage] = useState(page)\n const [currentScale, setCurrentScale] = useState(scale)\n const [currentRotation, setCurrentRotation] = useState(rotation)\n const [currentViewMode, setCurrentViewMode] = useState<ViewMode>(viewMode)\n const [currentZoomMode, setCurrentZoomMode] = useState<ZoomMode>(zoomMode)\n const [currentTool, setCurrentTool] = useState<ToolbarTool>(activeTool)\n const [numPages, setNumPages] = useState(0)\n const [loading, setLoading] = useState(false)\n const [error, setError] = useState<string | null>(null)\n \n // Sidebar state\n const [sidebarOpen, setSidebarOpen] = useState(sidebarView !== 'none')\n const [currentSidebarView, setCurrentSidebarView] = useState<SidebarView>(sidebarView)\n const [outline, setOutline] = useState<PDFOutlineItem[]>([])\n const [attachments, setAttachments] = useState<PDFAttachment[]>([])\n const [documentInfo, setDocumentInfo] = useState<PDFDocumentInfo | null>(null)\n \n // Search state\n const [searchTerm, setSearchTerm] = useState('')\n const [searchResults, setSearchResults] = useState(0)\n const [currentSearchResult, setCurrentSearchResult] = useState(0)\n \n // Presentation mode state\n const [presentationMode, setPresentationMode] = useState(false)\n \n const loadingTaskRef = useRef<PDFLoadingTask | null>(null)\n const containerRef = useRef<HTMLDivElement>(null)\n const currentFileRef = useRef<string | File | ArrayBuffer | Uint8Array | null>(null)\n const loadingAbortControllerRef = useRef<AbortController | null>(null)\n\n // Clean up function\n const cleanup = useCallback(() => {\n // Cancel any ongoing loading\n if (loadingAbortControllerRef.current) {\n loadingAbortControllerRef.current.abort()\n loadingAbortControllerRef.current = null\n }\n \n if (loadingTaskRef.current) {\n try {\n loadingTaskRef.current.destroy()\n } catch (error) {\n console.warn('Error destroying loading task:', error)\n }\n loadingTaskRef.current = null\n }\n if (pdf) {\n try {\n pdf.destroy()\n } catch (error) {\n console.warn('Error destroying PDF document:', error)\n }\n }\n }, [pdf])\n\n // Stable file key reference\n const fileKeyRef = useRef<string>('')\n const lastSuccessfulFileRef = useRef<string>('')\n\n // Generate stable file key\n const getFileKey = useCallback((file: string | File | ArrayBuffer | Uint8Array) => {\n if (typeof file === 'string') return file\n if (file instanceof File) return `${file.name}-${file.size}-${file.lastModified}`\n return `buffer-${file.byteLength}`\n }, [])\n\n // Load document metadata\n const loadDocumentMetadata = useCallback(async (pdfDoc: PDFDocumentProxy) => {\n try {\n // Load document info\n const info = await pdfDoc.getMetadata()\n setDocumentInfo(info.info as PDFDocumentInfo)\n \n // Load outline\n try {\n const outlineData = await pdfDoc.getOutline()\n if (outlineData) {\n // Convert PDF.js outline format to our format\n const convertOutline = (items: any[]): PDFOutlineItem[] => {\n return items.map(item => ({\n title: item.title || '',\n bold: item.bold || false,\n italic: item.italic || false,\n color: item.color ? [item.color[0], item.color[1], item.color[2]] as [number, number, number] : undefined,\n dest: item.dest,\n url: item.url,\n items: item.items ? convertOutline(item.items) : undefined\n }))\n }\n setOutline(convertOutline(outlineData))\n }\n } catch (error) {\n console.warn('No document outline available:', error)\n setOutline([])\n }\n \n // Load attachments\n try {\n const attachmentData = await pdfDoc.getAttachments()\n if (attachmentData) {\n const attachmentList: PDFAttachment[] = []\n for (const [filename, data] of Object.entries(attachmentData) as [string, any][]) {\n attachmentList.push({\n filename,\n content: data.content\n })\n }\n setAttachments(attachmentList)\n }\n } catch (error) {\n console.warn('No attachments available:', error)\n setAttachments([])\n }\n } catch (error) {\n console.warn('Error loading document metadata:', error)\n }\n }, [])\n\n // Load PDF function with comprehensive error handling\n const loadPDF = useCallback(async () => {\n const fileKey = getFileKey(file)\n \n // Skip if same file is already loaded successfully\n if (fileKey === lastSuccessfulFileRef.current && pdf) {\n console.debug('PDF already loaded for this file, skipping reload')\n return\n }\n \n // Skip if already loading the same file\n if (fileKey === fileKeyRef.current && loading) {\n console.debug('PDF load already in progress for this file, skipping duplicate request')\n return\n }\n \n fileKeyRef.current = fileKey\n \n // Cancel previous loading if it's for a different file\n if (loadingAbortControllerRef.current && fileKey !== fileKeyRef.current) {\n loadingAbortControllerRef.current.abort()\n }\n \n // Create new abort controller for this request\n loadingAbortControllerRef.current = new AbortController()\n \n setLoading(true)\n setError(null)\n \n try {\n cleanup()\n \n const result = await PDFLoader.loadPDF({ file })\n \n // Check if request was aborted\n if (loadingAbortControllerRef.current?.signal.aborted) {\n console.debug('PDF load was cancelled')\n return\n }\n \n if (result.error) {\n throw result.error\n }\n \n if (!result.pdf) {\n throw new Error('Failed to load PDF')\n }\n \n setPdf(result.pdf)\n setNumPages(result.pdf.numPages)\n lastSuccessfulFileRef.current = fileKey\n \n // Load document metadata\n await loadDocumentMetadata(result.pdf)\n \n if (onDocumentLoad) {\n onDocumentLoad(result.pdf)\n }\n \n console.debug(`PDF loaded successfully: ${result.pdf.numPages} pages`)\n } catch (error: any) {\n if (error.name === 'AbortError' || error.message?.includes('aborted')) {\n console.debug('PDF load was cancelled')\n return\n }\n \n const errorMessage = error.message || 'Failed to load PDF'\n console.error('PDF load error:', error)\n setError(errorMessage)\n if (onError) {\n onError(error)\n }\n } finally {\n setLoading(false)\n loadingAbortControllerRef.current = null\n }\n }, [file, getFileKey, loading, pdf, cleanup, loadDocumentMetadata, onDocumentLoad, onError])\n\n // Load PDF with debouncing\n useEffect(() => {\n const timeoutId = setTimeout(() => {\n loadPDF()\n }, 300) // 300ms debounce\n \n return () => clearTimeout(timeoutId)\n }, [loadPDF])\n\n // Sync props with internal state\n useEffect(() => {\n if (page !== currentPage) {\n setCurrentPage(page)\n }\n }, [page, currentPage])\n\n useEffect(() => {\n if (scale !== currentScale) {\n setCurrentScale(scale)\n }\n }, [scale, currentScale])\n\n useEffect(() => {\n if (rotation !== currentRotation) {\n setCurrentRotation(rotation)\n }\n }, [rotation, currentRotation])\n\n useEffect(() => {\n if (viewMode !== currentViewMode) {\n setCurrentViewMode(viewMode)\n }\n }, [viewMode, currentViewMode])\n\n useEffect(() => {\n if (activeTool !== currentTool) {\n setCurrentTool(activeTool)\n }\n }, [activeTool, currentTool])\n\n useEffect(() => {\n if (sidebarView !== currentSidebarView) {\n setCurrentSidebarView(sidebarView)\n setSidebarOpen(sidebarView !== 'none')\n }\n }, [sidebarView, currentSidebarView])\n\n // Navigation handlers\n const handlePageChange = useCallback((newPage: number) => {\n if (newPage >= 1 && newPage <= numPages && newPage !== currentPage) {\n setCurrentPage(newPage)\n if (onPageChange) {\n onPageChange(newPage)\n }\n }\n }, [currentPage, numPages, onPageChange])\n\n const goToNextPage = useCallback(() => {\n if (currentPage < numPages) {\n handlePageChange(currentPage + 1)\n }\n }, [currentPage, numPages, handlePageChange])\n\n const goToPrevPage = useCallback(() => {\n if (currentPage > 1) {\n handlePageChange(currentPage - 1)\n }\n }, [currentPage, handlePageChange])\n\n const goToFirstPage = useCallback(() => {\n handlePageChange(1)\n }, [handlePageChange])\n\n const goToLastPage = useCallback(() => {\n handlePageChange(numPages)\n }, [numPages, handlePageChange])\n\n // Zoom handlers\n const handleScaleChange = useCallback((newScale: number) => {\n if (Math.abs(newScale - currentScale) > 0.01) {\n setCurrentScale(newScale)\n setCurrentZoomMode('auto') // Reset zoom mode when manually changing scale\n if (onScaleChange) {\n onScaleChange(newScale)\n }\n }\n }, [currentScale, onScaleChange])\n\n const zoomIn = useCallback(() => {\n const newScale = Math.min(currentScale * 1.25, 5.0)\n handleScaleChange(newScale)\n }, [currentScale, handleScaleChange])\n\n const zoomOut = useCallback(() => {\n const newScale = Math.max(currentScale / 1.25, 0.25)\n handleScaleChange(newScale)\n }, [currentScale, handleScaleChange])\n\n const handleZoomToFit = useCallback(() => {\n setCurrentZoomMode('page-fit')\n // Calculate fit-to-page scale based on container size\n if (containerRef.current) {\n const containerHeight = containerRef.current.clientHeight - 100 // Account for toolbar\n const containerWidth = containerRef.current.clientWidth - (sidebarOpen ? 250 : 0)\n const scale = Math.min(containerWidth / 612, containerHeight / 792) // Standard page size\n handleScaleChange(scale)\n }\n }, [handleScaleChange, sidebarOpen])\n\n const handleZoomToWidth = useCallback(() => {\n setCurrentZoomMode('page-width')\n // Calculate fit-to-width scale\n if (containerRef.current) {\n const containerWidth = containerRef.current.clientWidth - (sidebarOpen ? 250 : 0) - 40 // Account for padding\n const scale = containerWidth / 612 // Standard page width\n handleScaleChange(scale)\n }\n }, [handleScaleChange, sidebarOpen])\n\n const handleActualSize = useCallback(() => {\n setCurrentZoomMode('actual')\n handleScaleChange(1.0)\n }, [handleScaleChange])\n\n // Zoom mode handlers\n const handleZoomModeChange = useCallback((mode: ZoomMode) => {\n setCurrentZoomMode(mode)\n switch (mode) {\n case 'page-fit':\n handleZoomToFit()\n break\n case 'page-width':\n handleZoomToWidth()\n break\n case 'actual':\n handleActualSize()\n break\n }\n }, [handleZoomToFit, handleZoomToWidth, handleActualSize])\n\n // Rotation handler\n const handleRotate = useCallback(() => {\n const newRotation = (currentRotation + 90) % 360\n setCurrentRotation(newRotation)\n if (onRotationChange) {\n onRotationChange(newRotation)\n }\n }, [currentRotation, onRotationChange])\n\n const handleRotateCounterClockwise = useCallback(() => {\n const newRotation = (currentRotation - 90 + 360) % 360\n setCurrentRotation(newRotation)\n if (onRotationChange) {\n onRotationChange(newRotation)\n }\n }, [currentRotation, onRotationChange])\n\n // View mode handlers\n const handleViewModeChange = useCallback((mode: ViewMode) => {\n setCurrentViewMode(mode)\n if (onViewModeChange) {\n onViewModeChange(mode)\n }\n }, [onViewModeChange])\n\n // Sidebar handlers\n const handleSidebarToggle = useCallback(() => {\n const newOpen = !sidebarOpen\n setSidebarOpen(newOpen)\n if (!newOpen) {\n setCurrentSidebarView('none')\n } else if (currentSidebarView === 'none') {\n setCurrentSidebarView('thumbnails')\n }\n if (onSidebarToggle) {\n onSidebarToggle(newOpen)\n }\n }, [sidebarOpen, currentSidebarView, onSidebarToggle])\n\n const handleSidebarViewChange = useCallback((view: SidebarView) => {\n setCurrentSidebarView(view)\n setSidebarOpen(view !== 'none')\n if (onSidebarViewChange) {\n onSidebarViewChange(view)\n }\n }, [onSidebarViewChange])\n\n // Tool handlers\n const handleToolChange = useCallback((tool: ToolbarTool) => {\n setCurrentTool(tool)\n if (onToolChange) {\n onToolChange(tool)\n }\n }, [onToolChange])\n\n // Search handlers\n const handleSearch = useCallback((term: string) => {\n setSearchTerm(term)\n // TODO: Implement actual search functionality\n setSearchResults(0)\n setCurrentSearchResult(0)\n if (onSearch) {\n onSearch(term)\n }\n }, [onSearch])\n\n const handleSearchNext = useCallback(() => {\n if (searchResults > 0) {\n setCurrentSearchResult((prev) => (prev % searchResults) + 1)\n }\n }, [searchResults])\n\n const handleSearchPrevious = useCallback(() => {\n if (searchResults > 0) {\n setCurrentSearchResult((prev) => (prev === 1 ? searchResults : prev - 1))\n }\n }, [searchResults])\n\n const handleClearSearch = useCallback(() => {\n setSearchTerm('')\n setSearchResults(0)\n setCurrentSearchResult(0)\n }, [])\n\n // File handlers\n const handleOpenFile = useCallback(() => {\n const input = document.createElement('input')\n input.type = 'file'\n input.accept = '.pdf'\n input.onchange = (e) => {\n const file = (e.target as HTMLInputElement).files?.[0]\n if (file && onOpenFile) {\n onOpenFile(file)\n }\n }\n input.click()\n }, [onOpenFile])\n\n const handlePrint = useCallback(() => {\n if (onPrint) {\n onPrint()\n } else {\n // Default print implementation\n window.print()\n }\n }, [onPrint])\n\n const handleDownload = useCallback(() => {\n if (onDownload) {\n onDownload()\n } else if (typeof file === 'string') {\n // Download from URL\n const link = document.createElement('a')\n link.href = file\n link.download = 'document.pdf'\n link.click()\n } else if (file instanceof File) {\n // Download File object\n const url = URL.createObjectURL(file)\n const link = document.createElement('a')\n link.href = url\n link.download = file.name\n link.click()\n URL.revokeObjectURL(url)\n }\n }, [file, onDownload])\n\n // Presentation mode handlers\n const handlePresentationMode = useCallback(() => {\n setPresentationMode(!presentationMode)\n if (onPresentationMode) {\n onPresentationMode()\n }\n \n // Request fullscreen\n if (!presentationMode && containerRef.current) {\n if (containerRef.current.requestFullscreen) {\n containerRef.current.requestFullscreen()\n }\n } else if (document.exitFullscreen) {\n document.exitFullscreen()\n }\n }, [presentationMode, onPresentationMode])\n\n // Document info handler\n const handleDocumentInfo = useCallback(() => {\n if (documentInfo && onDocumentInfo) {\n onDocumentInfo(documentInfo)\n } else if (documentInfo) {\n // Show default document info dialog\n const info = Object.entries(documentInfo as Record<string, any>)\n .filter(([, value]) => value)\n .map(([key, value]) => `${key}: ${value}`)\n .join('\\n')\n alert(`Document Information:\\n\\n${info}`)\n }\n }, [documentInfo, onDocumentInfo])\n\n // Outline click handler\n const handleOutlineClick = useCallback((dest: any) => {\n // TODO: Navigate to destination\n console.log('Navigate to destination:', dest)\n }, [])\n\n // Keyboard shortcuts\n useKeyboardShortcuts(enableKeyboardShortcuts && !presentationMode, {\n onNextPage: goToNextPage,\n onPreviousPage: goToPrevPage,\n onFirstPage: goToFirstPage,\n onLastPage: goToLastPage,\n onZoomIn: zoomIn,\n onZoomOut: zoomOut,\n onActualSize: handleActualSize,\n onRotateClockwise: handleRotate,\n onRotateCounterClockwise: handleRotateCounterClockwise,\n onPresentationMode: handlePresentationMode,\n onHandTool: () => handleToolChange('pan'),\n onTextSelection: () => handleToolChange('selection'),\n onFind: () => handleSearch(''),\n onFindNext: handleSearchNext,\n onFindPrevious: handleSearchPrevious,\n onDownload: handleDownload,\n onPrint: handlePrint,\n onOpenFile: handleOpenFile,\n onGoToPage: () => {\n const page = prompt('Go to page:')\n if (page) {\n const pageNum = parseInt(page, 10)\n if (!isNaN(pageNum)) {\n handlePageChange(pageNum)\n }\n }\n },\n onToggleSidebar: handleSidebarToggle,\n })\n\n // Cleanup on unmount\n useEffect(() => {\n return cleanup\n }, [cleanup])\n\n const containerStyle: React.CSSProperties = {\n width,\n height,\n display: 'flex',\n flexDirection: 'column',\n border: '1px solid #ddd',\n backgroundColor: '#f5f5f5',\n position: presentationMode ? 'fixed' : 'relative',\n top: presentationMode ? 0 : 'auto',\n left: presentationMode ? 0 : 'auto',\n right: presentationMode ? 0 : 'auto',\n bottom: presentationMode ? 0 : 'auto',\n zIndex: presentationMode ? 9999 : 'auto',\n ...style,\n }\n\n const mainContentStyle: React.CSSProperties = {\n flex: 1,\n display: 'flex',\n overflow: 'hidden',\n }\n\n const viewerStyle: React.CSSProperties = {\n flex: 1,\n overflow: 'auto',\n display: 'flex',\n justifyContent: 'center',\n alignItems: currentViewMode === 'continuous' ? 'flex-start' : 'center',\n padding: '20px',\n backgroundColor: '#525659',\n }\n\n if (loading) {\n return (\n <div className={`pdf-viewer ${className}`} style={containerStyle} ref={containerRef}>\n <div style={viewerStyle}>\n <div style={{ color: 'white', fontSize: '16px' }}>Loading PDF...</div>\n </div>\n </div>\n )\n }\n\n if (error) {\n return (\n <div className={`pdf-viewer ${className}`} style={containerStyle} ref={containerRef}>\n <div style={viewerStyle}>\n <div style={{ color: '#ff6b6b', fontSize: '16px' }}>Error: {error}</div>\n </div>\n </div>\n )\n }\n\n if (!pdf) {\n return (\n <div className={`pdf-viewer ${className}`} style={containerStyle} ref={containerRef}>\n <div style={viewerStyle}>\n <div style={{ color: 'white', fontSize: '16px' }}>No PDF loaded</div>\n </div>\n </div>\n )\n }\n\n return (\n <div className={`pdf-viewer ${className}`} style={containerStyle} ref={containerRef}>\n {renderToolbar && !customToolbar && !presentationMode && (\n <PDFToolbar\n currentPage={currentPage}\n totalPages={numPages}\n scale={currentScale}\n viewMode={currentViewMode}\n zoomMode={currentZoomMode}\n activeTool={currentTool}\n searchTerm={searchTerm}\n searchResults={searchResults}\n currentSearchResult={currentSearchResult}\n sidebarOpen={sidebarOpen}\n onPageChange={handlePageChange}\n onScaleChange={handleScaleChange}\n onPrevPage={goToPrevPage}\n onNextPage={goToNextPage}\n onZoomIn={zoomIn}\n onZoomOut={zoomOut}\n onRotate={handleRotate}\n onOpenFile={handleOpenFile}\n onPrint={handlePrint}\n onDownload={handleDownload}\n onSearch={handleSearch}\n onSearchNext={handleSearchNext}\n onSearchPrevious={handleSearchPrevious}\n onClearSearch={handleClearSearch}\n onToolChange={handleToolChange}\n onZoomToFit={handleZoomToFit}\n onZoomToWidth={handleZoomToWidth}\n onViewModeChange={handleViewModeChange}\n onZoomModeChange={handleZoomModeChange}\n onSidebarToggle={handleSidebarToggle}\n onPresentationMode={handlePresentationMode}\n onDocumentInfo={handleDocumentInfo}\n showPageControls={showPageControls}\n showZoomControls={showZoomControls}\n showRotateControls={showRotateControls}\n showViewModeControls={showViewModeControls}\n showOpenOption={showOpenOption}\n showSearchOption={showSearchOption}\n showPrintOption={showPrintOption}\n showDownloadOption={showDownloadOption}\n showToolSelection={showToolSelection}\n showFitOptions={showFitOptions}\n showPresentationMode={showPresentationMode}\n />\n )}\n {customToolbar}\n \n <div style={mainContentStyle}>\n {renderSidebar && sidebarOpen && !presentationMode && (\n <PDFSidebar\n isOpen={sidebarOpen}\n activeView={currentSidebarView}\n pdf={pdf}\n currentPage={currentPage}\n outline={outline}\n attachments={attachments}\n onToggle={handleSidebarToggle}\n onViewChange={handleSidebarViewChange}\n onPageSelect={handlePageChange}\n onOutlineClick={handleOutlineClick}\n />\n )}\n \n <div style={viewerStyle}>\n {currentViewMode === 'continuous' ? (\n // Continuous view - render multiple pages\n <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>\n {Array.from({ length: numPages }, (_, i) => i + 1).map(pageNum => (\n <PDFPage\n key={pageNum}\n pageNumber={pageNum}\n scale={currentScale}\n rotation={currentRotation}\n pdf={pdf}\n enableTextSelection={enableTextSelection}\n highlights={highlights}\n />\n ))}\n </div>\n ) : currentViewMode === 'two-page' ? (\n // Two-page view\n <div style={{ display: 'flex', gap: '20px' }}>\n <PDFPage\n pageNumber={currentPage}\n scale={currentScale}\n rotation={currentRotation}\n pdf={pdf}\n enableTextSelection={enableTextSelection}\n highlights={highlights}\n />\n {currentPage < numPages && (\n <PDFPage\n pageNumber={currentPage + 1}\n scale={currentScale}\n rotation={currentRotation}\n pdf={pdf}\n enableTextSelection={enableTextSelection}\n highlights={highlights}\n />\n )}\n </div>\n ) : (\n // Single page view\n <PDFPage\n pageNumber={currentPage}\n scale={currentScale}\n rotation={currentRotation}\n pdf={pdf}\n enableTextSelection={enableTextSelection}\n highlights={highlights}\n />\n )}\n </div>\n </div>\n </div>\n )\n}\n\n// Memoize the component for performance\nconst propsComparison = (prevProps: PDFViewerProps, nextProps: PDFViewerProps) => {\n // File comparison\n if (prevProps.file !== nextProps.file) {\n if (typeof prevProps.file === 'string' && typeof nextProps.file === 'string') {\n return prevProps.file === nextProps.file\n }\n if (prevProps.file instanceof File && nextProps.file instanceof File) {\n return (\n prevProps.file.name === nextProps.file.name &&\n prevProps.file.size === nextProps.file.size &&\n prevProps.file.lastModified === nextProps.file.lastModified\n )\n }\n return false\n }\n\n // Other props comparison\n return (\n prevProps.page === nextProps.page &&\n prevProps.scale === nextProps.scale &&\n prevProps.rotation === nextProps.rotation &&\n prevProps.viewMode === nextProps.viewMode &&\n prevProps.sidebarView === nextProps.sidebarView &&\n prevProps.zoomMode === nextProps.zoomMode &&\n prevProps.activeTool === nextProps.activeTool &&\n prevProps.enableTextSelection === nextProps.enableTextSelection &&\n prevProps.enableKeyboardShortcuts === nextProps.enableKeyboardShortcuts &&\n prevProps.renderToolbar === nextProps.renderToolbar &&\n prevProps.renderSidebar === nextProps.renderSidebar\n )\n}\n\nexport const PDFViewer = React.memo(PDFViewerComponent, propsComparison)\n\nexport default PDFViewer ","import React, { useState, useRef } from 'react'\nimport type { PDFToolbarProps, ToolbarTool, ViewMode, ZoomMode } from './types'\n\nexport const PDFToolbar: React.FC<PDFToolbarProps> = ({\n currentPage,\n totalPages,\n scale,\n viewMode = 'single',\n zoomMode = 'auto',\n activeTool = 'none',\n searchTerm = '',\n searchResults = 0,\n currentSearchResult = 0,\n sidebarOpen = false,\n onPageChange,\n onScaleChange,\n onPrevPage,\n onNextPage,\n onZoomIn,\n onZoomOut,\n onRotate,\n onOpenFile,\n onPrint,\n onDownload,\n onSearch,\n onSearchNext,\n onSearchPrevious,\n onClearSearch,\n onToolChange,\n onZoomToFit,\n onZoomToWidth,\n onViewModeChange,\n onZoomModeChange,\n onSidebarToggle,\n onPresentationMode,\n onDocumentInfo,\n className = '',\n style,\n showPageControls = true,\n showZoomControls = true,\n showRotateControls = true,\n showViewModeControls = true,\n showOpenOption = true,\n showSearchOption = true,\n showPrintOption = true,\n showDownloadOption = true,\n showToolSelection = true,\n showFitOptions = true,\n showPresentationMode = true,\n}) => {\n const [pageInput, setPageInput] = useState(currentPage.toString())\n const [searchInput, setSearchInput] = useState(searchTerm)\n const [showSearchBar, setShowSearchBar] = useState(false)\n const fileInputRef = useRef<HTMLInputElement>(null)\n\n const handlePageInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n setPageInput(e.target.value)\n }\n\n const handlePageInputSubmit = (e: React.FormEvent) => {\n e.preventDefault()\n const pageNumber = parseInt(pageInput, 10)\n if (!isNaN(pageNumber) && pageNumber >= 1 && pageNumber <= totalPages) {\n onPageChange(pageNumber)\n } else {\n setPageInput(currentPage.toString())\n }\n }\n\n const handleScaleSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {\n const newScale = parseFloat(e.target.value)\n onScaleChange(newScale)\n }\n\n const handleSearchSubmit = (e: React.FormEvent) => {\n e.preventDefault()\n if (onSearch && searchInput.trim()) {\n onSearch(searchInput.trim())\n }\n }\n\n const handleOpenFile = () => {\n if (fileInputRef.current) {\n fileInputRef.current.click()\n }\n if (onOpenFile) {\n onOpenFile()\n }\n }\n\n const handleToolChange = (tool: ToolbarTool) => {\n if (onToolChange) {\n onToolChange(tool)\n }\n }\n\n const toggleSearchBar = () => {\n setShowSearchBar(!showSearchBar)\n if (!showSearchBar && onClearSearch) {\n onClearSearch()\n setSearchInput('')\n }\n }\n\n React.useEffect(() => {\n setPageInput(currentPage.toString())\n }, [currentPage])\n\n React.useEffect(() => {\n setSearchInput(searchTerm)\n }, [searchTerm])\n\n // Styles\n const toolbarStyle: React.CSSProperties = {\n display: 'flex',\n alignItems: 'center',\n padding: '8px 16px',\n backgroundColor: '#2c3e50',\n color: 'white',\n borderBottom: '1px solid #34495e',\n gap: '8px',\n flexWrap: 'wrap',\n boxShadow: '0 2px 4px rgba(0,0,0,0.1)',\n ...style,\n }\n\n const buttonStyle: React.CSSProperties = {\n padding: '8px 12px',\n border: 'none',\n borderRadius: '6px',\n backgroundColor: '#34495e',\n color: 'white',\n cursor: 'pointer',\n fontSize: '14px',\n display: 'flex',\n alignItems: 'center',\n gap: '6px',\n transition: 'all 0.2s ease',\n minWidth: '40px',\n justifyContent: 'center',\n }\n\n const activeButtonStyle: React.CSSProperties = {\n ...buttonStyle,\n backgroundColor: '#3498db',\n color: 'white',\n }\n\n const disabledButtonStyle: React.CSSProperties = {\n ...buttonStyle,\n opacity: 0.4,\n cursor: 'not-allowed',\n backgroundColor: '#34495e',\n }\n\n const inputStyle: React.CSSProperties = {\n padding: '6px 8px',\n border: '1px solid #495057',\n borderRadius: '4px',\n fontSize: '14px',\n backgroundColor: '#495057',\n color: 'white',\n textAlign: 'center',\n width: '60px',\n }\n\n const searchInputStyle: React.CSSProperties = {\n padding: '6px 12px',\n border: '1px solid #495057',\n borderRadius: '4px',\n fontSize: '14px',\n backgroundColor: '#495057',\n color: 'white',\n width: '200px',\n }\n\n const selectStyle: React.CSSProperties = {\n padding: '6px 8px',\n border: '1px solid #495057',\n borderRadius: '4px',\n fontSize: '14px',\n backgroundColor: '#495057',\n color: 'white',\n minWidth: '80px',\n }\n\n const separatorStyle: React.CSSProperties = {\n width: '1px',\n height: '32px',\n backgroundColor: '#495057',\n margin: '0 8px',\n }\n\n const toolGroupStyle: React.CSSProperties = {\n display: 'flex',\n gap: '4px',\n alignItems: 'center',\n }\n\n const labelStyle: React.CSSProperties = {\n fontSize: '12px',\n color: '#bdc3c7',\n fontWeight: '500',\n }\n\n return (\n <div className={`pdf-toolbar ${className}`} style={toolbarStyle}>\n {/* Hidden file input */}\n <input\n ref={fileInputRef}\n type=\"file\"\n accept=\".pdf,application/pdf\"\n style={{ display: 'none' }}\n onChange={(e) => {\n // File handling would be implemented in parent component\n }}\n />\n\n {/* Sidebar Toggle */}\n <div style={toolGroupStyle}>\n <button\n onClick={onSidebarToggle}\n style={sidebarOpen ? activeButtonStyle : buttonStyle}\n title=\"Toggle sidebar (F4)\"\n >\n đī¸\n </button>\n </div>\n\n <div style={separatorStyle} />\n\n {/* File Operations Group */}\n {showOpenOption && (\n <div style={toolGroupStyle}>\n <button\n onClick={handleOpenFile}\n style={buttonStyle}\n title=\"Open PDF file (Ctrl+O)\"\n >\n đ Open\n </button>\n </div>\n )}\n\n {showOpenOption && <div style={separatorStyle} />}\n\n {/* Page Navigation Group */}\n {showPageControls && (\n <div style={toolGroupStyle}>\n <span style={labelStyle}>PAGE</span>\n <button\n onClick={onPrevPage}\n disabled={currentPage <= 1}\n style={currentPage <= 1 ? disabledButtonStyle : buttonStyle}\n title=\"Previous page (â)\"\n >\n â\n </button>\n \n <form onSubmit={handlePageInputSubmit} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>\n <input\n type=\"text\"\n value={pageInput}\n onChange={handlePageInputChange}\n style={inputStyle}\n title=\"Go to page\"\n />\n <span style={{ fontSize: '14px', color: '#bdc3c7' }}>/ {totalPages}</span>\n </form>\n \n <button\n onClick={onNextPage}\n disabled={currentPage >= totalPages}\n style={currentPage >= totalPages ? disabledButtonStyle : buttonStyle}\n title=\"Next page (â)\"\n >\n âļ\n </button>\n </div>\n )}\n\n {(showPageControls && showZoomControls) && <div style={separatorStyle} />}\n\n {/* Magnification/Zoom Group */}\n {showZoomControls && (\n <div style={toolGroupStyle}>\n <span style={labelStyle}>ZOOM</span>\n <button\n onClick={onZoomOut}\n style={buttonStyle}\n title=\"Zoom out (-)\"\n >\n đâ\n </button>\n \n <select\n value={scale}\n onChange={handleScaleSelect}\n style={selectStyle}\n title=\"Zoom level\"\n >\n <option value={0.25}>25%</option>\n <option value={0.5}>50%</option>\n <option value={0.75}>75%</option>\n <option value={1.0}>100%</option>\n <option value={1.25}>125%</option>\n <option value={1.5}>150%</option>\n <option value={2.0}>200%</option>\n <option value={3.0}>300%</option>\n <option value={4.0}>400%</option>\n </select>\n \n <button\n onClick={onZoomIn}\n style={buttonStyle}\n title=\"Zoom in (+)\"\n >\n đ+\n </button>\n\n {showFitOptions && (\n <>\n <button\n onClick={onZoomToFit}\n style={zoomMode === 'page-fit' ? activeButtonStyle : buttonStyle}\n title=\"Fit to page\"\n >\n đ Fit\n </button>\n <button\n onClick={onZoomToWidth}\n style={zoomMode === 'page-width' ? activeButtonStyle : buttonStyle}\n title=\"Fit to width\"\n >\n â Width\n </button>\n <button\n onClick={() => onZoomModeChange?.('actual')}\n style={zoomMode === 'actual' ? activeButtonStyle : buttonStyle}\n title=\"Actual size\"\n >\n đ¯ Actual\n </button>\n </>\n )}\n </div>\n )}\n\n {/* View Mode Controls */}\n {showViewModeControls && (\n <>\n <div style={separatorStyle} />\n <div style={toolGroupStyle}>\n <span style={labelStyle}>VIEW</span>\n <button\n onClick={() => onViewModeChange?.('single')}\n style={viewMode === 'single' ? activeButtonStyle : buttonStyle}\n title=\"Single page view\"\n >\n đ Single\n </button>\n <button\n onClick={() => onViewModeChange?.('continuous')}\n style={viewMode === 'continuous' ? activeButtonStyle : buttonStyle}\n title=\"Continuous scroll view\"\n >\n đ Scroll\n </button>\n <button\n onClick={() => onViewModeChange?.('two-page')}\n style={viewMode === 'two-page' ? activeButtonStyle : buttonStyle}\n title=\"Two-page view\"\n >\n đ Two Page\n </button>\n <button\n onClick={() => onViewModeChange?.('book')}\n style={viewMode === 'book' ? activeButtonStyle : buttonStyle}\n title=\"Book view\"\n >\n đ Book\n </button>\n </div>\n </>\n )}\n\n {(showZoomControls && showToolSelection) && <div style={separatorStyle} />}\n\n {/* Tool Selection Group */}\n {showToolSelection && (\n <div style={toolGroupStyle}>\n <span style={labelStyle}>TOOLS</span>\n <button\n onClick={() => handleToolChange('pan')}\n style={activeTool === 'pan' ? activeButtonStyle : buttonStyle}\n title=\"Pan tool - Click and drag to move around\"\n >\n â Pan\n </button>\n \n <button\n onClick={() => handleToolChange('selection')}\n style={activeTool === 'selection' ? activeButtonStyle : buttonStyle}\n title=\"Selection tool - Select text and areas\"\n >\n đ Select\n </button>\n \n <button\n onClick={() => handleToolChange('annotation')}\n style={activeTool === 'annotation' ? activeButtonStyle : buttonStyle}\n title=\"Annotation tool - Add and edit annotations\"\n >\n âī¸ Annotate\n </button>\n </div>\n )}\n\n {(showToolSelection && showSearchOption) && <div style={separatorStyle} />}\n\n {/* Search Group */}\n {showSearchOption && (\n <div style={toolGroupStyle}>\n <button\n onClick={toggleSearchBar}\n style={showSearchBar ? activeButtonStyle : buttonStyle}\n title=\"Search in document\"\n >\n đ Search\n </button>\n \n {showSearchBar && (\n <>\n <form onSubmit={handleSearchSubmit} style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>\n <input\n type=\"text\"\n value={searchInput}\n onChange={(e) => setSearchInput(e.target.value)}\n placeholder=\"Search in PDF...\"\n style={searchInputStyle}\n />\n <button\n type=\"submit\"\n style={buttonStyle}\n title=\"Search\"\n >\n đ\n </button>\n </form>\n \n {searchResults > 0 && (\n <div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>\n <span style={{ fontSize: '12px', color: '#bdc3c7' }}>\n {currentSearchResult + 1} of {searchResults}\n </span>\n <button\n onClick={onSearchPrevious}\n disabled={currentSearchResult <= 0}\n style={currentSearchResult <= 0 ? disabledButtonStyle : buttonStyle}\n title=\"Previous result\"\n >\n â˛\n </button>\n <button\n onClick={onSearchNext}\n disabled={currentSearchResult >= searchResults - 1}\n style={currentSearchResult >= searchResults - 1 ? disabledButtonStyle : buttonStyle}\n title=\"Next result\"\n >\n âŧ\n </button>\n <button\n onClick={() => {\n if (onClearSearch) onClearSearch()\n setSearchInput('')\n setShowSearchBar(false)\n }}\n style={buttonStyle}\n title=\"Clear search\"\n >\n â\n </button>\n </div>\n )}\n </>\n )}\n </div>\n )}\n\n {(showSearchOption && (showRotateControls || showPrintOption || showDownloadOption)) && (\n <div style={separatorStyle} />\n )}\n\n {/* Document Actions Group */}\n <div style={toolGroupStyle}>\n {showRotateControls && onRotate && (\n <button\n onClick={onRotate}\n style={buttonStyle}\n title=\"Rotate document clockwise (R)\"\n >\n âģ Rotate\n </button>\n )}\n\n {showPresentationMode && onPresentationMode && (\n <button\n onClick={onPresentationMode}\n style={buttonStyle}\n title=\"Presentation mode (Ctrl+Alt+P)\"\n >\n đĻ Present\n </button>\n )}\n\n <button\n onClick={onDocumentInfo}\n style={buttonStyle}\n title=\"Document properties\"\n >\n âšī¸ Info\n </button>\n\n {showPrintOption && onPrint && (\n <button\n onClick={onPrint}\n style={buttonStyle}\n title=\"Print document (Ctrl+P)\"\n >\n đ¨ī¸ Print\n </button>\n )}\n\n {showDownloadOption && onDownload && (\n <button\n onClick={onDownload}\n style={buttonStyle}\n title=\"Download PDF (Ctrl+S)\"\n >\n đž Download\n </button>\n )}\n </div>\n </div>\n )\n}\n\nexport default PDFToolbar ","import React, { useState, useEffect, useCallback, useRef } from 'react'\nimport type { \n PDFSidebarProps, \n SidebarView, \n PDFDocumentProxy, \n PDFPageProxy,\n PDFOutlineItem \n} from './types'\n\nexport const PDFSidebar: React.FC<PDFSidebarProps> = ({\n isOpen,\n activeView,\n pdf,\n currentPage,\n outline,\n attachments,\n onToggle,\n onViewChange,\n onPageSelect,\n onOutlineClick,\n className = '',\n style,\n}) => {\n const [thumbnails, setThumbnails] = useState<{ [key: number]: string }>({})\n const [loadingThumbnails, setLoadingThumbnails] = useState<Set<number>>(new Set())\n const [expandedOutlineItems, setExpandedOutlineItems] = useState<Set<string>>(new Set())\n const thumbnailsContainerRef = useRef<HTMLDivElement>(null)\n const observer = useRef<IntersectionObserver | null>(null)\n\n // Load thumbnail for a specific page\n const loadThumbnail = useCallback(async (pageNum: number) => {\n if (!pdf || thumbnails[pageNum] || loadingThumbnails.has(pageNum)) return\n\n setLoadingThumbnails(prev => new Set(prev).add(pageNum))\n\n try {\n const page = await pdf.getPage(pageNum)\n const viewport = page.getViewport({ scale: 0.2 }) // Small scale for thumbnails\n \n const canvas = document.createElement('canvas')\n const context = canvas.getContext('2d')!\n canvas.height = viewport.height\n canvas.width = viewport.width\n\n const renderContext = {\n canvasContext: context,\n viewport: viewport,\n }\n\n await page.render(renderContext).promise\n \n const thumbnailUrl = canvas.toDataURL()\n setThumbnails(prev => ({ ...prev, [pageNum]: thumbnailUrl }))\n } catch (error) {\n console.warn(`Failed to load thumbnail for page ${pageNum}:`, error)\n } finally {\n setLoadingThumbnails(prev => {\n const newSet = new Set(prev)\n newSet.delete(pageNum)\n return newSet\n })\n }\n }, [pdf, thumbnails, loadingThumbnails])\n\n // Setup intersection observer for lazy loading thumbnails\n useEffect(() => {\n if (activeView !== 'thumbnails' || !thumbnailsContainerRef.current) return\n\n observer.current = new IntersectionObserver(\n (entries) => {\n entries.forEach(entry => {\n if (entry.isIntersecting) {\n const pageNum = parseInt(entry.target.getAttribute('data-page') || '0')\n if (pageNum > 0) {\n loadThumbnail(pageNum)\n }\n }\n })\n },\n { rootMargin: '50px' }\n )\n\n const thumbnailElements = thumbnailsContainerRef.current.querySelectorAll('[data-page]')\n thumbnailElements.forEach(el => observer.current?.observe(el))\n\n return () => {\n if (observer.current) {\n observer.current.disconnect()\n }\n }\n }, [activeView, pdf, loadThumbnail])\n\n // Handle outline item toggle\n const handleOutlineToggle = useCallback((itemId: string) => {\n setExpandedOutlineItems(prev => {\n const newSet = new Set(prev)\n if (newSet.has(itemId)) {\n newSet.delete(itemId)\n } else {\n newSet.add(itemId)\n }\n return newSet\n })\n }, [])\n\n // Render outline items recursively\n const renderOutlineItems = useCallback((items: PDFOutlineItem[], level: number = 0): React.ReactNode => {\n return items.map((item, index) => {\n const itemId = `${level}-${index}`\n const hasChildren = item.items && item.items.length > 0\n const isExpanded = expandedOutlineItems.has(itemId)\n\n return (\n <div key={itemId} className=\"pdf-outline-item\">\n <div \n className=\"pdf-outline-item-content\"\n style={{ \n paddingLeft: `${level * 16 + 8}px`,\n display: 'flex',\n alignItems: 'center',\n padding: '4px 8px',\n cursor: 'pointer',\n fontSize: '14px',\n fontWeight: item.bold ? 'bold' : 'normal',\n fontStyle: item.italic ? 'italic' : 'normal',\n color: item.color ? `rgb(${item.color.join(',')})` : 'inherit'\n }}\n onClick={() => {\n if (item.dest) {\n onOutlineClick(item.dest)\n }\n if (hasChildren) {\n handleOutlineToggle(itemId)\n }\n }}\n >\n {hasChildren && (\n <span \n style={{ \n marginRight: '4px',\n transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',\n transition: 'transform 0.2s ease'\n }}\n >\n âļ\n </span>\n )}\n <span>{item.title}</span>\n </div>\n {hasChildren && isExpanded && (\n <div className=\"pdf-outline-children\">\n {renderOutlineItems(item.items!, level + 1)}\n </div>\n )}\n </div>\n )\n })\n }, [expandedOutlineItems, handleOutlineToggle, onOutlineClick])\n\n if (!isOpen) return null\n\n const sidebarStyle: React.CSSProperties = {\n width: '250px',\n height: '100%',\n backgroundColor: '#f8f9fa',\n borderRight: '1px solid #dee2e6',\n display: 'flex',\n flexDirection: 'column',\n overflow: 'hidden',\n ...style,\n }\n\n const headerStyle: React.CSSProperties = {\n display: 'flex',\n borderBottom: '1px solid #dee2e6',\n backgroundColor: '#e9ecef',\n }\n\n const tabStyle: React.CSSProperties = {\n flex: 1,\n padding: '8px 12px',\n border: 'none',\n backgroundColor: 'transparent',\n cursor: 'pointer',\n fontSize: '12px',\n textAlign: 'center',\n textTransform: 'uppercase',\n fontWeight: '500',\n transition: 'background-color 0.2s ease',\n }\n\n const activeTabStyle: React.CSSProperties = {\n ...tabStyle,\n backgroundColor: '#fff',\n borderBottom: '2px solid #007bff',\n }\n\n const contentStyle: React.CSSProperties = {\n flex: 1,\n overflow: 'auto',\n padding: '8px',\n }\n\n const closeButtonStyle: React.CSSProperties = {\n position: 'absolute',\n top: '8px',\n right: '8px',\n background: 'none',\n border: 'none',\n fontSize: '16px',\n cursor: 'pointer',\n color: '#6c757d',\n padding: '4px',\n borderRadius: '4px',\n }\n\n return (\n <div className={`pdf-sidebar ${className}`} style={sidebarStyle}>\n <button style={closeButtonStyle} onClick={onToggle} title=\"Close sidebar\">\n â\n </button>\n \n {/* Sidebar tabs */}\n <div style={headerStyle}>\n <button\n style={activeView === 'thumbnails' ? activeTabStyle : tabStyle}\n onClick={() => onViewChange('thumbnails')}\n title=\"Show page thumbnails\"\n >\n đ Pages\n </button>\n <button\n style={activeView === 'outline' ? activeTabStyle : tabStyle}\n onClick={() => onViewChange('outline')}\n title=\"Show document outline\"\n >\n đ Outline\n </button>\n <button\n style={activeView === 'attachments' ? activeTabStyle : tabStyle}\n onClick={() => onViewChange('attachments')}\n title=\"Show attachments\"\n >\n đ Files\n </butto