feat: PDF自绘阅读器+抓手(空格按住/H切换/顶部按钮)+缩放适应宽度(pdf.js worker注入bundle零服务端依赖)

This commit is contained in:
Aisland
2026-08-17 22:50:14 +08:00
parent eaaef0945e
commit b86b2361e8
12 changed files with 666 additions and 46 deletions
+37 -42
View File
@@ -3,59 +3,54 @@
* - lib/index.js 服务端 ESM(cordis 插件,健康路由)
* - lib/client.js 客户端单文件 CJS(window.__ModuleLoader__.load 握手;
* react / @deepseek-ai/* 由宿主模块系统提供,保持 external)
* esbuild 优先用本包 node_modules 的平台二进制,其次复用相邻项目 vendor。
* esbuild 走 JS API;pdf.js worker 源码以字符串注入客户端 banner,
* 运行时用 Blob URL 起 module worker(不依赖服务端路由,F5 即生效)。
*/
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync } from 'node:fs'
import { build } from 'esbuild'
import { readFileSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
mkdirSync(join(here, 'lib'), { recursive: true })
const esbuildCandidates = [
join(here, 'node_modules', '@esbuild', 'win32-x64', 'esbuild.exe'),
join(here, '..', '..', 'dsh-travelatlas', 'node_modules', '@esbuild', 'win32-x64', 'esbuild.exe'),
]
const esbuildBin = esbuildCandidates.find((p) => existsSync(p))
if (!esbuildBin) {
console.error('[dsh-worktable build] 找不到 esbuild.exe:请先在 01_content 运行 npm install')
process.exit(1)
}
console.log('[dsh-worktable build] esbuild: ' + esbuildBin)
// pdf.js 工作线程源码(legacy ESM 构建)注入客户端全局,避免任何服务端依赖
const pdfWorkerSrc = readFileSync(
join(here, 'node_modules', 'pdfjs-dist', 'legacy', 'build', 'pdf.worker.min.mjs'),
'utf8',
)
const clientBanner = {
js: "window.__ModuleLoader__.load({ id: 'dsh-worktable', factory: (require) => { var module = { exports: {} }; var exports = module.exports;",
js: "var __WT_PDF_WORKER__ = " + JSON.stringify(pdfWorkerSrc) + ";\n" +
"window.__ModuleLoader__.load({ id: 'dsh-worktable', factory: (require) => { var module = { exports: {} }; var exports = module.exports;",
}
const clientFooter = { js: 'return module.exports; } });' }
const jobs = [
{
entryPoints: [join(here, 'src/index.ts')], outfile: 'lib/index.js',
platform: 'node', format: 'esm', target: ['node22'],
external: ['@deepseek-ai/*', 'node:*', 'ws', 'node-pty'],
},
{
entryPoints: [join(here, 'src/client/index.tsx')], outfile: 'lib/client.js',
platform: 'browser', format: 'cjs', target: ['es2022'], jsx: 'automatic',
external: ['@deepseek-ai/*', 'react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'scheduler'],
banner: clientBanner, footer: clientFooter,
},
]
await build({
entryPoints: [join(here, 'src/index.ts')],
outfile: 'lib/index.js',
bundle: true,
sourcemap: true,
logLevel: 'info',
platform: 'node',
format: 'esm',
target: ['node22'],
external: ['@deepseek-ai/*', 'node:*', 'ws', 'node-pty'],
})
await build({
entryPoints: [join(here, 'src/client/index.tsx')],
outfile: 'lib/client.js',
bundle: true,
sourcemap: true,
logLevel: 'info',
platform: 'browser',
format: 'cjs',
target: ['es2022'],
jsx: 'automatic',
external: ['@deepseek-ai/*', 'react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'scheduler'],
banner: clientBanner,
footer: clientFooter,
})
for (const job of jobs) {
const args = [
...job.entryPoints,
'--bundle', '--sourcemap', '--log-level=info',
'--platform=' + job.platform,
'--format=' + job.format,
'--target=' + job.target.join(','),
'--outfile=' + join(here, job.outfile),
...job.external.map((e) => '--external:' + e),
]
if (job.jsx) args.push('--jsx=' + job.jsx)
if (job.banner) args.push('--banner:js=' + job.banner.js)
if (job.footer) args.push('--footer:js=' + job.footer.js)
execFileSync(esbuildBin, args, { stdio: 'inherit' })
}
console.log('[dsh-worktable build] done: lib/index.js, lib/client.js')
+291
View File
@@ -11,6 +11,7 @@
"devDependencies": {
"esbuild": "^0.25.0",
"markdown-it": "^15.0.0",
"pdfjs-dist": "^4.10.38",
"xterm": "^5.3.0"
},
"peerDependencies": {
@@ -472,6 +473,283 @@
"node": ">=18"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-0.1.100.tgz",
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
"dev": true,
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.100",
"@napi-rs/canvas-darwin-arm64": "0.1.100",
"@napi-rs/canvas-darwin-x64": "0.1.100",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/argparse": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-3.0.0.tgz",
@@ -599,6 +877,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/pdfjs-dist": {
"version": "4.10.38",
"resolved": "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz",
"integrity": "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.65"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
+1
View File
@@ -53,6 +53,7 @@
"devDependencies": {
"esbuild": "^0.25.0",
"markdown-it": "^15.0.0",
"pdfjs-dist": "^4.10.38",
"xterm": "^5.3.0"
}
}
+8
View File
@@ -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',
+192 -2
View File
@@ -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 (
+11
View File
@@ -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}',
+1 -1
View File
@@ -353,7 +353,7 @@ type SplitPane = {
| 内容窗 | 可行性 | 依据 |
| --- | --- | --- |
| 浏览器 | ✅ 已实现 | iframe + 地址栏 |
| 资源管理器 | ✅ 已实现(第一版) | 服务端 /api/worktable/fs 目录列表(参考 better-sidebar 架构);文件打开待后续 |
| 资源管理器 | ✅ 已实现(第一版) | 服务端 /api/worktable/fs 目录列表(参考 better-sidebar 架构);文件点击开预览标签(2026-08-17):.html iframe、.md markdown-it 渲染、.txt/.log 纯文本、.pdf pdf.js 自绘阅读器(抓手:按住空格临时拖动 / H 切换常开 / 顶部 ✋ 按钮,缩放与适应宽度)、常见图片居中展示;pdf.js worker 源码注入客户端 bundle(Blob URL 起 module worker,零服务端依赖) |
| 源代码管理 | ✅ 已实现(第一版) | 服务端 /api/worktable/git(porcelain v1 -z);diff/暂存/提交待后续 |
| 终端 | ✅ 已实现(第一版) | WS /api/worktable/term + node-pty + xterm(宿主缺 node-pty 时降级提示) |
| 任务管理 | ✅ 已实现(第一版) | 客户端 sessions 快照 jobsBySession(后台任务列表,2s 刷新) |
@@ -615,3 +615,21 @@
PRD 开头文本;同路径再开 tabsAfterDup=1(去重);package.json → .dsh-wt_txt 展示;
全 STEP 回归 ERROR_COUNT: 0。bundle-eval 补 atob/btoa 桩后 PASS(markdown-it 引入)。
- 备注:客户端改动 F5 生效;服务端 pdf MIME 需手动重启 dsh web 一次后 PDF 预览才生效。
## 补记(PDF 自绘阅读器:抓手/空格拖拽/H 切换/缩放)
- 用户需求:PDF 阅读器加抓手工具——顶部 ✋ 按钮;按住空格期间鼠标变抓手、左键拖动平移、
松开空格取消;快捷键 H 一键切换抓手;抓手态下拖动即可移动页面。
- 关键决策:原生 Chrome PDF 视图(iframe)内部无法拦截鼠标 → 改用 pdf.js 自绘渲染
(画布多页纵向 + 视口按需渲染 + DPR≤2)。
- 实现:
① PdfViewer 组件:工具栏 [✋抓手][−][百分比][+][⤢适应宽度];缩放 0.2~5;
抓手三入口——空格按住(输入框不劫持、松开取消)/ H 常开切换 / 顶部按钮;
抓手态 cursor grab/grabbing,pointer 捕获拖拽改 scrollLeft/Top;
② worker 零服务端依赖:build.mjs 改 esbuild JS API,把 pdf.worker.min.mjs 源码以
__WT_PDF_WORKER__ 字符串注入 client banner(bundle 2.6MB),运行时 Blob URL 起 module worker;
失败自动回退原生 iframe(服务端 MIME 已补 application/pdf 备用);
③ 服务端 file 路由 MIME 补 pdf/markdown/log/bmp/ico。
- 验证(functional-diag STEP12 + 04_test/fixture.pdf 夹具):canvasW=439 自绘渲染;
空格按住 panClass=true/松开 false;H 开/关正确;放大至 281% 溢出后合成指针拖拽
scrollTopAfterDrag=140(平移生效);全 STEP 回归 ERROR_COUNT: 0。
- 备注:全部客户端改动,F5 即生效,无需重启 dsh web。
+2
View File
@@ -29,6 +29,8 @@ const sandbox = { self: null,
localStorage: { getItem(){ return null }, setItem(){}, removeItem(){} },
atob: (s) => Buffer.from(s, 'base64').toString('binary'), btoa: (s) => Buffer.from(s, 'binary').toString('base64'),
CustomEvent: class { constructor(t, o){ this.type = t; this.detail = o && o.detail } },
DOMMatrix: class {}, Path2D: class {}, ImageData: class {}, TextMetrics: class {}, OffscreenCanvas: class { getContext(){ return null } }, structuredClone: (v) => JSON.parse(JSON.stringify(v)),
DOMException: class extends Error { constructor(m, n){ super(m); this.name = n } }, AbortController: class { constructor(){ this.signal = {} } abort(){} }, queueMicrotask: (f) => Promise.resolve().then(f), ImageBitmap: class {}, MessageChannel: class { constructor(){ this.port1 = {}; this.port2 = {} } }, EventTarget: class { addEventListener(){} removeEventListener(){} dispatchEvent(){ return true } },
ResizeObserver: class { observe(){} disconnect(){} },
MutationObserver: class { observe(){} disconnect(){} },
navigator: { userAgent: "bundle-eval/1.0", platform: "Win32" }, location: { protocol: "http:", host: "127.0.0.1:3080" },
+32
View File
@@ -0,0 +1,32 @@
%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
endobj
4 0 obj
<< /Length 74 >>
stream
BT /F1 24 Tf 72 770 Td (Hello Worktable PDF) Tj ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000241 00000 n
0000000341 00000 n
trailer
<< /Size 6 /Root 1 0 R >>
startxref
411
%%EOF
+52 -1
View File
@@ -272,7 +272,58 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
})()`);
console.log('STEP11:', JSON.stringify(step11));
const errors = events.filter((e) =>
// PDF 阅读器:自绘渲染 + 抓手(H 切换/空格临时/拖拽平移)+ 缩放
const step12 = await evaluate(`(async function(){
var out={};
try{
var st=window.__dshWorktable.splitStore;
st.open({id:'t-pdf',title:'pdf',top:null,main:[{id:'q1',title:'q',min:200,content:null}],chatWidth:{default:320,min:240,max:600}});
await new Promise(function(r){setTimeout(r,400)});
st.openTab('main',0,{kind:'file',path:'E:\\\\AI_Workspace\\\\DeepseekHarness\\\\Projects\\\\dsh-worktable\\\\04_test\\\\fixture.pdf'});
await new Promise(function(r){setTimeout(r,6500)});
out.bar=!!document.querySelector('.dsh-wt_pdfBar');
out.pages=document.querySelectorAll('.dsh-wt_pdfPage canvas').length;
out.iframeFallback=!!document.querySelector('.dsh-wt_paneFrame');
var c=document.querySelector('.dsh-wt_pdfPage canvas');
out.canvasW=c?c.width:0;
var pdfEl=document.querySelector('.dsh-wt_pdf');
if(pdfEl){
// 空格临时抓手
window.dispatchEvent(new KeyboardEvent('keydown',{key:' ',code:'Space',bubbles:true}));
await new Promise(function(r){setTimeout(r,200)});
out.panClassAfterSpace=pdfEl.className.indexOf('dsh-wt_pdfPan')>=0;
window.dispatchEvent(new KeyboardEvent('keyup',{key:' ',code:'Space',bubbles:true}));
await new Promise(function(r){setTimeout(r,200)});
out.panClassAfterSpaceUp=pdfEl.className.indexOf('dsh-wt_pdfPan')>=0;
// H 常开抓手
window.dispatchEvent(new KeyboardEvent('keydown',{key:'h',code:'KeyH',bubbles:true}));
await new Promise(function(r){setTimeout(r,200)});
out.panClassAfterH=pdfEl.className.indexOf('dsh-wt_pdfPan')>=0;
// 放大到内容溢出(可滚动;btns 顺序:抓手/缩小/放大/适应宽度)
var btns=document.querySelectorAll('.dsh-wt_pdfBtn');
for(var z=0;z<6 && btns[2];z++){ btns[2].click(); }
await new Promise(function(r){setTimeout(r,1200)});
out.pct=document.querySelector('.dsh-wt_pdfPct')?document.querySelector('.dsh-wt_pdfPct').textContent:null;
out.scrollable=pdfEl.scrollHeight>pdfEl.clientHeight;
var r0=pdfEl.getBoundingClientRect();
var x=r0.left+r0.width/2, y=r0.top+r0.height/2;
pdfEl.dispatchEvent(new PointerEvent('pointerdown',{clientX:x,clientY:y,bubbles:true,pointerId:1}));
pdfEl.dispatchEvent(new PointerEvent('pointermove',{clientX:x,clientY:y-140,bubbles:true,pointerId:1}));
pdfEl.dispatchEvent(new PointerEvent('pointerup',{clientX:x,clientY:y-140,bubbles:true,pointerId:1}));
await new Promise(function(r){setTimeout(r,300)});
out.scrollTopAfterDrag=pdfEl.scrollTop;
window.dispatchEvent(new KeyboardEvent('keydown',{key:'h',code:'KeyH',bubbles:true}));
await new Promise(function(r){setTimeout(r,200)});
out.panClassAfterH2=pdfEl.className.indexOf('dsh-wt_pdfPan')>=0;
}
st.close();
}catch(err){ out.err=String(err) }
return JSON.stringify(out);
})()`);
console.log('STEP12:', JSON.stringify(step12));
const errors = events.filter((e) => {
}).filter((e) =>
e.method === 'Runtime.exceptionThrown' ||
(e.method === 'Log.entryAdded' && e.params.entry.level === 'error') ||
(e.method === 'Runtime.consoleAPICalled' && e.params.type === 'error'),
+21
View File
@@ -0,0 +1,21 @@
// 生成最小合法 PDF 测试夹具(单页 Hello,带正确 xref 偏移)
const fs = require('fs');
const objects = [
'1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n',
'2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n',
'3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n',
'4 0 obj\n<< /Length 74 >>\nstream\nBT /F1 24 Tf 72 770 Td (Hello Worktable PDF) Tj ET\nendstream\nendobj\n',
'5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n',
];
let body = '%PDF-1.4\n';
const offsets = [0];
for (const o of objects) { offsets.push(body.length); body += o; }
const xrefPos = body.length;
body += 'xref\n0 ' + (objects.length + 1) + '\n';
body += '0000000000 65535 f \n';
for (let i = 1; i <= objects.length; i++) body += String(offsets[i]).padStart(10, '0') + ' 00000 n \n';
body += 'trailer\n<< /Size ' + (objects.length + 1) + ' /Root 1 0 R >>\n';
body += 'startxref\n' + xrefPos + '\n%%EOF\n';
const out = 'E:/AI_Workspace/DeepseekHarness/Projects/dsh-worktable/04_test/fixture.pdf';
fs.writeFileSync(out, body, 'latin1');
console.log('fixture written', fs.statSync(out).size, 'bytes');