PDFPreview.tsx 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867
  1. import React, { useState, useEffect, useRef } from 'react';
  2. import { PDFStatus } from '../types';
  3. import { pdfPreviewService } from '../services/pdfPreviewService';
  4. import { useToast } from '../contexts/ToastContext';
  5. import { X, FileText, Loader, AlertCircle, Maximize2, Eye, Download, ExternalLink, RefreshCw, Scissors, ChevronLeft, ChevronRight } from 'lucide-react';
  6. import { PDFSelectionTool } from './PDFSelectionTool';
  7. import { CreateNoteFromPDFDialog } from './CreateNoteFromPDFDialog';
  8. import { noteService } from '../services/noteService';
  9. import { useLanguage } from '../contexts/LanguageContext';
  10. import { knowledgeBaseService } from '../services/knowledgeBaseService';
  11. import * as pdfjs from 'pdfjs-dist';
  12. // Set worker path for PDF.js
  13. pdfjs.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.mjs`;
  14. interface PDFPreviewProps {
  15. fileId: string;
  16. fileName: string;
  17. authToken: string;
  18. groupId?: string;
  19. initialPage?: number; // 追加
  20. highlightText?: string; // 追加
  21. onClose: () => void;
  22. }
  23. export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authToken, groupId, initialPage, highlightText, 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(initialPage || 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 { showToast } = useToast();
  38. const { t, language } = useLanguage();
  39. const containerRef = React.useRef<HTMLDivElement>(null);
  40. const canvasRef = useRef<HTMLCanvasElement>(null);
  41. useEffect(() => {
  42. if (initialPage && initialPage > 0) {
  43. setCurrentPage(initialPage);
  44. }
  45. }, [initialPage]);
  46. useEffect(() => {
  47. if (status.status === 'ready') {
  48. pdfPreviewService.getPDFUrl(fileId)
  49. .then(result => {
  50. setPdfUrl(result.url); // ダウンロード用にpdfUrlを設定
  51. // PDFデータを取得してblob URLを作成
  52. fetch(result.url)
  53. .then(response => response.blob())
  54. .then(blob => {
  55. setPdfBlob(blob);
  56. // PDF文書の読み込みとレンダリングを開始
  57. loadAndRenderPDF(blob);
  58. })
  59. .catch(() => setIframeError(true));
  60. })
  61. .catch(() => setIframeError(true));
  62. }
  63. }, [status.status, fileId]);
  64. useEffect(() => {
  65. if (pdfDoc && currentPage) {
  66. // ページ切り替えまたはズームレベル変更時に再レンダリング
  67. renderCurrentPage(pdfDoc, currentPage);
  68. }
  69. }, [currentPage, pdfDoc, zoomLevel]);
  70. useEffect(() => {
  71. checkPDFStatus();
  72. const interval = setInterval(checkPDFStatus, 3000);
  73. return () => clearInterval(interval);
  74. }, [fileId]);
  75. // スクロールページめくり機能を追加
  76. useEffect(() => {
  77. const container = containerRef.current;
  78. if (!container) return;
  79. const handleWheel = (e: WheelEvent) => {
  80. if (!pdfDoc || e.shiftKey) return; // Shiftキーが押されている場合はページめくりをトリガーしない(水平スクロールを許可)
  81. // スクロール対象がcanvasコンテナ内にあるかチェック
  82. const canvasContainer = container.querySelector('.pdf-canvas-container');
  83. if (!canvasContainer) return;
  84. // スクロールイベントがcanvasコンテナ内で発生することを確認
  85. if (canvasContainer.contains(e.target as HTMLElement)) {
  86. // Ctrlキーが押されている場合はズームを実行し、ページめくりはしない
  87. if (e.ctrlKey || e.metaKey) {
  88. e.preventDefault();
  89. const zoomIncrement = e.deltaY > 0 ? -0.1 : 0.1;
  90. const newZoom = Math.max(0.5, Math.min(3.0, zoomLevel + zoomIncrement));
  91. setZoomLevel(newZoom);
  92. return;
  93. }
  94. // スクロール方向を検出(Ctrlキー以外の場合)
  95. if (e.deltaY > 0 && currentPage < numPages) {
  96. // 下にスクロール、次のページへ
  97. e.preventDefault();
  98. setCurrentPage(prev => Math.min(prev + 1, numPages));
  99. } else if (e.deltaY < 0 && currentPage > 1) {
  100. // 上にスクロール、前のページへ
  101. e.preventDefault();
  102. setCurrentPage(prev => Math.max(prev - 1, 1));
  103. }
  104. }
  105. };
  106. container.addEventListener('wheel', handleWheel, { passive: false });
  107. return () => container.removeEventListener('wheel', handleWheel);
  108. }, [currentPage, numPages, pdfDoc, zoomLevel]);
  109. const checkPDFStatus = async () => {
  110. try {
  111. const pdfStatus = await pdfPreviewService.getPDFStatus(fileId);
  112. setStatus(pdfStatus);
  113. // ステータスがpendingの場合、変換を能動的にトリガー
  114. if (pdfStatus.status === 'pending') {
  115. setStatus({ status: 'converting' });
  116. try {
  117. // PDF URLにアクセスして変換をトリガー
  118. await pdfPreviewService.preloadPDF(fileId);
  119. } catch (error) {
  120. console.log('Preload triggered, conversion should start');
  121. }
  122. }
  123. if (pdfStatus.status === 'ready' || pdfStatus.status === 'failed') {
  124. setLoading(false);
  125. }
  126. } catch (error) {
  127. setLoading(false);
  128. setStatus({ status: 'failed', error: t('checkPDFStatusFailed') });
  129. showToast('error', t('checkPDFStatusFailed'));
  130. }
  131. };
  132. const loadAndRenderPDF = async (blob: Blob) => {
  133. try {
  134. const pdfData = await blob.arrayBuffer();
  135. const pdf = await pdfjs.getDocument({ data: pdfData }).promise;
  136. setPdfDoc(pdf);
  137. setNumPages(pdf.numPages);
  138. if (currentPage > pdf.numPages) {
  139. setCurrentPage(pdf.numPages);
  140. }
  141. renderCurrentPage(pdf, currentPage);
  142. } catch (error) {
  143. console.error('Failed to load PDF:', error);
  144. setIframeError(true);
  145. }
  146. };
  147. const handleZoomIn = () => {
  148. setZoomLevel(prev => Math.min(3.0, prev + 0.1));
  149. };
  150. const handleZoomOut = () => {
  151. setZoomLevel(prev => Math.max(0.5, prev - 0.1));
  152. };
  153. const handleResetZoom = () => {
  154. setZoomLevel(1.0);
  155. };
  156. const renderRequestId = useRef(0);
  157. const renderCurrentPage = async (pdf: pdfjs.PDFDocumentProxy, pageNum: number) => {
  158. if (!canvasRef.current) return;
  159. // 今回のレンダリングリクエストに一意のIDを割り当てる
  160. const requestId = ++renderRequestId.current;
  161. let renderTask: pdfjs.RenderTask | null = null;
  162. try {
  163. // 進行中のレンダリングタスクが存在する場合、キャンセルして完了を持ち越す
  164. if (currentRenderTask.current) {
  165. currentRenderTask.current.cancel();
  166. try {
  167. await currentRenderTask.current.promise;
  168. } catch (e) {
  169. // キャンセルによるエラーは無視
  170. }
  171. }
  172. // 待機中に新しいリクエストが来た場合、このリクエストは中止する
  173. if (requestId !== renderRequestId.current) {
  174. return;
  175. }
  176. const page = await pdf.getPage(pageNum);
  177. const canvas = canvasRef.current;
  178. const context = canvas.getContext('2d');
  179. if (!context) return;
  180. // Get container dimensions
  181. if (!containerRef.current) return;
  182. const container = containerRef.current;
  183. const containerWidth = container.clientWidth;
  184. const containerHeight = container.clientHeight;
  185. // Handle high DPI displays
  186. const devicePixelRatio = window.devicePixelRatio || 1;
  187. // Calculate scale to fit page in container while maintaining aspect ratio
  188. const viewport = page.getViewport({ scale: 1 });
  189. const baseScaleX = containerWidth / viewport.width;
  190. const baseScaleY = containerHeight / viewport.height;
  191. // Use minimum scale to fit the page in container, then apply zoom level
  192. let scale = Math.min(baseScaleX, baseScaleY, 1); // Don't upscale beyond 1:1 by default
  193. // Apply zoom level
  194. scale *= zoomLevel;
  195. const finalScale = scale * devicePixelRatio;
  196. const scaledViewport = page.getViewport({ scale: finalScale });
  197. // Set canvas dimensions with device pixel ratio
  198. canvas.width = scaledViewport.width;
  199. canvas.height = scaledViewport.height;
  200. // Reset any previous transforms
  201. context.setTransform(1, 0, 0, 1, 0, 0);
  202. // Clear canvas
  203. context.clearRect(0, 0, canvas.width, canvas.height);
  204. // Fill with white background
  205. context.fillStyle = 'white';
  206. context.fillRect(0, 0, canvas.width, canvas.height);
  207. // Set proper transform for high DPI
  208. context.scale(devicePixelRatio, devicePixelRatio);
  209. // 実際のレンダリング直前にもう一度チェック
  210. if (requestId !== renderRequestId.current) {
  211. return;
  212. }
  213. // Create and save the new render task
  214. const renderContext = {
  215. canvasContext: context,
  216. viewport: page.getViewport({ scale: scale }),
  217. };
  218. // Save the render task to allow for cancellation
  219. renderTask = page.render(renderContext);
  220. currentRenderTask.current = renderTask;
  221. // Wait for rendering to complete
  222. await renderTask.promise;
  223. // Render Text Layer
  224. if (requestId === renderRequestId.current) {
  225. const textContent = await page.getTextContent();
  226. // Find or create text layer div
  227. let textLayerDiv = container.querySelector('.textLayer') as HTMLDivElement;
  228. if (!textLayerDiv) {
  229. textLayerDiv = document.createElement('div');
  230. textLayerDiv.className = 'textLayer';
  231. // Ensure container has relative positioning
  232. const canvasContainer = container.querySelector('.pdf-canvas-container .flex');
  233. if (canvasContainer) {
  234. (canvasContainer as HTMLElement).style.position = 'relative';
  235. (canvasContainer as HTMLElement).appendChild(textLayerDiv);
  236. }
  237. }
  238. // Reset text layer
  239. textLayerDiv.innerHTML = '';
  240. textLayerDiv.style.position = 'absolute';
  241. textLayerDiv.style.top = '0';
  242. textLayerDiv.style.left = '0';
  243. textLayerDiv.style.height = `${scaledViewport.height}px`;
  244. textLayerDiv.style.width = `${scaledViewport.width}px`;
  245. // Apply transform to match high DPI scaling if needed, essentially we want it to overlay the canvas exactly
  246. // The canvas is scaled by devicePixelRatio via CSS width/height vs attribute width/height
  247. // But text layer usually acts on CSS pixels.
  248. // If scaledViewport was created with finalScale (scale * devicePixelRatio), then it's in device pixels.
  249. // We might need to adjust.
  250. // Actually, pdf.js text layer expects viewport to be the same as used for rendering?
  251. // Typically we render text layer at 1:1 CSS pixel mapping if possible or match the viewport.
  252. // Let's use the viewport that matches the visual size (CSS pixels)
  253. const cssViewport = page.getViewport({ scale: scale });
  254. textLayerDiv.style.width = `${cssViewport.width}px`;
  255. textLayerDiv.style.height = `${cssViewport.height}px`;
  256. textLayerDiv.style.setProperty('--scale-factor', `${scale}`);
  257. // We need to import renderTextLayer dynamically or check availability
  258. // Since we imported * as pdfjs, let's try pdfjs.renderTextLayer
  259. // Note: In some versions it's inside pdfjs.pdfjsLib or similar.
  260. // But typically we construct a TextLayerBuilder or use renderTextLayer utility.
  261. // For simplicity in React without extra libs, we can try to render it manually or use the basic API if available.
  262. // However, pdfjs-dist v4 has `pdfjs.renderTextLayer({ textContent, container, viewport, textDivs })`.
  263. // In pdfjs-dist v4, renderTextLayer is removed/deprecated in favor of using TextLayer class directly.
  264. // We use the exported TextLayer class.
  265. const TextLayerClass = (pdfjs as any).TextLayer;
  266. if (TextLayerClass) {
  267. const textLayer = new TextLayerClass({
  268. textContentSource: textContent,
  269. container: textLayerDiv,
  270. viewport: cssViewport,
  271. textDivs: []
  272. });
  273. await textLayer.render();
  274. } else {
  275. console.error('TextLayer class not found in pdfjs-dist');
  276. }
  277. // Apply Highlights
  278. if (highlightText) {
  279. console.log('Attempting to highlight:', highlightText);
  280. const normalize = (str: string) => str.toLowerCase().replace(/\s+/g, '');
  281. const searchStr = normalize(highlightText);
  282. const spans = Array.from(textLayerDiv.querySelectorAll('span'));
  283. let found = false;
  284. // Strategy 1: Check for exact match across spans (if text is fragmented)
  285. // We can try to build the full text and map it back, but that's complex.
  286. // Let's try a token-based approach.
  287. // Simple strategy: If a span contains a significant chunk of the search string, highlight it.
  288. // Or if the search string is found within the concatenated text of a few adjacent spans.
  289. // Let's try to highlight any span that has a significant substring match.
  290. spans.forEach(span => {
  291. const spanText = normalize(span.textContent || '');
  292. if (!spanText) return;
  293. // If the span text is fully contained in the search string
  294. if (searchStr.includes(spanText) && spanText.length > 3) {
  295. span.style.backgroundColor = 'rgba(255, 255, 0, 0.4)';
  296. span.style.borderRadius = '2px';
  297. found = true;
  298. }
  299. // If the search string is fully contained in the span text
  300. else if (spanText.includes(searchStr)) {
  301. span.style.backgroundColor = 'rgba(255, 255, 0, 0.4)';
  302. span.style.borderRadius = '2px';
  303. found = true;
  304. }
  305. });
  306. if (!found) {
  307. console.log('No exact/substring match found. Trying fuzzy/keyword match.');
  308. // Fallback: Keyword matching (if exact match fails)
  309. // For CJK characters, even 2 chars is significant. For English, keep it > 3.
  310. // We split by non-word characters to get tokens
  311. const tokens = highlightText.toLowerCase().split(/[^\w\u4e00-\u9fa5]+/);
  312. const keywords = tokens.filter(k => {
  313. const isCJK = /[\u4e00-\u9fa5]/.test(k);
  314. return isCJK ? k.length >= 2 : k.length > 3;
  315. });
  316. if (keywords.length > 0) {
  317. spans.forEach(span => {
  318. const spanText = (span.textContent || '').toLowerCase();
  319. if (keywords.some(k => spanText.includes(k))) {
  320. span.style.backgroundColor = 'rgba(255, 255, 0, 0.4)'; // Increased opacity
  321. span.style.borderRadius = '2px';
  322. found = true;
  323. }
  324. });
  325. }
  326. }
  327. // Scroll first highlighted element into view
  328. const firstHighlight = textLayerDiv.querySelector('span[style*="background-color"]');
  329. if (firstHighlight) {
  330. console.log('Scrolling to highlight');
  331. firstHighlight.scrollIntoView({ behavior: 'smooth', block: 'center' });
  332. } else {
  333. console.log('No highlight applied');
  334. }
  335. }
  336. }
  337. // このタスクが完了したとき、まだ最新のタスクであればクリアする
  338. if (currentRenderTask.current === renderTask) {
  339. currentRenderTask.current = null;
  340. }
  341. } catch (error) {
  342. if (error instanceof Error && error.name !== 'RenderingCancelledException') {
  343. console.error('Failed to render PDF page:', error);
  344. }
  345. // このタスクでエラーが発生したとき、まだ最新のタスクであればクリアする
  346. if (currentRenderTask.current === renderTask) {
  347. currentRenderTask.current = null;
  348. }
  349. }
  350. };
  351. const handleFullscreen = () => {
  352. setIsFullscreen(!isFullscreen);
  353. };
  354. const handleDownload = () => {
  355. if (pdfUrl) {
  356. // pdfUrlが既にある場合、直接ダウンロード
  357. const link = document.createElement('a');
  358. link.href = pdfUrl;
  359. link.download = fileName.replace(/\.[^/.]+$/, '.pdf');
  360. document.body.appendChild(link);
  361. link.click();
  362. document.body.removeChild(link);
  363. } else {
  364. // pdfUrlがない場合、直接取得してダウンロードを試みる
  365. pdfPreviewService.getPDFUrl(fileId)
  366. .then(result => {
  367. const link = document.createElement('a');
  368. link.href = result.url;
  369. link.download = fileName.replace(/\.[^/.]+$/, '.pdf');
  370. document.body.appendChild(link);
  371. link.click();
  372. document.body.removeChild(link);
  373. })
  374. .catch(error => {
  375. console.error('Failed to download PDF:', error);
  376. showToast('error', t('downloadPDFFailed'));
  377. });
  378. }
  379. };
  380. const handleOpenInNewTab = () => {
  381. if (pdfUrl) {
  382. window.open(pdfUrl, '_blank');
  383. } else {
  384. // pdfUrlがない場合、直接取得して開くことを試みる
  385. pdfPreviewService.getPDFUrl(fileId)
  386. .then(result => {
  387. window.open(result.url, '_blank');
  388. })
  389. .catch(error => {
  390. console.error('Failed to open PDF in new tab:', error);
  391. showToast('error', t('openPDFInNewTabFailed'));
  392. });
  393. }
  394. };
  395. const handleRegenerate = async () => {
  396. if (window.confirm(t('confirmRegeneratePDF'))) {
  397. setStatus({ status: 'converting' });
  398. setLoading(true);
  399. try {
  400. await pdfPreviewService.preloadPDF(fileId, true);
  401. // 状態をリセットして再読み込みをトリガー
  402. setPdfUrl('');
  403. setIframeError(false);
  404. setPdfDoc(null);
  405. setPdfBlob(null);
  406. setNumPages(0);
  407. } catch (error) {
  408. showToast('error', t('requestRegenerationFailed'));
  409. setStatus({ status: 'failed', error: t('requestRegenerationFailed') });
  410. }
  411. }
  412. };
  413. const handleIframeError = () => {
  414. setIframeError(true);
  415. };
  416. const handleSelectionComplete = (screenshot: Blob, text: string) => {
  417. // Set preliminary data and open dialog
  418. setSelectionData({ screenshot, text });
  419. setIsSelectionMode(false);
  420. };
  421. const handleSaveNote = async (title: string, content: string, selectedGroupId?: string) => {
  422. if (!authToken || !selectionData) return;
  423. try {
  424. await noteService.createFromPDFSelection(
  425. authToken,
  426. fileId,
  427. selectionData.screenshot,
  428. selectedGroupId || groupId,
  429. currentPage
  430. );
  431. showToast('success', t('noteCreatedSuccess'));
  432. setSelectionData(null);
  433. } catch (error) {
  434. console.error('Failed to create note:', error);
  435. showToast('error', t('noteCreatedFailed'));
  436. }
  437. };
  438. const renderContent = () => {
  439. switch (status.status) {
  440. case 'pending':
  441. return (
  442. <div className="flex flex-col items-center justify-center h-full text-gray-500">
  443. <Loader size={48} className="animate-spin mb-4" />
  444. <div className="text-lg font-medium mb-2">{t('preparingPDFConversion')}</div>
  445. <div className="text-sm">{t('pleaseWait')}</div>
  446. </div>
  447. );
  448. case 'converting':
  449. return (
  450. <div className="flex flex-col items-center justify-center h-full text-gray-500">
  451. <Loader size={48} className="animate-spin mb-4" />
  452. <div className="text-lg font-medium mb-2">{t('convertingPDF')}</div>
  453. <div className="text-sm">{t('pleaseWait')}</div>
  454. </div>
  455. );
  456. case 'failed':
  457. return (
  458. <div className="flex flex-col items-center justify-center h-full text-red-500">
  459. <AlertCircle size={48} className="mb-4" />
  460. <div className="text-lg font-medium mb-2">{t('pdfConversionFailed')}</div>
  461. <div className="text-sm text-gray-500 text-center max-w-md">
  462. {status.error || t('pdfConversionError')}
  463. </div>
  464. <button
  465. onClick={checkPDFStatus}
  466. className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
  467. >
  468. {t('retry')}
  469. </button>
  470. </div>
  471. );
  472. case 'ready':
  473. if (iframeError) {
  474. return (
  475. <div className="flex flex-col items-center justify-center h-full text-gray-500">
  476. <AlertCircle size={48} className="mb-4" />
  477. <div className="text-lg font-medium mb-2">{t('pdfLoadFailed')}</div>
  478. <div className="text-sm text-gray-500 mb-4">{t('pdfLoadError')}</div>
  479. <div className="flex gap-2">
  480. <button
  481. onClick={handleDownload}
  482. className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
  483. >
  484. <Download size={16} />
  485. {t('downloadPDF')}
  486. </button>
  487. <button
  488. onClick={handleOpenInNewTab}
  489. className="flex items-center gap-2 px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors"
  490. >
  491. <ExternalLink size={16} />
  492. {t('openInNewWindow')}
  493. </button>
  494. </div>
  495. </div>
  496. );
  497. }
  498. if (!pdfDoc) {
  499. return (
  500. <div className="flex flex-col items-center justify-center h-full text-gray-500">
  501. <Loader size={48} className="animate-spin mb-4" />
  502. <div className="text-lg font-medium mb-2">{t('loadingPDF')}</div>
  503. </div>
  504. );
  505. }
  506. return (
  507. <div className="relative w-full h-full flex flex-col" ref={containerRef}>
  508. <div className="flex-grow overflow-auto pdf-canvas-container bg-white">
  509. <div className="flex items-center justify-center min-h-full p-4">
  510. <canvas
  511. ref={canvasRef}
  512. className="shadow-lg"
  513. style={{
  514. maxHeight: 'none',
  515. maxWidth: '100%',
  516. display: 'block'
  517. }}
  518. />
  519. </div>
  520. </div>
  521. {isSelectionMode && (
  522. <PDFSelectionTool
  523. containerRef={containerRef}
  524. canvasRef={canvasRef}
  525. pdfBlob={pdfBlob}
  526. pageNumber={currentPage}
  527. authToken={authToken}
  528. zoomLevel={zoomLevel}
  529. onSelectionComplete={handleSelectionComplete}
  530. onCancel={() => setIsSelectionMode(false)}
  531. />
  532. )}
  533. <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">
  534. <div className="flex items-center border-r border-slate-200 pr-2 mr-2">
  535. <button
  536. onClick={handleZoomOut}
  537. className="p-1 hover:bg-slate-100 rounded text-slate-600 min-w-[28px] flex items-center justify-center"
  538. title={t('zoomOut')}
  539. >
  540. <span className="text-lg">−</span>
  541. </button>
  542. <span className="mx-1 text-sm text-slate-600 min-w-[40px] text-center">{Math.round(zoomLevel * 100)}%</span>
  543. <button
  544. onClick={handleZoomIn}
  545. className="p-1 hover:bg-slate-100 rounded text-slate-600 min-w-[28px] flex items-center justify-center"
  546. title={t('zoomIn')}
  547. >
  548. <span className="text-lg">+</span>
  549. </button>
  550. <button
  551. onClick={handleResetZoom}
  552. className="ml-1 p-1 px-2 hover:bg-slate-100 rounded text-slate-600 text-xs"
  553. title={t('resetZoom')}
  554. >
  555. 100%
  556. </button>
  557. </div>
  558. <button
  559. onClick={() => {
  560. const newPage = Math.max(1, currentPage - 1);
  561. setCurrentPage(newPage);
  562. }}
  563. className="p-1 hover:bg-slate-100 rounded text-slate-600"
  564. >
  565. <ChevronLeft size={16} />
  566. </button>
  567. <div className="flex items-center gap-1">
  568. <input
  569. type="number"
  570. value={currentPage}
  571. onChange={(e) => {
  572. const val = Math.max(1, Math.min(numPages, parseInt(e.target.value) || 1));
  573. setCurrentPage(val);
  574. }}
  575. className="w-12 text-center text-sm border-none focus:ring-0 bg-transparent font-medium"
  576. />
  577. <span className="text-sm text-slate-500">/ {numPages}</span>
  578. </div>
  579. <button
  580. onClick={() => {
  581. const newPage = Math.min(numPages, currentPage + 1);
  582. setCurrentPage(newPage);
  583. }}
  584. className="p-1 hover:bg-slate-100 rounded text-slate-600"
  585. >
  586. <ChevronRight size={16} />
  587. </button>
  588. </div>
  589. {selectionData && (
  590. <CreateNoteFromPDFDialog
  591. screenshot={selectionData.screenshot}
  592. extractedText={selectionData.text}
  593. authToken={authToken}
  594. initialGroupId={groupId}
  595. initialPageNumber={currentPage}
  596. onSave={handleSaveNote}
  597. onCancel={() => setSelectionData(null)}
  598. />
  599. )}
  600. </div>
  601. );
  602. default:
  603. return null;
  604. }
  605. };
  606. return (
  607. <div className={`fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-4'
  608. }`}>
  609. <div className={`bg-white rounded-lg overflow-hidden ${isFullscreen ? 'w-full h-full' : 'w-full max-w-4xl h-5/6'
  610. }`}>
  611. {/* 头部 */}
  612. <div className="flex items-center justify-between p-4 border-b bg-gray-50">
  613. <div className="flex items-center space-x-3">
  614. <FileText size={20} className="text-gray-600" />
  615. <div>
  616. <div className="font-medium text-gray-900">{fileName}</div>
  617. <div className="text-sm text-gray-500">{t('pdfPreview')}</div>
  618. </div>
  619. </div>
  620. <div className="flex items-center space-x-2">
  621. {status.status === 'ready' && !iframeError && (
  622. <>
  623. <div className="flex items-center gap-2 mr-2 border-r pr-2">
  624. <span className="text-sm text-gray-500">{t('selectPageNumber')}</span>
  625. <input
  626. type="number"
  627. min={1}
  628. max={numPages}
  629. value={currentPage}
  630. onChange={(e) => {
  631. const val = Math.max(1, Math.min(numPages, parseInt(e.target.value) || 1));
  632. setCurrentPage(val);
  633. }}
  634. className="w-16 px-2 py-1 border rounded text-sm"
  635. title={t('enterPageNumber')}
  636. />
  637. </div>
  638. <button
  639. onClick={() => setIsSelectionMode(!isSelectionMode)}
  640. className={`p-2 transition-colors ${isSelectionMode ? 'bg-blue-100 text-blue-600 rounded' : 'text-gray-400 hover:text-blue-600'}`}
  641. title={isSelectionMode ? t('exitSelectionMode') : t('clickToSelectAndNote')}
  642. >
  643. <Scissors size={18} />
  644. </button>
  645. <button
  646. onClick={handleRegenerate}
  647. className="p-2 text-gray-400 hover:text-blue-600 transition-colors"
  648. title={t('regeneratePDF')}
  649. >
  650. <RefreshCw size={18} />
  651. </button>
  652. <button
  653. onClick={handleDownload}
  654. className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
  655. title={t('downloadPDF')}
  656. >
  657. <Download size={18} />
  658. </button>
  659. <button
  660. onClick={handleOpenInNewTab}
  661. className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
  662. title={t('openInNewWindow')}
  663. >
  664. <ExternalLink size={18} />
  665. </button>
  666. <button
  667. onClick={handleFullscreen}
  668. className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
  669. title={isFullscreen ? t('exitFullscreen') : t('fullscreenDisplay')}
  670. >
  671. <Maximize2 size={18} />
  672. </button>
  673. </>
  674. )}
  675. <button
  676. onClick={onClose}
  677. className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
  678. >
  679. <X size={18} />
  680. </button>
  681. </div>
  682. </div>
  683. {/* 内容区域 */}
  684. <div className="flex-1 h-full">
  685. {renderContent()}
  686. </div>
  687. </div>
  688. </div>
  689. );
  690. };
  691. // Add global styles for text layer if not present
  692. const style = document.createElement('style');
  693. style.innerHTML = `
  694. .textLayer {
  695. position: absolute;
  696. text-align: initial;
  697. left: 0;
  698. top: 0;
  699. right: 0;
  700. bottom: 0;
  701. overflow: hidden;
  702. opacity: 1; /* Increased from 0.25 to 1 because the spans are transparent anyway */
  703. line-height: 1.0;
  704. pointer-events: auto; /* Enable text selection */
  705. z-index: 10; /* Ensure text layer is above canvas */
  706. mix-blend-mode: multiply; /* Better blending for highlights */
  707. }
  708. .textLayer > span {
  709. color: transparent;
  710. position: absolute;
  711. white-space: pre;
  712. cursor: text;
  713. transform-origin: 0% 0%;
  714. }
  715. `;
  716. document.head.appendChild(style);
  717. interface PDFPreviewButtonProps {
  718. fileId: string;
  719. fileName: string;
  720. onPreview: () => void;
  721. }
  722. export const PDFPreviewButton: React.FC<PDFPreviewButtonProps> = ({
  723. fileId,
  724. fileName,
  725. onPreview
  726. }) => {
  727. const [status, setStatus] = useState<PDFStatus>({ status: 'pending' });
  728. const [loading, setLoading] = useState(true);
  729. const { t } = useLanguage();
  730. useEffect(() => {
  731. checkStatus();
  732. }, [fileId]);
  733. const checkStatus = async () => {
  734. try {
  735. const pdfStatus = await pdfPreviewService.getPDFStatus(fileId);
  736. setStatus(pdfStatus);
  737. } catch (error) {
  738. // エラーを無視し、デフォルト状態を使用
  739. } finally {
  740. setLoading(false);
  741. }
  742. };
  743. const getIcon = () => {
  744. if (loading || status.status === 'converting') {
  745. return <Loader className="w-3 h-3 animate-spin" />;
  746. }
  747. if (status.status === 'failed') {
  748. return <AlertCircle className="w-3 h-3" />;
  749. }
  750. return <Eye className="w-3 h-3" />;
  751. };
  752. const getTitle = () => {
  753. switch (status.status) {
  754. case 'ready': return t('pdfPreviewReady');
  755. case 'converting': return t('convertingInProgress');
  756. case 'failed': return t('conversionFailed');
  757. default: return t('generatePDFPreviewButton');
  758. }
  759. };
  760. return (
  761. <button
  762. onClick={onPreview}
  763. disabled={loading || status.status === 'converting'}
  764. className={`p-1 rounded transition-colors ${status.status === 'failed'
  765. ? 'text-red-400 hover:text-red-500 hover:bg-red-50'
  766. : 'text-slate-400 hover:text-blue-500 hover:bg-blue-50'
  767. } disabled:opacity-50 disabled:cursor-not-allowed`}
  768. title={getTitle()}
  769. >
  770. {getIcon()}
  771. </button>
  772. );
  773. };