import React, { useState, useEffect, useRef } from 'react'; import { PDFStatus } from '../types'; import { pdfPreviewService } from '../services/pdfPreviewService'; import { useToast } from '../contexts/ToastContext'; 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; initialPage?: number; // 追加 highlightText?: string; // 追加 onClose: () => void; } export const PDFPreview: React.FC = ({ fileId, fileName, authToken, groupId, initialPage, highlightText, 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(initialPage || 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 { showToast } = useToast(); const { t, language } = useLanguage(); const containerRef = React.useRef(null); const canvasRef = useRef(null); useEffect(() => { if (initialPage && initialPage > 0) { setCurrentPage(initialPage); } }, [initialPage]); useEffect(() => { if (status.status === 'ready') { pdfPreviewService.getPDFUrl(fileId) .then(result => { setPdfUrl(result.url); // ダウンロード用にpdfUrlを設定 // PDFデータを取得してblob URLを作成 fetch(result.url) .then(response => response.blob()) .then(blob => { setPdfBlob(blob); // PDF文書の読み込みとレンダリングを開始 loadAndRenderPDF(blob); }) .catch(() => setIframeError(true)); }) .catch(() => setIframeError(true)); } }, [status.status, fileId]); useEffect(() => { if (pdfDoc && currentPage) { // ページ切り替えまたはズームレベル変更時に再レンダリング renderCurrentPage(pdfDoc, currentPage); } }, [currentPage, pdfDoc, zoomLevel]); useEffect(() => { checkPDFStatus(); const interval = setInterval(checkPDFStatus, 3000); return () => clearInterval(interval); }, [fileId]); // スクロールページめくり機能を追加 useEffect(() => { const container = containerRef.current; if (!container) return; const handleWheel = (e: WheelEvent) => { if (!pdfDoc || e.shiftKey) return; // Shiftキーが押されている場合はページめくりをトリガーしない(水平スクロールを許可) // スクロール対象がcanvasコンテナ内にあるかチェック const canvasContainer = container.querySelector('.pdf-canvas-container'); if (!canvasContainer) return; // スクロールイベントがcanvasコンテナ内で発生することを確認 if (canvasContainer.contains(e.target as HTMLElement)) { // Ctrlキーが押されている場合はズームを実行し、ページめくりはしない if (e.ctrlKey || e.metaKey) { e.preventDefault(); const zoomIncrement = e.deltaY > 0 ? -0.1 : 0.1; const newZoom = Math.max(0.5, Math.min(3.0, zoomLevel + zoomIncrement)); setZoomLevel(newZoom); return; } // スクロール方向を検出(Ctrlキー以外の場合) if (e.deltaY > 0 && currentPage < numPages) { // 下にスクロール、次のページへ e.preventDefault(); setCurrentPage(prev => Math.min(prev + 1, numPages)); } else if (e.deltaY < 0 && currentPage > 1) { // 上にスクロール、前のページへ e.preventDefault(); setCurrentPage(prev => Math.max(prev - 1, 1)); } } }; container.addEventListener('wheel', handleWheel, { passive: false }); return () => container.removeEventListener('wheel', handleWheel); }, [currentPage, numPages, pdfDoc, zoomLevel]); 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) { setLoading(false); setStatus({ status: 'failed', error: t('checkPDFStatusFailed') }); showToast('error', t('checkPDFStatusFailed')); } }; 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 renderRequestId = useRef(0); const renderCurrentPage = async (pdf: pdfjs.PDFDocumentProxy, pageNum: number) => { if (!canvasRef.current) return; // 今回のレンダリングリクエストに一意のIDを割り当てる const requestId = ++renderRequestId.current; let renderTask: pdfjs.RenderTask | null = null; try { // 進行中のレンダリングタスクが存在する場合、キャンセルして完了を持ち越す if (currentRenderTask.current) { currentRenderTask.current.cancel(); try { await currentRenderTask.current.promise; } catch (e) { // キャンセルによるエラーは無視 } } // 待機中に新しいリクエストが来た場合、このリクエストは中止する if (requestId !== renderRequestId.current) { return; } 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 while maintaining aspect ratio const viewport = page.getViewport({ scale: 1 }); const baseScaleX = containerWidth / viewport.width; const baseScaleY = containerHeight / viewport.height; // Use minimum scale to fit the page in container, then apply zoom level let scale = Math.min(baseScaleX, baseScaleY, 1); // Don't upscale beyond 1:1 by default // Apply zoom level scale *= zoomLevel; const finalScale = scale * devicePixelRatio; const scaledViewport = page.getViewport({ scale: finalScale }); // Set canvas dimensions with device pixel ratio canvas.width = scaledViewport.width; canvas.height = scaledViewport.height; // 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); // 実際のレンダリング直前にもう一度チェック if (requestId !== renderRequestId.current) { return; } // Create and save the new render task const renderContext = { canvasContext: context, viewport: page.getViewport({ scale: scale }), }; // Save the render task to allow for cancellation renderTask = page.render(renderContext); currentRenderTask.current = renderTask; // Wait for rendering to complete await renderTask.promise; // Render Text Layer if (requestId === renderRequestId.current) { const textContent = await page.getTextContent(); // Find or create text layer div let textLayerDiv = container.querySelector('.textLayer') as HTMLDivElement; if (!textLayerDiv) { textLayerDiv = document.createElement('div'); textLayerDiv.className = 'textLayer'; // Ensure container has relative positioning const canvasContainer = container.querySelector('.pdf-canvas-container .flex'); if (canvasContainer) { (canvasContainer as HTMLElement).style.position = 'relative'; (canvasContainer as HTMLElement).appendChild(textLayerDiv); } } // Reset text layer textLayerDiv.innerHTML = ''; textLayerDiv.style.position = 'absolute'; textLayerDiv.style.top = '0'; textLayerDiv.style.left = '0'; textLayerDiv.style.height = `${scaledViewport.height}px`; textLayerDiv.style.width = `${scaledViewport.width}px`; // Apply transform to match high DPI scaling if needed, essentially we want it to overlay the canvas exactly // The canvas is scaled by devicePixelRatio via CSS width/height vs attribute width/height // But text layer usually acts on CSS pixels. // If scaledViewport was created with finalScale (scale * devicePixelRatio), then it's in device pixels. // We might need to adjust. // Actually, pdf.js text layer expects viewport to be the same as used for rendering? // Typically we render text layer at 1:1 CSS pixel mapping if possible or match the viewport. // Let's use the viewport that matches the visual size (CSS pixels) const cssViewport = page.getViewport({ scale: scale }); textLayerDiv.style.width = `${cssViewport.width}px`; textLayerDiv.style.height = `${cssViewport.height}px`; textLayerDiv.style.setProperty('--scale-factor', `${scale}`); // We need to import renderTextLayer dynamically or check availability // Since we imported * as pdfjs, let's try pdfjs.renderTextLayer // Note: In some versions it's inside pdfjs.pdfjsLib or similar. // But typically we construct a TextLayerBuilder or use renderTextLayer utility. // For simplicity in React without extra libs, we can try to render it manually or use the basic API if available. // However, pdfjs-dist v4 has `pdfjs.renderTextLayer({ textContent, container, viewport, textDivs })`. // In pdfjs-dist v4, renderTextLayer is removed/deprecated in favor of using TextLayer class directly. // We use the exported TextLayer class. const TextLayerClass = (pdfjs as any).TextLayer; if (TextLayerClass) { const textLayer = new TextLayerClass({ textContentSource: textContent, container: textLayerDiv, viewport: cssViewport, textDivs: [] }); await textLayer.render(); } else { console.error('TextLayer class not found in pdfjs-dist'); } // Apply Highlights if (highlightText) { console.log('Attempting to highlight:', highlightText); const normalize = (str: string) => str.toLowerCase().replace(/\s+/g, ''); const searchStr = normalize(highlightText); const spans = Array.from(textLayerDiv.querySelectorAll('span')); let found = false; // Strategy 1: Check for exact match across spans (if text is fragmented) // We can try to build the full text and map it back, but that's complex. // Let's try a token-based approach. // Simple strategy: If a span contains a significant chunk of the search string, highlight it. // Or if the search string is found within the concatenated text of a few adjacent spans. // Let's try to highlight any span that has a significant substring match. spans.forEach(span => { const spanText = normalize(span.textContent || ''); if (!spanText) return; // If the span text is fully contained in the search string if (searchStr.includes(spanText) && spanText.length > 3) { span.style.backgroundColor = 'rgba(255, 255, 0, 0.4)'; span.style.borderRadius = '2px'; found = true; } // If the search string is fully contained in the span text else if (spanText.includes(searchStr)) { span.style.backgroundColor = 'rgba(255, 255, 0, 0.4)'; span.style.borderRadius = '2px'; found = true; } }); if (!found) { console.log('No exact/substring match found. Trying fuzzy/keyword match.'); // Fallback: Keyword matching (if exact match fails) // For CJK characters, even 2 chars is significant. For English, keep it > 3. // We split by non-word characters to get tokens const tokens = highlightText.toLowerCase().split(/[^\w\u4e00-\u9fa5]+/); const keywords = tokens.filter(k => { const isCJK = /[\u4e00-\u9fa5]/.test(k); return isCJK ? k.length >= 2 : k.length > 3; }); if (keywords.length > 0) { spans.forEach(span => { const spanText = (span.textContent || '').toLowerCase(); if (keywords.some(k => spanText.includes(k))) { span.style.backgroundColor = 'rgba(255, 255, 0, 0.4)'; // Increased opacity span.style.borderRadius = '2px'; found = true; } }); } } // Scroll first highlighted element into view const firstHighlight = textLayerDiv.querySelector('span[style*="background-color"]'); if (firstHighlight) { console.log('Scrolling to highlight'); firstHighlight.scrollIntoView({ behavior: 'smooth', block: 'center' }); } else { console.log('No highlight applied'); } } } // このタスクが完了したとき、まだ最新のタスクであればクリアする if (currentRenderTask.current === renderTask) { currentRenderTask.current = null; } } catch (error) { if (error instanceof Error && error.name !== 'RenderingCancelledException') { console.error('Failed to render PDF page:', error); } // このタスクでエラーが発生したとき、まだ最新のタスクであればクリアする if (currentRenderTask.current === renderTask) { 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 (window.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 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()}
); }; // Add global styles for text layer if not present const style = document.createElement('style'); style.innerHTML = ` .textLayer { position: absolute; text-align: initial; left: 0; top: 0; right: 0; bottom: 0; overflow: hidden; opacity: 1; /* Increased from 0.25 to 1 because the spans are transparent anyway */ line-height: 1.0; pointer-events: auto; /* Enable text selection */ z-index: 10; /* Ensure text layer is above canvas */ mix-blend-mode: multiply; /* Better blending for highlights */ } .textLayer > span { color: transparent; position: absolute; white-space: pre; cursor: text; transform-origin: 0% 0%; } `; document.head.appendChild(style); 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(); useEffect(() => { checkStatus(); }, [fileId]); const checkStatus = async () => { try { const pdfStatus = await pdfPreviewService.getPDFStatus(fileId); setStatus(pdfStatus); } catch (error) { // エラーを無視し、デフォルト状態を使用 } finally { setLoading(false); } }; const getIcon = () => { if (loading || status.status === 'converting') { return ; } if (status.status === 'failed') { return ; } return ; }; const getTitle = () => { switch (status.status) { case 'ready': return t('pdfPreviewReady'); case 'converting': return t('convertingInProgress'); case 'failed': return t('conversionFailed'); default: return t('generatePDFPreviewButton'); } }; return ( ); };