diff --git a/01_content/build.mjs b/01_content/build.mjs index b2fa920..77c8f33 100644 --- a/01_content/build.mjs +++ b/01_content/build.mjs @@ -14,15 +14,8 @@ import { fileURLToPath } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) mkdirSync(join(here, 'lib'), { recursive: true }) -// pdf.js 工作线程源码(legacy ESM 构建)注入客户端全局,避免任何服务端依赖 -const pdfWorkerSrc = readFileSync( - join(here, 'node_modules', 'pdfjs-dist', 'legacy', 'build', 'pdf.worker.min.mjs'), - 'utf8', -) - const clientBanner = { - js: "var __WT_PDF_WORKER__ = " + JSON.stringify(pdfWorkerSrc) + ";\n" + - "window.__ModuleLoader__.load({ id: 'dsh-worktable', factory: (require) => { var module = { exports: {} }; var exports = module.exports;", + js: "window.__ModuleLoader__.load({ id: 'dsh-worktable', factory: (require) => { var module = { exports: {} }; var exports = module.exports;", } const clientFooter = { js: 'return module.exports; } });' } diff --git a/01_content/package-lock.json b/01_content/package-lock.json index 115842a..440d28e 100644 --- a/01_content/package-lock.json +++ b/01_content/package-lock.json @@ -11,7 +11,6 @@ "devDependencies": { "esbuild": "^0.25.0", "markdown-it": "^15.0.0", - "pdfjs-dist": "^4.10.38", "xterm": "^5.3.0" }, "peerDependencies": { @@ -473,283 +472,6 @@ "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", @@ -877,19 +599,6 @@ "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 408eb4d..17a177d 100644 --- a/01_content/package.json +++ b/01_content/package.json @@ -53,7 +53,6 @@ "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/index.tsx b/01_content/src/client/index.tsx index 4bf8eee..746f476 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -308,7 +308,6 @@ function WorktableSection(props: any) { const [projects, setProjects] = useState(loadProjects) const [metas, setMetas] = useState>({}) const [registeredIds, setRegisteredIds] = useState(() => [...registryStore.ids]) - const [managing, setManaging] = useState(false) const [addOpen, setAddOpen] = useState(false) const [viewOptionsOpen, setViewOptionsOpen] = useState(false) const [wsPreset, setWsPreset] = useState('2h') @@ -318,6 +317,8 @@ function WorktableSection(props: any) { const [iconPick, setIconPick] = useState<{ kind: 'layout' | 'shortcut' | 'project'; id: string; x: number; y: number } | null>(null) /** 删除二次确认:kind + 目标 id + 显示名 */ const [requestDelete, setRequestDelete] = useState<{ kind: 'layout' | 'shortcut' | 'project'; id: string; name: string } | null>(null) + /** 变更视图:正在挑选新拓扑的布局 id */ + const [viewPickFor, setViewPickFor] = useState(null) const [float, setFloat] = useState(() => view.dock === 'float' && view.floatTop != null ? { top: view.floatTop } : null, ) @@ -526,7 +527,7 @@ function WorktableSection(props: any) { const ownerProps = { query: view.query.trim(), wide, - managing, + managing: viewOptionsOpen, order: effectiveOrder, hidden: [...projects.hidden, ...projects.removed], nameOverrides: projects.nameOverrides, @@ -669,6 +670,57 @@ function WorktableSection(props: any) { persistProjects((prev) => ({ ...prev, layouts: prev.layouts.filter((l) => l.id !== id) })) } + // ── 变更视图:按预设重建布局拓扑,现有窗内容(标签)按序迁入,不丢失 ── + const applyLayoutChange = (id: string, presetId: string) => { + const layout = projects.layouts.find((l) => l.id === id) + if (!layout) return + const sources = [...(layout.top ?? []), ...layout.main] + .map((pp) => pp.tabs ?? []) + .filter((tabs) => tabs.length > 0) + const next = buildLayout(presetId, layout.title) + next.id = layout.id + next.icon = layout.icon + const targets = [...(next.left ? [next.left] : []), ...(next.top ?? []), ...next.main] + let si = 0 + for (const pane of targets) { + if (si < sources.length) { + pane.tabs = sources[si] + pane.active = 0 + pane.content = null + si++ + } + } + const overflow = sources.slice(si).flat() + if (overflow.length > 0 && targets.length > 0) { + const last = targets[targets.length - 1] + last.tabs = [...(last.tabs ?? []), ...overflow] + last.active = 0 + } + persistProjects((prev) => ({ + ...prev, + layouts: prev.layouts.map((l) => (l.id === id ? next : l)), + })) + // 该布局当前打开时:关旧开新,工作区即时变为新视图 + const wasOpen = splitStore.active && splitStore.spec?.id === id + if (wasOpen) { + splitStore.close() + openSplit(next) + } + setViewPickFor(null) + } + + /** 布局当前拓扑对应的预设 id(用于视图选择器高亮) */ + const presetOf = (l: LayoutSpec): string => { + const leftCount = l.left ? 1 : 0 + const topCount = (l.top ?? []).length + const contentCount = l.main.length + const chatFull = l.chatFullHeight === true + const def = PRESET_DEFS.find((d) => + d.leftCount === leftCount && d.topCount === topCount && d.contentCount === contentCount && d.chatFull === chatFull, + ) + return def ? def.id : '2h' + } + // ── 删除(全部走二次确认;常驻项目 = 移出工作台,插件不卸载) ── const removeProject = (id: string) => { persistProjects((prev) => ({ @@ -983,24 +1035,16 @@ function WorktableSection(props: any) { {viewOptionsOpen &&
setViewOptionsOpen(false)} />} {viewOptionsOpen && ( -
+
{t('sort.label')} + onClick={() => persistView({ orderBy: 'manual' })}>{t('sort.manual')} + onClick={() => persistView({ orderBy: 'recent' })}>{t('sort.recent')}
- -
- )} - - {managing &&
setManaging(false)} />} - {managing && ( -
{t('manage.title')} - +
{effectiveOrder.map((id) => { const meta = metas[id] @@ -1048,6 +1092,9 @@ function WorktableSection(props: any) { + {layout && ( + + )} + ) + })} +
+
+ )} + {requestDelete &&
setRequestDelete(null)} />} {requestDelete && (
@@ -1093,7 +1163,7 @@ function WorktableSection(props: any) {
)} -
+
{renderProjectSlot ? renderProjectSlot('sidebar.worktable.project', ownerProps) :
{t('empty')}
} diff --git a/01_content/src/client/locales.ts b/01_content/src/client/locales.ts index b22735a..3175d89 100644 --- a/01_content/src/client/locales.ts +++ b/01_content/src/client/locales.ts @@ -11,7 +11,7 @@ export const zh = { 'handle.title': '按住拖动位置 · 双击复位', 'handle.aria': '拖动工作台位置', 'menu.search': '搜索项目', - 'menu.viewOptions': '视图选项', + 'menu.viewOptions': '设置', 'menu.add': '添加项目', 'search.placeholder': '搜索项目…', 'search.close': '关闭搜索', @@ -45,6 +45,8 @@ export const zh = { 'add.layoutInvalid': '请填写布局名称', 'manage.deleteLayout': '删除布局', 'manage.deleteProject': '删除项目', + 'manage.changeView': '变更视图', + 'viewPick.title': '选择新视图(内容窗自动迁入)', 'confirm.title': '删除项目', 'confirm.cancel': '取消', 'confirm.delete': '删除', @@ -64,10 +66,6 @@ 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': '当前会话暂无后台任务', @@ -93,7 +91,7 @@ export const en = { 'handle.title': 'Drag to move · double-click to reset', 'handle.aria': 'Drag worktable position', 'menu.search': 'Search projects', - 'menu.viewOptions': 'View options', + 'menu.viewOptions': 'Settings', 'menu.add': 'Add project', 'search.placeholder': 'Search projects…', 'search.close': 'Close search', @@ -127,6 +125,8 @@ export const en = { 'add.layoutInvalid': 'Enter a layout name', 'manage.deleteLayout': 'Delete layout', 'manage.deleteProject': 'Delete project', + 'manage.changeView': 'Change layout', + 'viewPick.title': 'Pick a new layout (panes migrate automatically)', 'confirm.title': 'Delete project', 'confirm.cancel': 'Cancel', 'confirm.delete': 'Delete', @@ -146,10 +146,6 @@ 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 76e2f31..48fb896 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -2,7 +2,6 @@ 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)。 @@ -1098,201 +1097,12 @@ 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 阅读器: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