PDFPreview.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712
  1. import React, { useState, useEffect, useRef } from 'react';
  2. import { isFormatSupportedForPreview } from '../constants/fileSupport';
  3. import { PDFStatus } from '../types';
  4. import { pdfPreviewService } from '../services/pdfPreviewService';
  5. import { useToast } from '../contexts/ToastContext';
  6. import { useConfirm } from '../contexts/ConfirmContext';
  7. import { X, FileText, Loader, AlertCircle, Maximize2, Eye, Download, ExternalLink, RefreshCw, Scissors, ChevronLeft, ChevronRight } from 'lucide-react';
  8. import { PDFSelectionTool } from './PDFSelectionTool';
  9. import { CreateNoteFromPDFDialog } from './CreateNoteFromPDFDialog';
  10. import { noteService } from '../services/noteService';
  11. import { useLanguage } from '../contexts/LanguageContext';
  12. import { knowledgeBaseService } from '../services/knowledgeBaseService';
  13. import * as pdfjs from 'pdfjs-dist';
  14. // Set worker path for PDF.js
  15. pdfjs.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.mjs`;
  16. interface PDFPreviewProps {
  17. fileId: string;
  18. fileName: string;
  19. authToken: string;
  20. groupId?: string;
  21. onClose: () => void;
  22. }
  23. export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authToken, groupId, onClose }) => {
  24. const [status, setStatus] = useState<PDFStatus>({ status: 'pending' });
  25. const [loading, setLoading] = useState(true);
  26. const [isFullscreen, setIsFullscreen] = useState(false);
  27. const [pdfUrl, setPdfUrl] = useState<string>('');
  28. const [iframeError, setIframeError] = useState(false);
  29. const [isSelectionMode, setIsSelectionMode] = useState(false);
  30. const [currentPage, setCurrentPage] = useState(1);
  31. const [pdfBlob, setPdfBlob] = useState<Blob | null>(null);
  32. const [selectionData, setSelectionData] = useState<{ screenshot: Blob; text: string } | null>(null);
  33. const [numPages, setNumPages] = useState<number>(0);
  34. const [pdfDoc, setPdfDoc] = useState<pdfjs.PDFDocumentProxy | null>(null);
  35. const [zoomLevel, setZoomLevel] = useState<number>(1.0); // ズームレベルの状態を追加
  36. const currentRenderTask = useRef<pdfjs.RenderTask | null>(null); // 現在のレンダリングタスクを保存
  37. const scrollContainerRef = useRef<HTMLDivElement>(null);
  38. const flipDirection = useRef<'next' | 'prev' | null>(null);
  39. const lastFlipTime = useRef<number>(0);
  40. const { showToast } = useToast();
  41. const { confirm } = useConfirm();
  42. const { t, language } = useLanguage();
  43. const containerRef = React.useRef<HTMLDivElement>(null);
  44. const canvasRef = useRef<HTMLCanvasElement>(null);
  45. useEffect(() => {
  46. if (status.status === 'ready') {
  47. pdfPreviewService.getPDFUrl(fileId)
  48. .then(result => {
  49. setPdfUrl(result.url); // ダウンロード用にpdfUrlを設定
  50. // PDFデータを取得してblob URLを作成
  51. fetch(result.url)
  52. .then(async response => {
  53. if (!response.ok) {
  54. const errorData = await response.json().catch(() => ({}));
  55. throw new Error(errorData.message || 'Failed to fetch PDF data');
  56. }
  57. return response.blob();
  58. })
  59. .then(blob => {
  60. setPdfBlob(blob);
  61. // PDF文書の読み込みとレンダリングを開始
  62. loadAndRenderPDF(blob);
  63. })
  64. .catch((err) => {
  65. console.error('PDF fetch error:', err);
  66. setIframeError(true);
  67. setStatus({ status: 'failed', error: err.message });
  68. });
  69. })
  70. .catch((err) => {
  71. console.error('getPDFUrl error:', err);
  72. setIframeError(true);
  73. setStatus({ status: 'failed', error: err.message });
  74. });
  75. }
  76. }, [status.status, fileId]);
  77. useEffect(() => {
  78. if (pdfDoc && currentPage) {
  79. // ページ切り替えまたはズームレベル変更時に再レンダリング
  80. renderCurrentPage(pdfDoc, currentPage);
  81. }
  82. }, [currentPage, pdfDoc, zoomLevel]);
  83. const isSupported = isFormatSupportedForPreview(fileName);
  84. useEffect(() => {
  85. if (isSupported) {
  86. checkPDFStatus();
  87. const interval = setInterval(checkPDFStatus, 3000);
  88. return () => clearInterval(interval);
  89. } else {
  90. setLoading(false);
  91. setStatus({ status: 'failed', error: t('previewNotSupported') });
  92. }
  93. }, [fileId, isSupported]);
  94. const checkPDFStatus = async () => {
  95. try {
  96. const pdfStatus = await pdfPreviewService.getPDFStatus(fileId);
  97. setStatus(pdfStatus);
  98. // ステータスがpendingの場合、変換を能動的にトリガー
  99. if (pdfStatus.status === 'pending') {
  100. setStatus({ status: 'converting' });
  101. try {
  102. // PDF URLにアクセスして変換をトリガー
  103. await pdfPreviewService.preloadPDF(fileId);
  104. } catch (error) {
  105. console.log('Preload triggered, conversion should start');
  106. }
  107. }
  108. if (pdfStatus.status === 'ready' || pdfStatus.status === 'failed') {
  109. setLoading(false);
  110. }
  111. } catch (error: any) {
  112. setLoading(false);
  113. const errorMessage = error.message || t('checkPDFStatusFailed');
  114. setStatus({ status: 'failed', error: errorMessage });
  115. showToast(errorMessage, 'error');
  116. }
  117. };
  118. const loadAndRenderPDF = async (blob: Blob) => {
  119. try {
  120. const pdfData = await blob.arrayBuffer();
  121. const pdf = await pdfjs.getDocument({ data: pdfData }).promise;
  122. setPdfDoc(pdf);
  123. setNumPages(pdf.numPages);
  124. if (currentPage > pdf.numPages) {
  125. setCurrentPage(pdf.numPages);
  126. }
  127. renderCurrentPage(pdf, currentPage);
  128. } catch (error) {
  129. console.error('Failed to load PDF:', error);
  130. setIframeError(true);
  131. }
  132. };
  133. const handleZoomIn = () => {
  134. setZoomLevel(prev => Math.min(3.0, prev + 0.1));
  135. };
  136. const handleZoomOut = () => {
  137. setZoomLevel(prev => Math.max(0.5, prev - 0.1));
  138. };
  139. const handleResetZoom = () => {
  140. setZoomLevel(1.0);
  141. };
  142. const renderCurrentPage = async (pdf: pdfjs.PDFDocumentProxy, pageNum: number) => {
  143. if (!canvasRef.current) return;
  144. try {
  145. // 進行中のレンダリングタスクが存在する場合、キャンセルする
  146. if (currentRenderTask.current) {
  147. currentRenderTask.current.cancel();
  148. }
  149. const page = await pdf.getPage(pageNum);
  150. const canvas = canvasRef.current;
  151. const context = canvas.getContext('2d');
  152. if (!context) return;
  153. // Get container dimensions
  154. if (!containerRef.current) return;
  155. const container = containerRef.current;
  156. const containerWidth = container.clientWidth;
  157. const containerHeight = container.clientHeight;
  158. // Handle high DPI displays
  159. const devicePixelRatio = window.devicePixelRatio || 1;
  160. // Calculate scale to fit page in container width while maintaining aspect ratio
  161. const viewport = page.getViewport({ scale: 1 });
  162. const baseScale = (containerWidth - 48) / viewport.width; // Add padding for width
  163. // Apply zoom level to base scale
  164. const scale = baseScale * zoomLevel;
  165. const finalScale = scale * devicePixelRatio;
  166. const scaledViewport = page.getViewport({ scale: finalScale });
  167. const cssViewport = page.getViewport({ scale: scale });
  168. // Set canvas dimensions with device pixel ratio
  169. canvas.width = scaledViewport.width;
  170. canvas.height = scaledViewport.height;
  171. // Set CSS dimensions explicitly for high-DPI
  172. canvas.style.width = `${cssViewport.width}px`;
  173. canvas.style.height = `${cssViewport.height}px`;
  174. // Reset any previous transforms
  175. context.setTransform(1, 0, 0, 1, 0, 0);
  176. // Clear canvas
  177. context.clearRect(0, 0, canvas.width, canvas.height);
  178. // Fill with white background
  179. context.fillStyle = 'white';
  180. context.fillRect(0, 0, canvas.width, canvas.height);
  181. // Set proper transform for high DPI
  182. context.scale(devicePixelRatio, devicePixelRatio);
  183. // Create and save the new render task
  184. const renderContext = {
  185. canvasContext: context,
  186. viewport: page.getViewport({ scale: scale }),
  187. };
  188. // Save the render task to allow for cancellation
  189. currentRenderTask.current = page.render(renderContext);
  190. // Wait for rendering to complete
  191. await currentRenderTask.current.promise;
  192. // Clear the current render task
  193. currentRenderTask.current = null;
  194. // ページめくり後のスクロール位置調整
  195. if (flipDirection.current && scrollContainerRef.current) {
  196. const container = scrollContainerRef.current;
  197. if (flipDirection.current === 'next') {
  198. container.scrollTop = 0;
  199. } else if (flipDirection.current === 'prev') {
  200. container.scrollTop = container.scrollHeight;
  201. }
  202. flipDirection.current = null;
  203. }
  204. } catch (error) {
  205. if (error instanceof Error && error.name !== 'RenderingCancelledException') {
  206. console.error('Failed to render PDF page:', error);
  207. }
  208. // Clear the current render task even if there's an error
  209. currentRenderTask.current = null;
  210. }
  211. };
  212. const handleFullscreen = () => {
  213. setIsFullscreen(!isFullscreen);
  214. };
  215. const handleDownload = () => {
  216. if (pdfUrl) {
  217. // pdfUrlが既にある場合、直接ダウンロード
  218. const link = document.createElement('a');
  219. link.href = pdfUrl;
  220. link.download = fileName.replace(/\.[^/.]+$/, '.pdf');
  221. document.body.appendChild(link);
  222. link.click();
  223. document.body.removeChild(link);
  224. } else {
  225. // pdfUrlがない場合、直接取得してダウンロードを試みる
  226. pdfPreviewService.getPDFUrl(fileId)
  227. .then(result => {
  228. const link = document.createElement('a');
  229. link.href = result.url;
  230. link.download = fileName.replace(/\.[^/.]+$/, '.pdf');
  231. document.body.appendChild(link);
  232. link.click();
  233. document.body.removeChild(link);
  234. })
  235. .catch(error => {
  236. console.error('Failed to download PDF:', error);
  237. showToast('error', t('downloadPDFFailed'));
  238. });
  239. }
  240. };
  241. const handleOpenInNewTab = () => {
  242. if (pdfUrl) {
  243. window.open(pdfUrl, '_blank');
  244. } else {
  245. // pdfUrlがない場合、直接取得して開くことを試みる
  246. pdfPreviewService.getPDFUrl(fileId)
  247. .then(result => {
  248. window.open(result.url, '_blank');
  249. })
  250. .catch(error => {
  251. console.error('Failed to open PDF in new tab:', error);
  252. showToast('error', t('openPDFInNewTabFailed'));
  253. });
  254. }
  255. };
  256. const handleRegenerate = async () => {
  257. if (await confirm(t('confirmRegeneratePDF'))) {
  258. setStatus({ status: 'converting' });
  259. setLoading(true);
  260. try {
  261. await pdfPreviewService.preloadPDF(fileId, true);
  262. // 状態をリセットして再読み込みをトリガー
  263. setPdfUrl('');
  264. setIframeError(false);
  265. setPdfDoc(null);
  266. setPdfBlob(null);
  267. setNumPages(0);
  268. } catch (error) {
  269. showToast('error', t('requestRegenerationFailed'));
  270. setStatus({ status: 'failed', error: t('requestRegenerationFailed') });
  271. }
  272. }
  273. };
  274. const handleIframeError = () => {
  275. setIframeError(true);
  276. };
  277. const handleWheel = (e: React.WheelEvent) => {
  278. if (!scrollContainerRef.current || isSelectionMode) return;
  279. const container = scrollContainerRef.current;
  280. const { scrollTop, scrollHeight, clientHeight } = container;
  281. const now = Date.now();
  282. const throttleMs = 600; // 連続ページめくりを防止
  283. // 下にスクロールして次のページへ
  284. if (e.deltaY > 0 && scrollTop + clientHeight >= scrollHeight - 1) {
  285. if (currentPage < numPages && now - lastFlipTime.current > throttleMs) {
  286. flipDirection.current = 'next';
  287. lastFlipTime.current = now;
  288. setCurrentPage(prev => prev + 1);
  289. }
  290. }
  291. // 上にスクロールして前のページへ
  292. else if (e.deltaY < 0 && scrollTop <= 1) {
  293. if (currentPage > 1 && now - lastFlipTime.current > throttleMs) {
  294. flipDirection.current = 'prev';
  295. lastFlipTime.current = now;
  296. setCurrentPage(prev => prev - 1);
  297. }
  298. }
  299. };
  300. const handleSelectionComplete = (screenshot: Blob, text: string) => {
  301. // Set preliminary data and open dialog
  302. setSelectionData({ screenshot, text });
  303. setIsSelectionMode(false);
  304. };
  305. const handleSaveNote = async (title: string, content: string, selectedGroupId?: string) => {
  306. if (!authToken || !selectionData) return;
  307. try {
  308. await noteService.createFromPDFSelection(
  309. authToken,
  310. fileId,
  311. selectionData.screenshot,
  312. selectedGroupId || groupId,
  313. currentPage
  314. );
  315. showToast('success', t('noteCreatedSuccess'));
  316. setSelectionData(null);
  317. } catch (error) {
  318. console.error('Failed to create note:', error);
  319. showToast('error', t('noteCreatedFailed'));
  320. }
  321. };
  322. const renderContent = () => {
  323. switch (status.status) {
  324. case 'pending':
  325. return (
  326. <div className="flex flex-col items-center justify-center h-full text-gray-500">
  327. <Loader size={48} className="animate-spin mb-4" />
  328. <div className="text-lg font-medium mb-2">{t('preparingPDFConversion')}</div>
  329. <div className="text-sm">{t('pleaseWait')}</div>
  330. </div>
  331. );
  332. case 'converting':
  333. return (
  334. <div className="flex flex-col items-center justify-center h-full text-gray-500">
  335. <Loader size={48} className="animate-spin mb-4" />
  336. <div className="text-lg font-medium mb-2">{t('convertingPDF')}</div>
  337. <div className="text-sm">{t('pleaseWait')}</div>
  338. </div>
  339. );
  340. case 'failed':
  341. return (
  342. <div className="flex flex-col items-center justify-center h-full text-red-500">
  343. <AlertCircle size={48} className="mb-4" />
  344. <div className="text-lg font-medium mb-2">{t('pdfConversionFailed')}</div>
  345. <div className="text-sm text-gray-500 text-center max-w-md">
  346. {status.error || t('pdfConversionError')}
  347. </div>
  348. <button
  349. onClick={checkPDFStatus}
  350. className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
  351. >
  352. {t('retry')}
  353. </button>
  354. </div>
  355. );
  356. case 'ready':
  357. if (iframeError) {
  358. return (
  359. <div className="flex flex-col items-center justify-center h-full text-gray-500">
  360. <AlertCircle size={48} className="mb-4" />
  361. <div className="text-lg font-medium mb-2">{t('pdfLoadFailed')}</div>
  362. <div className="text-sm text-gray-500 mb-4">{t('pdfLoadError')}</div>
  363. <div className="flex gap-2">
  364. <button
  365. onClick={handleDownload}
  366. className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
  367. >
  368. <Download size={16} />
  369. {t('downloadPDF')}
  370. </button>
  371. <button
  372. onClick={handleOpenInNewTab}
  373. className="flex items-center gap-2 px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors"
  374. >
  375. <ExternalLink size={16} />
  376. {t('openInNewWindow')}
  377. </button>
  378. </div>
  379. </div>
  380. );
  381. }
  382. if (!pdfDoc) {
  383. return (
  384. <div className="flex flex-col items-center justify-center h-full text-gray-500">
  385. <Loader size={48} className="animate-spin mb-4" />
  386. <div className="text-lg font-medium mb-2">{t('loadingPDF')}</div>
  387. </div>
  388. );
  389. }
  390. return (
  391. <div className="relative w-full h-full flex flex-col" ref={containerRef}>
  392. <div
  393. ref={scrollContainerRef}
  394. onWheel={handleWheel}
  395. className="flex-grow overflow-auto pdf-canvas-container bg-gray-100"
  396. >
  397. <div className="flex flex-col items-center py-12 pb-32 min-h-full">
  398. <canvas
  399. ref={canvasRef}
  400. className="bg-white shadow-xl max-w-full"
  401. />
  402. </div>
  403. </div>
  404. {isSelectionMode && (
  405. <PDFSelectionTool
  406. containerRef={containerRef}
  407. canvasRef={canvasRef}
  408. pdfBlob={pdfBlob}
  409. pageNumber={currentPage}
  410. authToken={authToken}
  411. zoomLevel={zoomLevel}
  412. onSelectionComplete={handleSelectionComplete}
  413. onCancel={() => setIsSelectionMode(false)}
  414. />
  415. )}
  416. <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">
  417. <div className="flex items-center border-r border-slate-200 pr-2 mr-2">
  418. <button
  419. onClick={handleZoomOut}
  420. className="p-1 hover:bg-slate-100 rounded text-slate-600 min-w-[28px] flex items-center justify-center"
  421. title={t('zoomOut')}
  422. >
  423. <span className="text-lg">−</span>
  424. </button>
  425. <span className="mx-1 text-sm text-slate-600 min-w-[40px] text-center">{Math.round(zoomLevel * 100)}%</span>
  426. <button
  427. onClick={handleZoomIn}
  428. className="p-1 hover:bg-slate-100 rounded text-slate-600 min-w-[28px] flex items-center justify-center"
  429. title={t('zoomIn')}
  430. >
  431. <span className="text-lg">+</span>
  432. </button>
  433. <button
  434. onClick={handleResetZoom}
  435. className="ml-1 p-1 px-2 hover:bg-slate-100 rounded text-slate-600 text-xs"
  436. title={t('resetZoom')}
  437. >
  438. 100%
  439. </button>
  440. </div>
  441. <button
  442. onClick={() => {
  443. const newPage = Math.max(1, currentPage - 1);
  444. setCurrentPage(newPage);
  445. }}
  446. className="p-1 hover:bg-slate-100 rounded text-slate-600"
  447. >
  448. <ChevronLeft size={16} />
  449. </button>
  450. <div className="flex items-center gap-1">
  451. <input
  452. type="number"
  453. value={currentPage}
  454. onChange={(e) => {
  455. const val = Math.max(1, Math.min(numPages, parseInt(e.target.value) || 1));
  456. setCurrentPage(val);
  457. }}
  458. className="w-12 text-center text-sm border-none focus:ring-0 bg-transparent font-medium"
  459. />
  460. <span className="text-sm text-slate-500">/ {numPages}</span>
  461. </div>
  462. <button
  463. onClick={() => {
  464. const newPage = Math.min(numPages, currentPage + 1);
  465. setCurrentPage(newPage);
  466. }}
  467. className="p-1 hover:bg-slate-100 rounded text-slate-600"
  468. >
  469. <ChevronRight size={16} />
  470. </button>
  471. </div>
  472. {selectionData && (
  473. <CreateNoteFromPDFDialog
  474. screenshot={selectionData.screenshot}
  475. extractedText={selectionData.text}
  476. authToken={authToken}
  477. initialGroupId={groupId}
  478. initialPageNumber={currentPage}
  479. onSave={handleSaveNote}
  480. onCancel={() => setSelectionData(null)}
  481. />
  482. )}
  483. </div>
  484. );
  485. default:
  486. return null;
  487. }
  488. };
  489. return (
  490. <div className={`fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[999] ${isFullscreen ? 'p-0' : 'p-4'
  491. }`}>
  492. <div className={`bg-white rounded-lg overflow-hidden flex flex-col ${isFullscreen ? 'w-full h-full' : 'w-full max-w-4xl h-5/6'
  493. }`}>
  494. {/* 头部 */}
  495. <div className="flex items-center justify-between p-4 border-b bg-gray-50">
  496. <div className="flex items-center space-x-3">
  497. <FileText size={20} className="text-gray-600" />
  498. <div>
  499. <div className="font-medium text-gray-900">{fileName}</div>
  500. <div className="text-sm text-gray-500">{t('pdfPreview')}</div>
  501. </div>
  502. </div>
  503. <div className="flex items-center space-x-2">
  504. {status.status === 'ready' && !iframeError && (
  505. <>
  506. <div className="flex items-center gap-2 mr-2 border-r pr-2">
  507. <span className="text-sm text-gray-500">{t('selectPageNumber')}</span>
  508. <input
  509. type="number"
  510. min={1}
  511. max={numPages}
  512. value={currentPage}
  513. onChange={(e) => {
  514. const val = Math.max(1, Math.min(numPages, parseInt(e.target.value) || 1));
  515. setCurrentPage(val);
  516. }}
  517. className="w-16 px-2 py-1 border rounded text-sm"
  518. title={t('enterPageNumber')}
  519. />
  520. </div>
  521. <button
  522. onClick={() => setIsSelectionMode(!isSelectionMode)}
  523. className={`p-2 transition-colors ${isSelectionMode ? 'bg-blue-100 text-blue-600 rounded' : 'text-gray-400 hover:text-blue-600'}`}
  524. title={isSelectionMode ? t('exitSelectionMode') : t('clickToSelectAndNote')}
  525. >
  526. <Scissors size={18} />
  527. </button>
  528. <button
  529. onClick={handleRegenerate}
  530. className="p-2 text-gray-400 hover:text-blue-600 transition-colors"
  531. title={t('regeneratePDF')}
  532. >
  533. <RefreshCw size={18} />
  534. </button>
  535. <button
  536. onClick={handleDownload}
  537. className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
  538. title={t('downloadPDF')}
  539. >
  540. <Download size={18} />
  541. </button>
  542. <button
  543. onClick={handleOpenInNewTab}
  544. className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
  545. title={t('openInNewWindow')}
  546. >
  547. <ExternalLink size={18} />
  548. </button>
  549. <button
  550. onClick={handleFullscreen}
  551. className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
  552. title={isFullscreen ? t('exitFullscreen') : t('fullscreenDisplay')}
  553. >
  554. <Maximize2 size={18} />
  555. </button>
  556. </>
  557. )}
  558. <button
  559. onClick={onClose}
  560. className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
  561. >
  562. <X size={18} />
  563. </button>
  564. </div>
  565. </div>
  566. {/* 内容区域 */}
  567. <div className="flex-1 h-full">
  568. {renderContent()}
  569. </div>
  570. </div>
  571. </div>
  572. );
  573. };
  574. interface PDFPreviewButtonProps {
  575. fileId: string;
  576. fileName: string;
  577. onPreview: () => void;
  578. }
  579. export const PDFPreviewButton: React.FC<PDFPreviewButtonProps> = ({
  580. fileId,
  581. fileName,
  582. onPreview
  583. }) => {
  584. const [status, setStatus] = useState<PDFStatus>({ status: 'pending' });
  585. const [loading, setLoading] = useState(true);
  586. const { t } = useLanguage();
  587. const isSupported = isFormatSupportedForPreview(fileName);
  588. useEffect(() => {
  589. if (isSupported) {
  590. checkStatus();
  591. } else {
  592. setLoading(false);
  593. }
  594. }, [fileId, isSupported]);
  595. const checkStatus = async () => {
  596. try {
  597. const pdfStatus = await pdfPreviewService.getPDFStatus(fileId);
  598. setStatus(pdfStatus);
  599. } catch (error) {
  600. // エラーを無視し、デフォルト状態を使用
  601. } finally {
  602. setLoading(false);
  603. }
  604. };
  605. const getIcon = () => {
  606. if (!isSupported) {
  607. return <Eye className="w-3 h-3 text-slate-200" />;
  608. }
  609. if (loading || status.status === 'converting') {
  610. return <Loader className="w-3 h-3 animate-spin" />;
  611. }
  612. if (status.status === 'failed') {
  613. return <AlertCircle className="w-3 h-3" />;
  614. }
  615. return <Eye className="w-3 h-3" />;
  616. };
  617. const getTitle = () => {
  618. if (!isSupported) return t('previewNotSupported');
  619. switch (status.status) {
  620. case 'ready': return t('pdfPreviewReady');
  621. case 'converting': return t('convertingInProgress');
  622. case 'failed': return t('conversionFailed');
  623. default: return t('generatePDFPreviewButton');
  624. }
  625. };
  626. return (
  627. <button
  628. onClick={onPreview}
  629. disabled={loading || status.status === 'converting' || !isSupported}
  630. className={`p-1 rounded transition-colors ${!isSupported
  631. ? 'text-slate-200 cursor-not-allowed'
  632. : status.status === 'failed'
  633. ? 'text-red-400 hover:text-red-500 hover:bg-red-50'
  634. : 'text-slate-400 hover:text-blue-500 hover:bg-blue-50'
  635. } disabled:opacity-50 disabled:cursor-not-allowed`}
  636. title={getTitle()}
  637. >
  638. {getIcon()}
  639. </button>
  640. );
  641. };