From b86b2361e8ddd0bc877710e287443a5bd0fc6c84 Mon Sep 17 00:00:00 2001 From: Aisland Date: Mon, 17 Aug 2026 22:50:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20PDF=E8=87=AA=E7=BB=98=E9=98=85=E8=AF=BB?= =?UTF-8?q?=E5=99=A8+=E6=8A=93=E6=89=8B(=E7=A9=BA=E6=A0=BC=E6=8C=89?= =?UTF-8?q?=E4=BD=8F/H=E5=88=87=E6=8D=A2/=E9=A1=B6=E9=83=A8=E6=8C=89?= =?UTF-8?q?=E9=92=AE)+=E7=BC=A9=E6=94=BE=E9=80=82=E5=BA=94=E5=AE=BD?= =?UTF-8?q?=E5=BA=A6(pdf.js=20worker=E6=B3=A8=E5=85=A5bundle=E9=9B=B6?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E4=BE=9D=E8=B5=96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 01_content/build.mjs | 79 +++-- 01_content/package-lock.json | 291 ++++++++++++++++++ 01_content/package.json | 1 + 01_content/src/client/locales.ts | 8 + 01_content/src/client/split.tsx | 194 +++++++++++- 01_content/src/client/styles.ts | 11 + 02_process/PRD.md | 2 +- .../worklogs/2026-08-16_worktable-v2.md | 18 ++ 04_test/bundle-eval.cjs | 2 + 04_test/fixture.pdf | 32 ++ 04_test/functional-diag.cjs | 53 +++- 04_test/make-fixture.cjs | 21 ++ 12 files changed, 666 insertions(+), 46 deletions(-) create mode 100644 04_test/fixture.pdf create mode 100644 04_test/make-fixture.cjs diff --git a/01_content/build.mjs b/01_content/build.mjs index 77b25b2..b2fa920 100644 --- a/01_content/build.mjs +++ b/01_content/build.mjs @@ -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') diff --git a/01_content/package-lock.json b/01_content/package-lock.json index 440d28e..115842a 100644 --- a/01_content/package-lock.json +++ b/01_content/package-lock.json @@ -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", diff --git a/01_content/package.json b/01_content/package.json index 17a177d..408eb4d 100644 --- a/01_content/package.json +++ b/01_content/package.json @@ -53,6 +53,7 @@ "devDependencies": { "esbuild": "^0.25.0", "markdown-it": "^15.0.0", + "pdfjs-dist": "^4.10.38", "xterm": "^5.3.0" } } diff --git a/01_content/src/client/locales.ts b/01_content/src/client/locales.ts index 86ab84e..b22735a 100644 --- a/01_content/src/client/locales.ts +++ b/01_content/src/client/locales.ts @@ -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', diff --git a/01_content/src/client/split.tsx b/01_content/src/client/split.tsx index a7e3e37..76e2f31 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -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(null) + const canvasesRef = useRef>({}) + const tasksRef = useRef>({}) + const rafRef = useRef(0) + const [failed, setFailed] = useState(false) + const [doc, setDoc] = useState(null) + const [pageCount, setPageCount] = useState(0) + const [scale, setScale] = useState(1) + const [fitScale, setFitScale] = useState(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('[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