CourseAssetPreview.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. import { useEffect, useRef, useState } from 'react'
  2. import { ChevronLeft, ChevronRight, Download, X } from 'lucide-react'
  3. import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'
  4. import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
  5. import { downloadPrivateFile, fetchOfficePreview, fetchPrivateAsset } from '../api'
  6. import type { CourseAsset } from '../types'
  7. const extension = (name: string) => name.split('.').pop()?.toLowerCase() || ''
  8. const documentTypes = new Set(['pdf', 'pptx', 'ppt', 'docx', 'doc'])
  9. const officeTypes = new Set(['pptx', 'ppt', 'docx', 'doc'])
  10. const imageTypes = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'])
  11. const videoTypes = new Set(['mp4', 'webm', 'mov', 'm4v'])
  12. export function canPreviewAsset(asset: CourseAsset) {
  13. const type = extension(asset.originalName)
  14. return documentTypes.has(type) || imageTypes.has(type) || videoTypes.has(type) ||
  15. asset.mimeType.startsWith('image/') || asset.mimeType.startsWith('video/')
  16. }
  17. export function CourseAssetPreview({ asset, courseId, onClose }: {
  18. asset: CourseAsset
  19. courseId: string
  20. onClose: () => void
  21. }) {
  22. const type = extension(asset.originalName)
  23. const isDocument = documentTypes.has(type)
  24. const isImage = imageTypes.has(type) || asset.mimeType.startsWith('image/')
  25. const [objectUrl, setObjectUrl] = useState('')
  26. const [document, setDocument] = useState<PDFDocumentProxy | null>(null)
  27. const [page, setPage] = useState(1)
  28. const [loading, setLoading] = useState(true)
  29. const [rendering, setRendering] = useState(false)
  30. const [error, setError] = useState('')
  31. const canvas = useRef<HTMLCanvasElement>(null)
  32. const canvasWrap = useRef<HTMLDivElement>(null)
  33. useEffect(() => {
  34. let active = true
  35. let task: ReturnType<typeof import('pdfjs-dist').getDocument> | null = null
  36. setLoading(true)
  37. setError('')
  38. setPage(1)
  39. setDocument(null)
  40. setObjectUrl('')
  41. ;(async () => {
  42. try {
  43. const blob = officeTypes.has(type)
  44. ? await fetchOfficePreview(courseId, asset.id)
  45. : await fetchPrivateAsset(asset.url)
  46. if (!active) return
  47. if (isDocument) {
  48. const pdfjs = await import('pdfjs-dist')
  49. pdfjs.GlobalWorkerOptions.workerSrc = workerUrl
  50. task = pdfjs.getDocument({ data: new Uint8Array(await blob.arrayBuffer()) })
  51. const pdf = await task.promise
  52. if (active) setDocument(pdf)
  53. } else {
  54. const url = URL.createObjectURL(blob)
  55. if (active) setObjectUrl(url)
  56. else URL.revokeObjectURL(url)
  57. }
  58. } catch (cause) {
  59. if (active) setError((cause as Error).message || '无法预览素材')
  60. } finally {
  61. if (active) setLoading(false)
  62. }
  63. })()
  64. return () => {
  65. active = false
  66. if (task) void task.destroy()
  67. }
  68. }, [asset.id, asset.url, courseId, isDocument, type])
  69. useEffect(() => {
  70. if (!objectUrl) return
  71. return () => URL.revokeObjectURL(objectUrl)
  72. }, [objectUrl])
  73. useEffect(() => {
  74. if (!document || !canvas.current || !canvasWrap.current) return
  75. let cancelled = false
  76. let renderTask: RenderTask | null = null
  77. setRendering(true)
  78. ;(async () => {
  79. try {
  80. const pdfPage = await document.getPage(page)
  81. if (cancelled || !canvas.current || !canvasWrap.current) return
  82. const canvasElement = canvas.current
  83. const context = canvasElement.getContext('2d')
  84. if (!context) throw new Error('浏览器无法绘制预览')
  85. const base = pdfPage.getViewport({ scale: 1 })
  86. const fitWidth = Math.max(280, Math.min(canvasWrap.current.clientWidth - 32, 1300))
  87. const viewport = pdfPage.getViewport({ scale: fitWidth / base.width })
  88. const dpr = Math.min(window.devicePixelRatio || 1, 2)
  89. canvasElement.width = Math.round(viewport.width * dpr)
  90. canvasElement.height = Math.round(viewport.height * dpr)
  91. canvasElement.style.width = `${Math.round(viewport.width)}px`
  92. canvasElement.style.height = `${Math.round(viewport.height)}px`
  93. renderTask = pdfPage.render({ canvas: canvasElement, canvasContext: context, viewport, transform: [dpr, 0, 0, dpr, 0, 0] })
  94. await renderTask.promise
  95. } catch (cause) {
  96. if (!cancelled && !(cause instanceof Error && cause.name === 'RenderingCancelledException')) setError((cause as Error).message)
  97. } finally {
  98. if (!cancelled) setRendering(false)
  99. }
  100. })()
  101. return () => { cancelled = true; renderTask?.cancel() }
  102. }, [document, page])
  103. useEffect(() => {
  104. const keyDown = (event: KeyboardEvent) => {
  105. if (event.key === 'Escape') onClose()
  106. if (!document) return
  107. if (event.key === 'ArrowRight') setPage((current) => Math.min(document.numPages, current + 1))
  108. if (event.key === 'ArrowLeft') setPage((current) => Math.max(1, current - 1))
  109. }
  110. window.addEventListener('keydown', keyDown)
  111. return () => window.removeEventListener('keydown', keyDown)
  112. }, [document, onClose])
  113. return <div className="asset-preview-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}>
  114. <div className="asset-preview-dialog" role="dialog" aria-modal="true" aria-label={`预览 ${asset.originalName}`}>
  115. <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>
  116. <div className="asset-preview-stage" ref={canvasWrap}>
  117. {loading && <p className="asset-preview-message">正在加载预览{officeTypes.has(type) ? ',首次转换可能需要一些时间' : ''}…</p>}
  118. {error && <div className="asset-preview-message" role="alert"><p>{error}</p><small>可以下载原文件查看。</small></div>}
  119. {isDocument && <canvas ref={canvas} className="asset-preview-canvas" style={{ display: document && !error ? 'block' : 'none' }} />}
  120. {!isDocument && objectUrl && (isImage
  121. ? <img className="asset-preview-media" src={objectUrl} alt={asset.originalName} onError={() => setError('浏览器不支持预览此图片格式')} />
  122. : <video className="asset-preview-media" src={objectUrl} controls autoPlay={false} onError={() => setError('浏览器不支持播放此视频格式')} />)}
  123. {rendering && !loading && !error && <span className="asset-preview-rendering">正在渲染第 {page} 页…</span>}
  124. </div>
  125. <footer className="asset-preview-footer">
  126. <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>
  127. <button type="button" onClick={() => void downloadPrivateFile(asset.url, asset.originalName).catch((cause) => setError((cause as Error).message))}><Download size={17} /> 下载原文件</button>
  128. </footer>
  129. </div>
  130. </div>
  131. }