洛阳市文章资讯

前端基于Mozilla PDF.js实现一个功能完善的PDF预览组件

2026-03-31 14:29:01 浏览次数:0
详细信息

1. 首先安装依赖

npm install pdfjs-dist

2. PDF预览组件实现

// components/PDFViewer.jsx
import React, { useState, useRef, useEffect } from 'react';
import * as pdfjsLib from 'pdfjs-dist';
import 'pdfjs-dist/build/pdf.worker.entry';

pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`;

const PDFViewer = ({ 
  file, 
  onLoadSuccess, 
  onLoadError,
  scale = 1.2,
  rotation = 0 
}) => {
  const [pdfDocument, setPdfDocument] = useState(null);
  const [currentPage, setCurrentPage] = useState(1);
  const [totalPages, setTotalPages] = useState(0);
  const [scaleValue, setScaleValue] = useState(scale);
  const [rotationValue, setRotationValue] = useState(rotation);
  const [isLoading, setIsLoading] = useState(true);
  const [searchText, setSearchText] = useState('');
  const [searchResults, setSearchResults] = useState([]);
  const [currentSearchIndex, setCurrentSearchIndex] = useState(0);
  const [pageRendering, setPageRendering] = useState(false);

  const canvasRef = useRef(null);
  const containerRef = useRef(null);
  const searchRef = useRef(null);

  // 初始化PDF
  useEffect(() => {
    if (!file) return;

    const loadPDF = async () => {
      try {
        setIsLoading(true);
        let pdf;

        if (typeof file === 'string') {
          // 如果是URL
          pdf = await pdfjsLib.getDocument({
            url: file,
            cMapUrl: 'https://unpkg.com/pdfjs-dist@3.11.174/cmaps/',
            cMapPacked: true,
          }).promise;
        } else if (file instanceof ArrayBuffer) {
          // 如果是ArrayBuffer
          pdf = await pdfjsLib.getDocument({
            data: file,
            cMapUrl: 'https://unpkg.com/pdfjs-dist@3.11.174/cmaps/',
            cMapPacked: true,
          }).promise;
        } else if (file instanceof Blob) {
          // 如果是Blob
          const arrayBuffer = await file.arrayBuffer();
          pdf = await pdfjsLib.getDocument({
            data: arrayBuffer,
            cMapUrl: 'https://unpkg.com/pdfjs-dist@3.11.174/cmaps/',
            cMapPacked: true,
          }).promise;
        } else {
          throw new Error('Unsupported file type');
        }

        setPdfDocument(pdf);
        setTotalPages(pdf.numPages);

        if (onLoadSuccess) {
          onLoadSuccess(pdf);
        }
      } catch (error) {
        console.error('Error loading PDF:', error);
        if (onLoadError) {
          onLoadError(error);
        }
      } finally {
        setIsLoading(false);
      }
    };

    loadPDF();
  }, [file]);

  // 渲染页面
  const renderPage = async (pageNum) => {
    if (!pdfDocument || pageRendering) return;

    try {
      setPageRendering(true);
      const page = await pdfDocument.getPage(pageNum);

      const canvas = canvasRef.current;
      const context = canvas.getContext('2d');
      const container = containerRef.current;

      // 计算缩放后的尺寸
      const viewport = page.getViewport({ 
        scale: scaleValue,
        rotation: rotationValue 
      });

      canvas.height = viewport.height;
      canvas.width = viewport.width;

      // 调整容器大小
      if (container) {
        container.style.width = `${viewport.width}px`;
        container.style.height = `${viewport.height}px`;
      }

      // 渲染页面
      const renderContext = {
        canvasContext: context,
        viewport: viewport
      };

      await page.render(renderContext).promise;
      setCurrentPage(pageNum);
    } catch (error) {
      console.error('Error rendering page:', error);
    } finally {
      setPageRendering(false);
    }
  };

  // 页码变化时重新渲染
  useEffect(() => {
    if (pdfDocument && currentPage) {
      renderPage(currentPage);
    }
  }, [pdfDocument, currentPage, scaleValue, rotationValue]);

  // 导航功能
  const goToPrevPage = () => {
    if (currentPage > 1) {
      setCurrentPage(currentPage - 1);
    }
  };

  const goToNextPage = () => {
    if (currentPage < totalPages) {
      setCurrentPage(currentPage + 1);
    }
  };

  const goToPage = (pageNumber) => {
    const pageNum = Math.max(1, Math.min(pageNumber, totalPages));
    setCurrentPage(pageNum);
  };

  // 缩放功能
  const zoomIn = () => {
    setScaleValue(prev => Math.min(prev + 0.2, 3));
  };

  const zoomOut = () => {
    setScaleValue(prev => Math.max(prev - 0.2, 0.5));
  };

  const zoomTo = (value) => {
    setScaleValue(Math.max(0.5, Math.min(value, 3)));
  };

  // 旋转功能
  const rotate = (degrees = 90) => {
    setRotationValue((prev) => (prev + degrees) % 360);
  };

  // 搜索功能
  const searchInPDF = async () => {
    if (!pdfDocument || !searchText.trim()) return;

    const results = [];

    for (let i = 1; i <= totalPages; i++) {
      const page = await pdfDocument.getPage(i);
      const textContent = await page.getTextContent();

      textContent.items.forEach((item) => {
        if (item.str.toLowerCase().includes(searchText.toLowerCase())) {
          results.push({
            page: i,
            text: item.str,
            index: results.length
          });
        }
      });
    }

    setSearchResults(results);
    setCurrentSearchIndex(0);

    if (results.length > 0) {
      goToPage(results[0].page);
    }
  };

  const goToNextSearchResult = () => {
    if (searchResults.length === 0) return;

    const nextIndex = (currentSearchIndex + 1) % searchResults.length;
    setCurrentSearchIndex(nextIndex);
    goToPage(searchResults[nextIndex].page);
  };

  const goToPrevSearchResult = () => {
    if (searchResults.length === 0) return;

    const prevIndex = (currentSearchIndex - 1 + searchResults.length) % searchResults.length;
    setCurrentSearchIndex(prevIndex);
    goToPage(searchResults[prevIndex].page);
  };

  // 下载功能
  const downloadPDF = () => {
    if (!file) return;

    if (typeof file === 'string') {
      // 如果是URL,直接下载
      const link = document.createElement('a');
      link.href = file;
      link.download = 'document.pdf';
      link.click();
    } else if (file instanceof Blob) {
      // 如果是Blob,创建下载链接
      const url = URL.createObjectURL(file);
      const link = document.createElement('a');
      link.href = url;
      link.download = 'document.pdf';
      link.click();
      URL.revokeObjectURL(url);
    }
  };

  // 打印功能
  const printPDF = () => {
    if (!pdfDocument) return;

    // 打开新窗口打印
    const printWindow = window.open('');
    printWindow.document.write(`
      <html>
        <head>
          <title>打印PDF</title>
        </head>
        <body>
          <iframe 
            src="${typeof file === 'string' ? file : ''}" 
            style="width:100%;height:100%;border:none;"
          ></iframe>
          <script>
            window.onload = function() {
              setTimeout(() => {
                window.print();
              }, 1000);
            }
          </script>
        </body>
      </html>
    `);
  };

  // 缩略图功能组件
  const ThumbnailView = () => {
    const [thumbnails, setThumbnails] = useState([]);

    useEffect(() => {
      if (!pdfDocument) return;

      const loadThumbnails = async () => {
        const thumbs = [];
        for (let i = 1; i <= Math.min(totalPages, 20); i++) {
          const page = await pdfDocument.getPage(i);
          const viewport = page.getViewport({ scale: 0.2 });

          const canvas = document.createElement('canvas');
          canvas.height = viewport.height;
          canvas.width = viewport.width;

          const context = canvas.getContext('2d');
          await page.render({
            canvasContext: context,
            viewport: viewport
          }).promise;

          thumbs.push({
            page: i,
            dataUrl: canvas.toDataURL()
          });
        }
        setThumbnails(thumbs);
      };

      loadThumbnails();
    }, [pdfDocument]);

    if (thumbnails.length === 0) return null;

    return (
      <div style={{
        position: 'absolute',
        left: 0,
        top: 0,
        width: '200px',
        backgroundColor: '#f5f5f5',
        padding: '10px',
        overflowY: 'auto',
        height: '100%',
        borderRight: '1px solid #ddd'
      }}>
        <h4>缩略图</h4>
        {thumbnails.map(thumb => (
          <div 
            key={thumb.page}
            onClick={() => goToPage(thumb.page)}
            style={{
              marginBottom: '10px',
              cursor: 'pointer',
              border: currentPage === thumb.page ? '2px solid #1890ff' : '1px solid #ddd',
              borderRadius: '4px',
              overflow: 'hidden'
            }}
          >
            <img 
              src={thumb.dataUrl} 
              alt={`Page ${thumb.page}`}
              style={{ width: '100%', height: 'auto' }}
            />
            <div style={{ 
              textAlign: 'center', 
              padding: '2px',
              fontSize: '12px',
              backgroundColor: currentPage === thumb.page ? '#1890ff' : '#fff',
              color: currentPage === thumb.page ? '#fff' : '#666'
            }}>
              {thumb.page}
            </div>
          </div>
        ))}
      </div>
    );
  };

  return (
    <div className="pdf-viewer-container" style={{ 
      position: 'relative',
      width: '100%',
      height: '100vh',
      backgroundColor: '#525659'
    }}>
      {/* 工具栏 */}
      <div style={{
        position: 'absolute',
        top: 0,
        left: 0,
        right: 0,
        backgroundColor: '#333',
        color: 'white',
        padding: '10px',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        zIndex: 100
      }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
          {/* 导航按钮 */}
          <button 
            onClick={goToPrevPage}
            disabled={currentPage <= 1 || isLoading}
            style={buttonStyle}
          >
            上一页
          </button>

          <span style={{ minWidth: '80px', textAlign: 'center' }}>
            {isLoading ? '加载中...' : `${currentPage} / ${totalPages}`}
          </span>

          <button 
            onClick={goToNextPage}
            disabled={currentPage >= totalPages || isLoading}
            style={buttonStyle}
          >
            下一页
          </button>

          {/* 页码跳转 */}
          <input
            type="number"
            min="1"
            max={totalPages}
            value={currentPage}
            onChange={(e) => goToPage(parseInt(e.target.value) || 1)}
            style={{
              width: '60px',
              padding: '4px',
              margin: '0 5px'
            }}
          />
        </div>

        <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
          {/* 缩放控制 */}
          <button onClick={zoomOut} style={buttonStyle}>-</button>
          <select 
            value={scaleValue.toFixed(1)}
            onChange={(e) => zoomTo(parseFloat(e.target.value))}
            style={{ padding: '4px' }}
          >
            <option value="0.5">50%</option>
            <option value="0.75">75%</option>
            <option value="1.0">100%</option>
            <option value="1.25">125%</option>
            <option value="1.5">150%</option>
            <option value="2.0">200%</option>
          </select>
          <button onClick={zoomIn} style={buttonStyle}>+</button>
        </div>

        <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
          {/* 旋转 */}
          <button onClick={() => rotate(90)} style={buttonStyle}>
            旋转
          </button>

          {/* 搜索 */}
          <input
            ref={searchRef}
            type="text"
            placeholder="搜索..."
            value={searchText}
            onChange={(e) => setSearchText(e.target.value)}
            onKeyPress={(e) => e.key === 'Enter' && searchInPDF()}
            style={{ padding: '4px', width: '120px' }}
          />
          {searchResults.length > 0 && (
            <span style={{ fontSize: '12px' }}>
              {currentSearchIndex + 1} / {searchResults.length}
            </span>
          )}

          {/* 功能按钮 */}
          <button onClick={downloadPDF} style={buttonStyle}>
            下载
          </button>
          <button onClick={printPDF} style={buttonStyle}>
            打印
          </button>
        </div>
      </div>

      {/* 主要内容区域 */}
      <div style={{
        position: 'absolute',
        top: '50px',
        bottom: 0,
        left: 0,
        right: 0,
        display: 'flex'
      }}>
        {/* 缩略图侧边栏 */}
        <ThumbnailView />

        {/* PDF显示区域 */}
        <div style={{
          flex: 1,
          display: 'flex',
          justifyContent: 'center',
          alignItems: 'center',
          overflow: 'auto',
          padding: '20px'
        }}>
          {isLoading ? (
            <div style={{ color: 'white' }}>加载PDF中...</div>
          ) : (
            <div ref={containerRef} style={{ position: 'relative' }}>
              <canvas ref={canvasRef} />
              {pageRendering && (
                <div style={{
                  position: 'absolute',
                  top: '50%',
                  left: '50%',
                  transform: 'translate(-50%, -50%)',
                  color: 'white',
                  backgroundColor: 'rgba(0,0,0,0.5)',
                  padding: '10px',
                  borderRadius: '5px'
                }}>
                  渲染中...
                </div>
              )}
            </div>
          )}
        </div>
      </div>

      {/* 搜索导航 */}
      {searchResults.length > 0 && (
        <div style={{
          position: 'absolute',
          bottom: 0,
          left: '50%',
          transform: 'translateX(-50%)',
          backgroundColor: 'rgba(0,0,0,0.8)',
          color: 'white',
          padding: '10px',
          borderRadius: '5px 5px 0 0',
          display: 'flex',
          gap: '10px',
          alignItems: 'center'
        }}>
          <button onClick={goToPrevSearchResult} style={buttonStyle}>
            上一个
          </button>
          <span>搜索结果: {searchResults[currentSearchIndex]?.text}</span>
          <button onClick={goToNextSearchResult} style={buttonStyle}>
            下一个
          </button>
          <button 
            onClick={() => setSearchResults([])}
            style={{ ...buttonStyle, marginLeft: '10px' }}
          >
            关闭
          </button>
        </div>
      )}
    </div>
  );
};

// 按钮样式
const buttonStyle = {
  padding: '6px 12px',
  backgroundColor: '#1890ff',
  color: 'white',
  border: 'none',
  borderRadius: '4px',
  cursor: 'pointer',
  fontSize: '14px'
};

export default PDFViewer;

3. 使用示例

// App.jsx
import React, { useState } from 'react';
import PDFViewer from './components/PDFViewer';
import './App.css';

function App() {
  const [pdfFile, setPdfFile] = useState(null);
  const [pdfUrl, setPdfUrl] = useState('');

  const handleFileChange = (event) => {
    const file = event.target.files[0];
    if (file && file.type === 'application/pdf') {
      setPdfFile(file);
      setPdfUrl('');
    } else {
      alert('请选择PDF文件');
    }
  };

  const handleUrlSubmit = () => {
    if (pdfUrl) {
      setPdfFile(null);
      setPdfUrl(pdfUrl);
    }
  };

  return (
    <div className="App">
      <div style={{ 
        padding: '20px', 
        backgroundColor: '#f0f0f0',
        marginBottom: '20px'
      }}>
        <h1>PDF预览组件</h1>

        <div style={{ marginBottom: '20px' }}>
          <h3>上传本地PDF文件:</h3>
          <input
            type="file"
            accept=".pdf"
            onChange={handleFileChange}
          />
        </div>

        <div style={{ marginBottom: '20px' }}>
          <h3>或输入PDF URL:</h3>
          <div style={{ display: 'flex', gap: '10px' }}>
            <input
              type="text"
              placeholder="输入PDF URL"
              value={pdfUrl}
              onChange={(e) => setPdfUrl(e.target.value)}
              style={{ flex: 1, padding: '8px' }}
            />
            <button onClick={handleUrlSubmit}>加载URL</button>
          </div>
          <small>示例URL: https://example.com/document.pdf</small>
        </div>
      </div>

      {pdfFile || pdfUrl ? (
        <PDFViewer
          file={pdfFile || pdfUrl}
          onLoadSuccess={(pdf) => {
            console.log('PDF加载成功,总页数:', pdf.numPages);
          }}
          onLoadError={(error) => {
            console.error('PDF加载失败:', error);
            alert('PDF加载失败,请检查文件或URL');
          }}
          scale={1.2}
          rotation={0}
        />
      ) : (
        <div style={{
          textAlign: 'center',
          padding: '100px',
          color: '#666'
        }}>
          <h2>请上传PDF文件或输入PDF URL</h2>
          <p>支持本地文件上传和在线PDF预览</p>
        </div>
      )}
    </div>
  );
}

export default App;

4. 样式文件

/* App.css */
body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}

.App {
  height: 100vh;
  display: flex;
  flex-direction: column;
}

.pdf-viewer-container {
  flex: 1;
  position: relative;
}

button {
  padding: 8px 16px;
  background-color: #1890ff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: background-color 0.3s;
}

button:hover {
  background-color: #40a9ff;
}

button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}

input, select {
  padding: 6px 12px;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
  font-size: 14px;
}

input:focus, select:focus {
  outline: none;
  border-color: #40a9ff;
  box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}

5. 高级功能扩展

// 更多高级功能的hooks
// hooks/usePDFAnnotations.js
import { useState, useRef } from 'react';

export const usePDFAnnotations = () => {
  const [annotations, setAnnotations] = useState([]);
  const [isDrawing, setIsDrawing] = useState(false);
  const [drawingTool, setDrawingTool] = useState(null);
  const [selectedAnnotation, setSelectedAnnotation] = useState(null);

  const addAnnotation = (annotation) => {
    setAnnotations([...annotations, annotation]);
  };

  const removeAnnotation = (id) => {
    setAnnotations(annotations.filter(ann => ann.id !== id));
  };

  const updateAnnotation = (id, updates) => {
    setAnnotations(annotations.map(ann => 
      ann.id === id ? { ...ann, ...updates } : ann
    ));
  };

  return {
    annotations,
    addAnnotation,
    removeAnnotation,
    updateAnnotation,
    isDrawing,
    setIsDrawing,
    drawingTool,
    setDrawingTool,
    selectedAnnotation,
    setSelectedAnnotation
  };
};

主要功能说明:

基本功能

搜索功能

缩略图

文件操作

状态管理

这个组件提供了完整的PDF预览功能,可以根据具体需求进行扩展和样式调整。

相关推荐