import React, { useState, useEffect, useRef } from 'react'; import { isFormatSupportedForPreview } from '../constants/fileSupport'; import { PDFStatus } from '../types'; import { pdfPreviewService } from '../services/pdfPreviewService'; import { useToast } from '../contexts/ToastContext'; import { useConfirm } from '../contexts/ConfirmContext'; import { X, FileText, Loader, AlertCircle, Maximize2, Eye, Download, ExternalLink, RefreshCw, Scissors, ChevronLeft, ChevronRight } from 'lucide-react'; import { PDFSelectionTool } from './PDFSelectionTool'; import { CreateNoteFromPDFDialog } from './CreateNoteFromPDFDialog'; import { noteService } from '../services/noteService'; import { useLanguage } from '../contexts/LanguageContext'; import { knowledgeBaseService } from '../services/knowledgeBaseService'; import * as pdfjs from 'pdfjs-dist'; // Set worker path for PDF.js pdfjs.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.mjs`; interface PDFPreviewProps { fileId: string; fileName: string; authToken: string; groupId?: string; onClose: () => void; } export const PDFPreview: React.FC = ({ fileId, fileName, authToken, groupId, onClose }) => { const [status, setStatus] = useState({ status: 'pending' }); const [loading, setLoading] = useState(true); const [isFullscreen, setIsFullscreen] = useState(false); const [pdfUrl, setPdfUrl] = useState(''); const [iframeError, setIframeError] = useState(false); const [isSelectionMode, setIsSelectionMode] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [pdfBlob, setPdfBlob] = useState(null); const [selectionData, setSelectionData] = useState<{ screenshot: Blob; text: string } | null>(null); const [numPages, setNumPages] = useState(0); const [pdfDoc, setPdfDoc] = useState(null); const [zoomLevel, setZoomLevel] = useState(1.0); // ズームレベルの状態を追加 const currentRenderTask = useRef(null); // 現在のレンダリングタスクを保存 const scrollContainerRef = useRef(null); const flipDirection = useRef<'next' | 'prev' | null>(null); const lastFlipTime = useRef(0); const { showToast } = useToast(); const { confirm } = useConfirm(); const { t, language } = useLanguage(); const containerRef = React.useRef(null); const canvasRef = useRef(null); useEffect(() => { if (status.status === 'ready') { pdfPreviewService.getPDFUrl(fileId) .then(result => { setPdfUrl(result.url); // ダウンロード用にpdfUrlを設定 // PDFデータを取得してblob URLを作成 fetch(result.url) .then(async response => { if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.message || 'Failed to fetch PDF data'); } return response.blob(); }) .then(blob => { setPdfBlob(blob); // PDF文書の読み込みとレンダリングを開始 loadAndRenderPDF(blob); }) .catch((err) => { console.error('PDF fetch error:', err); setIframeError(true); setStatus({ status: 'failed', error: err.message }); }); }) .catch((err) => { console.error('getPDFUrl error:', err); setIframeError(true); setStatus({ status: 'failed', error: err.message }); }); } }, [status.status, fileId]); useEffect(() => { if (pdfDoc && currentPage) { // ページ切り替えまたはズームレベル変更時に再レンダリング renderCurrentPage(pdfDoc, currentPage); } }, [currentPage, pdfDoc, zoomLevel]); const isSupported = isFormatSupportedForPreview(fileName); useEffect(() => { if (isSupported) { checkPDFStatus(); const interval = setInterval(checkPDFStatus, 3000); return () => clearInterval(interval); } else { setLoading(false); setStatus({ status: 'failed', error: t('previewNotSupported') }); } }, [fileId, isSupported]); const checkPDFStatus = async () => { try { const pdfStatus = await pdfPreviewService.getPDFStatus(fileId); setStatus(pdfStatus); // ステータスがpendingの場合、変換を能動的にトリガー if (pdfStatus.status === 'pending') { setStatus({ status: 'converting' }); try { // PDF URLにアクセスして変換をトリガー await pdfPreviewService.preloadPDF(fileId); } catch (error) { console.log('Preload triggered, conversion should start'); } } if (pdfStatus.status === 'ready' || pdfStatus.status === 'failed') { setLoading(false); } } catch (error: any) { setLoading(false); const errorMessage = error.message || t('checkPDFStatusFailed'); setStatus({ status: 'failed', error: errorMessage }); showToast(errorMessage, 'error'); } }; const loadAndRenderPDF = async (blob: Blob) => { try { const pdfData = await blob.arrayBuffer(); const pdf = await pdfjs.getDocument({ data: pdfData }).promise; setPdfDoc(pdf); setNumPages(pdf.numPages); if (currentPage > pdf.numPages) { setCurrentPage(pdf.numPages); } renderCurrentPage(pdf, currentPage); } catch (error) { console.error('Failed to load PDF:', error); setIframeError(true); } }; const handleZoomIn = () => { setZoomLevel(prev => Math.min(3.0, prev + 0.1)); }; const handleZoomOut = () => { setZoomLevel(prev => Math.max(0.5, prev - 0.1)); }; const handleResetZoom = () => { setZoomLevel(1.0); }; const renderCurrentPage = async (pdf: pdfjs.PDFDocumentProxy, pageNum: number) => { if (!canvasRef.current) return; try { // 進行中のレンダリングタスクが存在する場合、キャンセルする if (currentRenderTask.current) { currentRenderTask.current.cancel(); } const page = await pdf.getPage(pageNum); const canvas = canvasRef.current; const context = canvas.getContext('2d'); if (!context) return; // Get container dimensions if (!containerRef.current) return; const container = containerRef.current; const containerWidth = container.clientWidth; const containerHeight = container.clientHeight; // Handle high DPI displays const devicePixelRatio = window.devicePixelRatio || 1; // Calculate scale to fit page in container width while maintaining aspect ratio const viewport = page.getViewport({ scale: 1 }); const baseScale = (containerWidth - 48) / viewport.width; // Add padding for width // Apply zoom level to base scale const scale = baseScale * zoomLevel; const finalScale = scale * devicePixelRatio; const scaledViewport = page.getViewport({ scale: finalScale }); const cssViewport = page.getViewport({ scale: scale }); // Set canvas dimensions with device pixel ratio canvas.width = scaledViewport.width; canvas.height = scaledViewport.height; // Set CSS dimensions explicitly for high-DPI canvas.style.width = `${cssViewport.width}px`; canvas.style.height = `${cssViewport.height}px`; // Reset any previous transforms context.setTransform(1, 0, 0, 1, 0, 0); // Clear canvas context.clearRect(0, 0, canvas.width, canvas.height); // Fill with white background context.fillStyle = 'white'; context.fillRect(0, 0, canvas.width, canvas.height); // Set proper transform for high DPI context.scale(devicePixelRatio, devicePixelRatio); // Create and save the new render task const renderContext = { canvasContext: context, viewport: page.getViewport({ scale: scale }), }; // Save the render task to allow for cancellation currentRenderTask.current = page.render(renderContext); // Wait for rendering to complete await currentRenderTask.current.promise; // Clear the current render task currentRenderTask.current = null; // ページめくり後のスクロール位置調整 if (flipDirection.current && scrollContainerRef.current) { const container = scrollContainerRef.current; if (flipDirection.current === 'next') { container.scrollTop = 0; } else if (flipDirection.current === 'prev') { container.scrollTop = container.scrollHeight; } flipDirection.current = null; } } catch (error) { if (error instanceof Error && error.name !== 'RenderingCancelledException') { console.error('Failed to render PDF page:', error); } // Clear the current render task even if there's an error currentRenderTask.current = null; } }; const handleFullscreen = () => { setIsFullscreen(!isFullscreen); }; const handleDownload = () => { if (pdfUrl) { // pdfUrlが既にある場合、直接ダウンロード const link = document.createElement('a'); link.href = pdfUrl; link.download = fileName.replace(/\.[^/.]+$/, '.pdf'); document.body.appendChild(link); link.click(); document.body.removeChild(link); } else { // pdfUrlがない場合、直接取得してダウンロードを試みる pdfPreviewService.getPDFUrl(fileId) .then(result => { const link = document.createElement('a'); link.href = result.url; link.download = fileName.replace(/\.[^/.]+$/, '.pdf'); document.body.appendChild(link); link.click(); document.body.removeChild(link); }) .catch(error => { console.error('Failed to download PDF:', error); showToast('error', t('downloadPDFFailed')); }); } }; const handleOpenInNewTab = () => { if (pdfUrl) { window.open(pdfUrl, '_blank'); } else { // pdfUrlがない場合、直接取得して開くことを試みる pdfPreviewService.getPDFUrl(fileId) .then(result => { window.open(result.url, '_blank'); }) .catch(error => { console.error('Failed to open PDF in new tab:', error); showToast('error', t('openPDFInNewTabFailed')); }); } }; const handleRegenerate = async () => { if (await confirm(t('confirmRegeneratePDF'))) { setStatus({ status: 'converting' }); setLoading(true); try { await pdfPreviewService.preloadPDF(fileId, true); // 状態をリセットして再読み込みをトリガー setPdfUrl(''); setIframeError(false); setPdfDoc(null); setPdfBlob(null); setNumPages(0); } catch (error) { showToast('error', t('requestRegenerationFailed')); setStatus({ status: 'failed', error: t('requestRegenerationFailed') }); } } }; const handleIframeError = () => { setIframeError(true); }; const handleWheel = (e: React.WheelEvent) => { if (!scrollContainerRef.current || isSelectionMode) return; const container = scrollContainerRef.current; const { scrollTop, scrollHeight, clientHeight } = container; const now = Date.now(); const throttleMs = 600; // 連続ページめくりを防止 // 下にスクロールして次のページへ if (e.deltaY > 0 && scrollTop + clientHeight >= scrollHeight - 1) { if (currentPage < numPages && now - lastFlipTime.current > throttleMs) { flipDirection.current = 'next'; lastFlipTime.current = now; setCurrentPage(prev => prev + 1); } } // 上にスクロールして前のページへ else if (e.deltaY < 0 && scrollTop <= 1) { if (currentPage > 1 && now - lastFlipTime.current > throttleMs) { flipDirection.current = 'prev'; lastFlipTime.current = now; setCurrentPage(prev => prev - 1); } } }; const handleSelectionComplete = (screenshot: Blob, text: string) => { // Set preliminary data and open dialog setSelectionData({ screenshot, text }); setIsSelectionMode(false); }; const handleSaveNote = async (title: string, content: string, selectedGroupId?: string) => { if (!authToken || !selectionData) return; try { await noteService.createFromPDFSelection( authToken, fileId, selectionData.screenshot, selectedGroupId || groupId, currentPage ); showToast('success', t('noteCreatedSuccess')); setSelectionData(null); } catch (error) { console.error('Failed to create note:', error); showToast('error', t('noteCreatedFailed')); } }; const renderContent = () => { switch (status.status) { case 'pending': return (
{t('preparingPDFConversion')}
{t('pleaseWait')}
); case 'converting': return (
{t('convertingPDF')}
{t('pleaseWait')}
); case 'failed': return (
{t('pdfConversionFailed')}
{status.error || t('pdfConversionError')}
); case 'ready': if (iframeError) { return (
{t('pdfLoadFailed')}
{t('pdfLoadError')}
); } if (!pdfDoc) { return (
{t('loadingPDF')}
); } return (
{isSelectionMode && ( setIsSelectionMode(false)} /> )}
{Math.round(zoomLevel * 100)}%
{ const val = Math.max(1, Math.min(numPages, parseInt(e.target.value) || 1)); setCurrentPage(val); }} className="w-12 text-center text-sm border-none focus:ring-0 bg-transparent font-medium" /> / {numPages}
{selectionData && ( setSelectionData(null)} /> )}
); default: return null; } }; return (
{/* 头部 */}
{fileName}
{t('pdfPreview')}
{status.status === 'ready' && !iframeError && ( <>
{t('selectPageNumber')} { const val = Math.max(1, Math.min(numPages, parseInt(e.target.value) || 1)); setCurrentPage(val); }} className="w-16 px-2 py-1 border rounded text-sm" title={t('enterPageNumber')} />
)}
{/* 内容区域 */}
{renderContent()}
); }; interface PDFPreviewButtonProps { fileId: string; fileName: string; onPreview: () => void; } export const PDFPreviewButton: React.FC = ({ fileId, fileName, onPreview }) => { const [status, setStatus] = useState({ status: 'pending' }); const [loading, setLoading] = useState(true); const { t } = useLanguage(); const isSupported = isFormatSupportedForPreview(fileName); useEffect(() => { if (isSupported) { checkStatus(); } else { setLoading(false); } }, [fileId, isSupported]); const checkStatus = async () => { try { const pdfStatus = await pdfPreviewService.getPDFStatus(fileId); setStatus(pdfStatus); } catch (error) { // エラーを無視し、デフォルト状態を使用 } finally { setLoading(false); } }; const getIcon = () => { if (!isSupported) { return ; } if (loading || status.status === 'converting') { return ; } if (status.status === 'failed') { return ; } return ; }; const getTitle = () => { if (!isSupported) return t('previewNotSupported'); switch (status.status) { case 'ready': return t('pdfPreviewReady'); case 'converting': return t('convertingInProgress'); case 'failed': return t('conversionFailed'); default: return t('generatePDFPreviewButton'); } }; return ( ); };