| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139 |
- import { useEffect, useRef, useState } from 'react'
- import { ChevronLeft, ChevronRight, Download, X } from 'lucide-react'
- import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'
- import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
- import { downloadPrivateFile, fetchOfficePreview, fetchPrivateAsset } from '../api'
- import type { CourseAsset } from '../types'
- const extension = (name: string) => name.split('.').pop()?.toLowerCase() || ''
- const documentTypes = new Set(['pdf', 'pptx', 'ppt', 'docx', 'doc'])
- const officeTypes = new Set(['pptx', 'ppt', 'docx', 'doc'])
- const imageTypes = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'])
- const videoTypes = new Set(['mp4', 'webm', 'mov', 'm4v'])
- export function canPreviewAsset(asset: CourseAsset) {
- const type = extension(asset.originalName)
- return documentTypes.has(type) || imageTypes.has(type) || videoTypes.has(type) ||
- asset.mimeType.startsWith('image/') || asset.mimeType.startsWith('video/')
- }
- export function CourseAssetPreview({ asset, courseId, onClose }: {
- asset: CourseAsset
- courseId: string
- onClose: () => void
- }) {
- const type = extension(asset.originalName)
- const isDocument = documentTypes.has(type)
- const isImage = imageTypes.has(type) || asset.mimeType.startsWith('image/')
- const [objectUrl, setObjectUrl] = useState('')
- const [document, setDocument] = useState<PDFDocumentProxy | null>(null)
- const [page, setPage] = useState(1)
- const [loading, setLoading] = useState(true)
- const [rendering, setRendering] = useState(false)
- const [error, setError] = useState('')
- const canvas = useRef<HTMLCanvasElement>(null)
- const canvasWrap = useRef<HTMLDivElement>(null)
- useEffect(() => {
- let active = true
- let task: ReturnType<typeof import('pdfjs-dist').getDocument> | null = null
- setLoading(true)
- setError('')
- setPage(1)
- setDocument(null)
- setObjectUrl('')
- ;(async () => {
- try {
- const blob = officeTypes.has(type)
- ? await fetchOfficePreview(courseId, asset.id)
- : await fetchPrivateAsset(asset.url)
- if (!active) return
- if (isDocument) {
- const pdfjs = await import('pdfjs-dist')
- pdfjs.GlobalWorkerOptions.workerSrc = workerUrl
- task = pdfjs.getDocument({ data: new Uint8Array(await blob.arrayBuffer()) })
- const pdf = await task.promise
- if (active) setDocument(pdf)
- } else {
- const url = URL.createObjectURL(blob)
- if (active) setObjectUrl(url)
- else URL.revokeObjectURL(url)
- }
- } catch (cause) {
- if (active) setError((cause as Error).message || '无法预览素材')
- } finally {
- if (active) setLoading(false)
- }
- })()
- return () => {
- active = false
- if (task) void task.destroy()
- }
- }, [asset.id, asset.url, courseId, isDocument, type])
- useEffect(() => {
- if (!objectUrl) return
- return () => URL.revokeObjectURL(objectUrl)
- }, [objectUrl])
- useEffect(() => {
- if (!document || !canvas.current || !canvasWrap.current) return
- let cancelled = false
- let renderTask: RenderTask | null = null
- setRendering(true)
- ;(async () => {
- try {
- const pdfPage = await document.getPage(page)
- if (cancelled || !canvas.current || !canvasWrap.current) return
- const canvasElement = canvas.current
- const context = canvasElement.getContext('2d')
- if (!context) throw new Error('浏览器无法绘制预览')
- const base = pdfPage.getViewport({ scale: 1 })
- const fitWidth = Math.max(280, Math.min(canvasWrap.current.clientWidth - 32, 1300))
- const viewport = pdfPage.getViewport({ scale: fitWidth / base.width })
- const dpr = Math.min(window.devicePixelRatio || 1, 2)
- canvasElement.width = Math.round(viewport.width * dpr)
- canvasElement.height = Math.round(viewport.height * dpr)
- canvasElement.style.width = `${Math.round(viewport.width)}px`
- canvasElement.style.height = `${Math.round(viewport.height)}px`
- renderTask = pdfPage.render({ canvas: canvasElement, canvasContext: context, viewport, transform: [dpr, 0, 0, dpr, 0, 0] })
- await renderTask.promise
- } catch (cause) {
- if (!cancelled && !(cause instanceof Error && cause.name === 'RenderingCancelledException')) setError((cause as Error).message)
- } finally {
- if (!cancelled) setRendering(false)
- }
- })()
- return () => { cancelled = true; renderTask?.cancel() }
- }, [document, page])
- useEffect(() => {
- const keyDown = (event: KeyboardEvent) => {
- if (event.key === 'Escape') onClose()
- if (!document) return
- if (event.key === 'ArrowRight') setPage((current) => Math.min(document.numPages, current + 1))
- if (event.key === 'ArrowLeft') setPage((current) => Math.max(1, current - 1))
- }
- window.addEventListener('keydown', keyDown)
- return () => window.removeEventListener('keydown', keyDown)
- }, [document, onClose])
- return <div className="asset-preview-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}>
- <div className="asset-preview-dialog" role="dialog" aria-modal="true" aria-label={`预览 ${asset.originalName}`}>
- <header className="asset-preview-header"><div><strong title={asset.originalName}>{asset.originalName}</strong><span>{isDocument ? officeTypes.has(type) ? '文档预览' : 'PDF 预览' : isImage ? '图片预览' : '视频预览'}</span>{officeTypes.has(type) && <span className="asset-preview-warning">预览可能存在字体或排版差异,请以下载的原文件为准。</span>}</div><button type="button" onClick={onClose} aria-label="关闭预览"><X size={22} /></button></header>
- <div className="asset-preview-stage" ref={canvasWrap}>
- {loading && <p className="asset-preview-message">正在加载预览{officeTypes.has(type) ? ',首次转换可能需要一些时间' : ''}…</p>}
- {error && <div className="asset-preview-message" role="alert"><p>{error}</p><small>可以下载原文件查看。</small></div>}
- {isDocument && <canvas ref={canvas} className="asset-preview-canvas" style={{ display: document && !error ? 'block' : 'none' }} />}
- {!isDocument && objectUrl && (isImage
- ? <img className="asset-preview-media" src={objectUrl} alt={asset.originalName} onError={() => setError('浏览器不支持预览此图片格式')} />
- : <video className="asset-preview-media" src={objectUrl} controls autoPlay={false} onError={() => setError('浏览器不支持播放此视频格式')} />)}
- {rendering && !loading && !error && <span className="asset-preview-rendering">正在渲染第 {page} 页…</span>}
- </div>
- <footer className="asset-preview-footer">
- <div>{document && <><button type="button" disabled={page <= 1 || rendering} onClick={() => setPage(page - 1)} aria-label="上一页"><ChevronLeft size={18} /> 上一页</button><span>第 {page} / {document.numPages} 页</span><button type="button" disabled={page >= document.numPages || rendering} onClick={() => setPage(page + 1)} aria-label="下一页">下一页 <ChevronRight size={18} /></button></>}</div>
- <button type="button" onClick={() => void downloadPrivateFile(asset.url, asset.originalName).catch((cause) => setError((cause as Error).message))}><Download size={17} /> 下载原文件</button>
- </footer>
- </div>
- </div>
- }
|