feat: PDF自绘阅读器+抓手(空格按住/H切换/顶部按钮)+缩放适应宽度(pdf.js worker注入bundle零服务端依赖)
This commit is contained in:
@@ -64,6 +64,10 @@ export const zh = {
|
||||
'pane.openLater': '该类型暂不支持预览',
|
||||
'file.loading': '加载中…',
|
||||
'file.fail': '文件读取失败',
|
||||
'pdf.pan': '抓手(按住空格临时拖动 / H 切换常开)',
|
||||
'pdf.zoomIn': '放大',
|
||||
'pdf.zoomOut': '缩小',
|
||||
'pdf.fit': '适应宽度',
|
||||
'pane.gitNotRepo': '当前目录不是 git 仓库',
|
||||
'pane.gitClean': '工作区干净',
|
||||
'pane.jobsEmpty': '当前会话暂无后台任务',
|
||||
@@ -142,6 +146,10 @@ export const en = {
|
||||
'pane.openLater': 'Preview not supported for this type',
|
||||
'file.loading': 'Loading…',
|
||||
'file.fail': 'Failed to read file',
|
||||
'pdf.pan': 'Pan tool (hold Space to drag temporarily / H to toggle)',
|
||||
'pdf.zoomIn': 'Zoom in',
|
||||
'pdf.zoomOut': 'Zoom out',
|
||||
'pdf.fit': 'Fit width',
|
||||
'pane.gitNotRepo': 'Not a git repository',
|
||||
'pane.gitClean': 'Working tree clean',
|
||||
'pane.jobsEmpty': 'No background jobs in the current session',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Terminal } from 'xterm'
|
||||
import 'xterm/css/xterm.css'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.min.mjs'
|
||||
|
||||
/**
|
||||
* dsh-worktable 乐高式工作区 M1:通用分栏引擎(PRD §13)。
|
||||
@@ -1097,12 +1098,201 @@ const mdRenderer = new MarkdownIt({ linkify: true })
|
||||
const IMAGE_EXTS = /[.](png|jpe?g|gif|webp|svg|bmp|ico)$/i
|
||||
const MD_EXTS = /[.](md|markdown|mdown)$/i
|
||||
|
||||
/** 本地文件预览:PDF 走原生 iframe、图片居中展示、MD 渲染预览、其余按纯文本 */
|
||||
/** 自绘 PDF 阅读器:pdf.js 画布渲染 + 抓手(空格按住临时 / H 切换常开 / 顶部按钮) */
|
||||
function PdfViewer(props: { path: string }) {
|
||||
const fileUrl = '/api/worktable/file?path=' + encodeURIComponent(props.path)
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null)
|
||||
const canvasesRef = useRef<Record<number, HTMLCanvasElement | null>>({})
|
||||
const tasksRef = useRef<Record<number, any>>({})
|
||||
const rafRef = useRef(0)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [doc, setDoc] = useState<any | null>(null)
|
||||
const [pageCount, setPageCount] = useState(0)
|
||||
const [scale, setScale] = useState(1)
|
||||
const [fitScale, setFitScale] = useState<number | null>(null)
|
||||
const [panOn, setPanOn] = useState(false)
|
||||
const [spaceHeld, setSpaceHeld] = useState(false)
|
||||
const dragRef = useRef<{ x: number; y: number; sl: number; st: number; active: boolean } | null>(null)
|
||||
|
||||
// 载入文档(失败回退原生 iframe)
|
||||
useEffect(() => {
|
||||
let dead = false
|
||||
;(async () => {
|
||||
try {
|
||||
if (!pdfjsLib.GlobalWorkerOptions.workerSrc) {
|
||||
// worker 源码由构建时注入(__WT_PDF_WORKER__),Blob URL 起 module worker,零服务端依赖
|
||||
const code = (window as any).__WT_PDF_WORKER__
|
||||
if (code) {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = URL.createObjectURL(
|
||||
new Blob([code], { type: 'text/javascript' }),
|
||||
)
|
||||
}
|
||||
}
|
||||
const d: any = await pdfjsLib.getDocument({ url: fileUrl }).promise
|
||||
if (dead) { try { d.destroy() } catch {}; return }
|
||||
setDoc(d)
|
||||
setPageCount(d.numPages)
|
||||
const p0 = await d.getPage(1)
|
||||
const vp = p0.getViewport({ scale: 1 })
|
||||
if (!dead) setFitScale(vp.width)
|
||||
} catch {
|
||||
if (!dead) setFailed(true)
|
||||
}
|
||||
})()
|
||||
return () => { dead = true; tasksRef.current = {} }
|
||||
}, [fileUrl])
|
||||
|
||||
// 首次按容器宽度自适应
|
||||
useEffect(() => {
|
||||
if (fitScale == null || scale !== 1 || !doc) return
|
||||
const wrap = wrapRef.current
|
||||
if (!wrap) return
|
||||
const w = wrap.clientWidth - 24
|
||||
if (w > 80) setScale(Math.max(0.2, Math.min(3, w / fitScale)))
|
||||
}, [fitScale, doc, scale])
|
||||
|
||||
// 渲染一页(canvas 尺寸随缩放,DPR 上限 2)
|
||||
const renderPage = useCallback(async (n: number, pageScale: number) => {
|
||||
const canvas = canvasesRef.current[n]
|
||||
const d = doc
|
||||
if (!canvas || !d) return
|
||||
const pending = tasksRef.current[n]
|
||||
if (pending) { try { pending.cancel() } catch {} }
|
||||
try {
|
||||
const page = await d.getPage(n)
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const vp = page.getViewport({ scale: pageScale * dpr })
|
||||
canvas.width = Math.floor(vp.width)
|
||||
canvas.height = Math.floor(vp.height)
|
||||
canvas.style.width = Math.floor(vp.width / dpr) + 'px'
|
||||
canvas.style.height = Math.floor(vp.height / dpr) + 'px'
|
||||
const task = page.render({ canvasContext: canvas.getContext('2d'), viewport: vp })
|
||||
tasksRef.current[n] = task
|
||||
await task.promise.catch(() => {})
|
||||
tasksRef.current[n] = null
|
||||
} catch {}
|
||||
}, [doc])
|
||||
|
||||
// 视口按需渲染(滚动节流 rAF)
|
||||
useEffect(() => {
|
||||
const wrap = wrapRef.current
|
||||
if (!wrap || !doc) return
|
||||
const visible = () => {
|
||||
const top = wrap.scrollTop - 420
|
||||
const bottom = wrap.scrollTop + wrap.clientHeight + 420
|
||||
const phs = Array.from(wrap.querySelectorAll<HTMLElement>('[data-page]'))
|
||||
for (const el of phs) {
|
||||
const n = Number(el.getAttribute('data-page'))
|
||||
const y = el.offsetTop
|
||||
const h = el.offsetHeight
|
||||
if (y + h >= top && y <= bottom) renderPage(n, scale)
|
||||
}
|
||||
}
|
||||
const onScroll = () => {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = requestAnimationFrame(visible)
|
||||
}
|
||||
wrap.addEventListener('scroll', onScroll, { passive: true })
|
||||
visible()
|
||||
return () => {
|
||||
wrap.removeEventListener('scroll', onScroll)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
}, [doc, scale, renderPage])
|
||||
|
||||
// 键盘:空格按住临时抓手 / H 常开抓手(输入框内不劫持)
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const t = e.target as HTMLElement | null
|
||||
if (t && (t.isContentEditable || t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return
|
||||
if (e.code === 'Space') {
|
||||
e.preventDefault()
|
||||
setSpaceHeld(true)
|
||||
} else if (e.key === 'h' || e.key === 'H') {
|
||||
setPanOn((v) => !v)
|
||||
}
|
||||
}
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.code === 'Space') setSpaceHeld(false)
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const panActive = panOn || spaceHeld
|
||||
|
||||
const onPointerDown = (e: any) => {
|
||||
if (!panActive) return
|
||||
const wrap = wrapRef.current
|
||||
if (!wrap) return
|
||||
e.preventDefault()
|
||||
dragRef.current = { x: e.clientX, y: e.clientY, sl: wrap.scrollLeft, st: wrap.scrollTop, active: true }
|
||||
try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) } catch {}
|
||||
}
|
||||
const onPointerMove = (e: any) => {
|
||||
const p = dragRef.current
|
||||
const wrap = wrapRef.current
|
||||
if (!p || !p.active || !wrap) return
|
||||
wrap.scrollLeft = p.sl - (e.clientX - p.x)
|
||||
wrap.scrollTop = p.st - (e.clientY - p.y)
|
||||
}
|
||||
const onPointerUp = () => {
|
||||
if (dragRef.current) dragRef.current.active = false
|
||||
}
|
||||
|
||||
const zoom = (factor: number) => setScale((s) => Math.max(0.2, Math.min(5, Math.round(s * factor * 100) / 100)))
|
||||
const fitWidth = () => {
|
||||
const wrap = wrapRef.current
|
||||
if (!wrap || fitScale == null) return
|
||||
const w = wrap.clientWidth - 24
|
||||
if (w > 80) setScale(Math.max(0.2, Math.min(3, w / fitScale)))
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
return <iframe className="dsh-wt_paneFrame" src={fileUrl} title={basenameOf(props.path)} />
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="dsh-wt_pdfBar">
|
||||
<button
|
||||
type="button"
|
||||
className={'dsh-wt_pdfBtn' + (panOn ? ' dsh-wt_pdfBtnOn' : '')}
|
||||
title={T('pdf.pan')}
|
||||
onClick={() => setPanOn((v) => !v)}
|
||||
>✋</button>
|
||||
<button type="button" className="dsh-wt_pdfBtn" title={T('pdf.zoomOut')} onClick={() => zoom(1 / 1.25)}>−</button>
|
||||
<span className="dsh-wt_pdfPct">{Math.round(scale * 100)}%</span>
|
||||
<button type="button" className="dsh-wt_pdfBtn" title={T('pdf.zoomIn')} onClick={() => zoom(1.25)}>+</button>
|
||||
<button type="button" className="dsh-wt_pdfBtn" title={T('pdf.fit')} onClick={fitWidth}>⤢</button>
|
||||
</div>
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className={'dsh-wt_pdf' + (panActive ? ' dsh-wt_pdfPan' : '')}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
{Array.from({ length: pageCount }, (_, i) => (
|
||||
<div key={i} className="dsh-wt_pdfPage" data-page={i + 1}>
|
||||
<canvas ref={(el) => { canvasesRef.current[i + 1] = el }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 本地文件预览:PDF 自绘阅读器(抓手)、图片居中、MD 渲染、其余纯文本 */
|
||||
function FileViewer(props: { path: string }) {
|
||||
const ext = (props.path.split('.').pop() || '').toLowerCase()
|
||||
const fileUrl = '/api/worktable/file?path=' + encodeURIComponent(props.path)
|
||||
if (ext === 'pdf') {
|
||||
return <iframe className="dsh-wt_paneFrame" src={fileUrl} title={basenameOf(props.path)} />
|
||||
return <PdfViewer path={props.path} />
|
||||
}
|
||||
if (IMAGE_EXTS.test('.' + ext)) {
|
||||
return (
|
||||
|
||||
@@ -100,6 +100,17 @@ export const css = [
|
||||
'.dsh-wt_md img{max-width:100%}',
|
||||
'.dsh-wt_imgView{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;overflow:auto;padding:10px;background:var(--dsw-alias-bg-base,#0b0e14)}',
|
||||
'.dsh-wt_imgView img{max-width:100%;max-height:100%;object-fit:contain}',
|
||||
// PDF 阅读器:工具栏 + 滚动容器 + 抓手光标
|
||||
'.dsh-wt_pdfBar{flex:none;display:flex;align-items:center;gap:6px;padding:4px 8px;border-bottom:1px solid var(--dsw-alias-border-l1,#262b36);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02))}',
|
||||
'.dsh-wt_pdfBtn{flex:none;display:flex;align-items:center;justify-content:center;width:24px;height:22px;padding:0;border:1px solid transparent;border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font-size:13px;line-height:1;cursor:pointer}',
|
||||
'.dsh-wt_pdfBtn:hover{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06))}',
|
||||
'.dsh-wt_pdfBtnOn{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06))}',
|
||||
'.dsh-wt_pdfPct{font-size:11px;color:var(--dsw-alias-label-secondary,#9aa4b2);min-width:42px;text-align:center}',
|
||||
'.dsh-wt_pdf{flex:1;min-height:0;overflow:auto;padding:12px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03))}',
|
||||
'.dsh-wt_pdfPan{cursor:grab}',
|
||||
'.dsh-wt_pdfPan:active{cursor:grabbing}',
|
||||
'.dsh-wt_pdfPage{margin:0 auto 14px;width:max-content;background:#fff;box-shadow:var(--dsw-shadow-lv2,0 8px 24px rgba(0,0,0,.4))}',
|
||||
'.dsh-wt_pdfPage canvas{display:block}',
|
||||
'.dsh-wt_pane[data-drop-hover=true]{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);box-shadow:0 0 0 2px rgba(79,142,247,.4),var(--dsw-shadow-lv2,0 8px 24px rgba(0,0,0,.4));transform:scale(1.012)}',
|
||||
'.dsh-wt_paneBar{flex:none;display:flex;align-items:center;height:22px;padding:0 8px;border-bottom:1px solid var(--dsw-alias-border-l1,#262b36);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02))}',
|
||||
'.dsh-wt_paneTitle{flex:1;min-width:0;font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary,#9aa4b2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
||||
|
||||
Reference in New Issue
Block a user