| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867 |
- 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<PDFPreviewProps> = ({ fileId, fileName, authToken, groupId, initialPage, highlightText, onClose }) => {
- const [status, setStatus] = useState<PDFStatus>({ status: 'pending' });
- const [loading, setLoading] = useState(true);
- const [isFullscreen, setIsFullscreen] = useState(false);
- const [pdfUrl, setPdfUrl] = useState<string>('');
- const [iframeError, setIframeError] = useState(false);
- const [isSelectionMode, setIsSelectionMode] = useState(false);
- const [currentPage, setCurrentPage] = useState(initialPage || 1); // 修正
- const [pdfBlob, setPdfBlob] = useState<Blob | null>(null);
- const [selectionData, setSelectionData] = useState<{ screenshot: Blob; text: string } | null>(null);
- const [numPages, setNumPages] = useState<number>(0);
- const [pdfDoc, setPdfDoc] = useState<pdfjs.PDFDocumentProxy | null>(null);
- const [zoomLevel, setZoomLevel] = useState<number>(1.0); // ズームレベルの状態を追加
- const currentRenderTask = useRef<pdfjs.RenderTask | null>(null); // 現在のレンダリングタスクを保存
- const { showToast } = useToast();
- const { t, language } = useLanguage();
- const containerRef = React.useRef<HTMLDivElement>(null);
- const canvasRef = useRef<HTMLCanvasElement>(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 (
- <div className="flex flex-col items-center justify-center h-full text-gray-500">
- <Loader size={48} className="animate-spin mb-4" />
- <div className="text-lg font-medium mb-2">{t('preparingPDFConversion')}</div>
- <div className="text-sm">{t('pleaseWait')}</div>
- </div>
- );
- case 'converting':
- return (
- <div className="flex flex-col items-center justify-center h-full text-gray-500">
- <Loader size={48} className="animate-spin mb-4" />
- <div className="text-lg font-medium mb-2">{t('convertingPDF')}</div>
- <div className="text-sm">{t('pleaseWait')}</div>
- </div>
- );
- case 'failed':
- return (
- <div className="flex flex-col items-center justify-center h-full text-red-500">
- <AlertCircle size={48} className="mb-4" />
- <div className="text-lg font-medium mb-2">{t('pdfConversionFailed')}</div>
- <div className="text-sm text-gray-500 text-center max-w-md">
- {status.error || t('pdfConversionError')}
- </div>
- <button
- onClick={checkPDFStatus}
- className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
- >
- {t('retry')}
- </button>
- </div>
- );
- case 'ready':
- if (iframeError) {
- return (
- <div className="flex flex-col items-center justify-center h-full text-gray-500">
- <AlertCircle size={48} className="mb-4" />
- <div className="text-lg font-medium mb-2">{t('pdfLoadFailed')}</div>
- <div className="text-sm text-gray-500 mb-4">{t('pdfLoadError')}</div>
- <div className="flex gap-2">
- <button
- onClick={handleDownload}
- className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
- >
- <Download size={16} />
- {t('downloadPDF')}
- </button>
- <button
- onClick={handleOpenInNewTab}
- className="flex items-center gap-2 px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors"
- >
- <ExternalLink size={16} />
- {t('openInNewWindow')}
- </button>
- </div>
- </div>
- );
- }
- if (!pdfDoc) {
- return (
- <div className="flex flex-col items-center justify-center h-full text-gray-500">
- <Loader size={48} className="animate-spin mb-4" />
- <div className="text-lg font-medium mb-2">{t('loadingPDF')}</div>
- </div>
- );
- }
- return (
- <div className="relative w-full h-full flex flex-col" ref={containerRef}>
- <div className="flex-grow overflow-auto pdf-canvas-container bg-white">
- <div className="flex items-center justify-center min-h-full p-4">
- <canvas
- ref={canvasRef}
- className="shadow-lg"
- style={{
- maxHeight: 'none',
- maxWidth: '100%',
- display: 'block'
- }}
- />
- </div>
- </div>
- {isSelectionMode && (
- <PDFSelectionTool
- containerRef={containerRef}
- canvasRef={canvasRef}
- pdfBlob={pdfBlob}
- pageNumber={currentPage}
- authToken={authToken}
- zoomLevel={zoomLevel}
- onSelectionComplete={handleSelectionComplete}
- onCancel={() => setIsSelectionMode(false)}
- />
- )}
- <div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 flex items-center gap-2 bg-white/90 border border-slate-200 px-3 py-1.5 rounded-full shadow-lg z-40">
- <div className="flex items-center border-r border-slate-200 pr-2 mr-2">
- <button
- onClick={handleZoomOut}
- className="p-1 hover:bg-slate-100 rounded text-slate-600 min-w-[28px] flex items-center justify-center"
- title={t('zoomOut')}
- >
- <span className="text-lg">−</span>
- </button>
- <span className="mx-1 text-sm text-slate-600 min-w-[40px] text-center">{Math.round(zoomLevel * 100)}%</span>
- <button
- onClick={handleZoomIn}
- className="p-1 hover:bg-slate-100 rounded text-slate-600 min-w-[28px] flex items-center justify-center"
- title={t('zoomIn')}
- >
- <span className="text-lg">+</span>
- </button>
- <button
- onClick={handleResetZoom}
- className="ml-1 p-1 px-2 hover:bg-slate-100 rounded text-slate-600 text-xs"
- title={t('resetZoom')}
- >
- 100%
- </button>
- </div>
- <button
- onClick={() => {
- const newPage = Math.max(1, currentPage - 1);
- setCurrentPage(newPage);
- }}
- className="p-1 hover:bg-slate-100 rounded text-slate-600"
- >
- <ChevronLeft size={16} />
- </button>
- <div className="flex items-center gap-1">
- <input
- type="number"
- value={currentPage}
- onChange={(e) => {
- 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"
- />
- <span className="text-sm text-slate-500">/ {numPages}</span>
- </div>
- <button
- onClick={() => {
- const newPage = Math.min(numPages, currentPage + 1);
- setCurrentPage(newPage);
- }}
- className="p-1 hover:bg-slate-100 rounded text-slate-600"
- >
- <ChevronRight size={16} />
- </button>
- </div>
- {selectionData && (
- <CreateNoteFromPDFDialog
- screenshot={selectionData.screenshot}
- extractedText={selectionData.text}
- authToken={authToken}
- initialGroupId={groupId}
- initialPageNumber={currentPage}
- onSave={handleSaveNote}
- onCancel={() => setSelectionData(null)}
- />
- )}
- </div>
- );
- default:
- return null;
- }
- };
- return (
- <div className={`fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-4'
- }`}>
- <div className={`bg-white rounded-lg overflow-hidden ${isFullscreen ? 'w-full h-full' : 'w-full max-w-4xl h-5/6'
- }`}>
- {/* 头部 */}
- <div className="flex items-center justify-between p-4 border-b bg-gray-50">
- <div className="flex items-center space-x-3">
- <FileText size={20} className="text-gray-600" />
- <div>
- <div className="font-medium text-gray-900">{fileName}</div>
- <div className="text-sm text-gray-500">{t('pdfPreview')}</div>
- </div>
- </div>
- <div className="flex items-center space-x-2">
- {status.status === 'ready' && !iframeError && (
- <>
- <div className="flex items-center gap-2 mr-2 border-r pr-2">
- <span className="text-sm text-gray-500">{t('selectPageNumber')}</span>
- <input
- type="number"
- min={1}
- max={numPages}
- value={currentPage}
- onChange={(e) => {
- 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')}
- />
- </div>
- <button
- onClick={() => setIsSelectionMode(!isSelectionMode)}
- className={`p-2 transition-colors ${isSelectionMode ? 'bg-blue-100 text-blue-600 rounded' : 'text-gray-400 hover:text-blue-600'}`}
- title={isSelectionMode ? t('exitSelectionMode') : t('clickToSelectAndNote')}
- >
- <Scissors size={18} />
- </button>
- <button
- onClick={handleRegenerate}
- className="p-2 text-gray-400 hover:text-blue-600 transition-colors"
- title={t('regeneratePDF')}
- >
- <RefreshCw size={18} />
- </button>
- <button
- onClick={handleDownload}
- className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
- title={t('downloadPDF')}
- >
- <Download size={18} />
- </button>
- <button
- onClick={handleOpenInNewTab}
- className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
- title={t('openInNewWindow')}
- >
- <ExternalLink size={18} />
- </button>
- <button
- onClick={handleFullscreen}
- className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
- title={isFullscreen ? t('exitFullscreen') : t('fullscreenDisplay')}
- >
- <Maximize2 size={18} />
- </button>
- </>
- )}
- <button
- onClick={onClose}
- className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
- >
- <X size={18} />
- </button>
- </div>
- </div>
- {/* 内容区域 */}
- <div className="flex-1 h-full">
- {renderContent()}
- </div>
- </div>
- </div>
- );
- };
- // 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<PDFPreviewButtonProps> = ({
- fileId,
- fileName,
- onPreview
- }) => {
- const [status, setStatus] = useState<PDFStatus>({ 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 <Loader className="w-3 h-3 animate-spin" />;
- }
- if (status.status === 'failed') {
- return <AlertCircle className="w-3 h-3" />;
- }
- return <Eye className="w-3 h-3" />;
- };
- 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 (
- <button
- onClick={onPreview}
- disabled={loading || status.status === 'converting'}
- className={`p-1 rounded transition-colors ${status.status === 'failed'
- ? 'text-red-400 hover:text-red-500 hover:bg-red-50'
- : 'text-slate-400 hover:text-blue-500 hover:bg-blue-50'
- } disabled:opacity-50 disabled:cursor-not-allowed`}
- title={getTitle()}
- >
- {getIcon()}
- </button>
- );
- };
|