revert: PDF回退原生阅读器(自绘抓手不可行) + feat: 视图选项改名设置并直接内嵌排序+管理列表 + 变更视图(内容自动迁入新拓扑)

This commit is contained in:
Aisland
2026-08-17 23:00:55 +08:00
parent b86b2361e8
commit 3a085e8bca
12 changed files with 145 additions and 652 deletions
+85 -15
View File
@@ -308,7 +308,6 @@ function WorktableSection(props: any) {
const [projects, setProjects] = useState<ProjectsState>(loadProjects)
const [metas, setMetas] = useState<Record<string, ProjectMeta>>({})
const [registeredIds, setRegisteredIds] = useState<string[]>(() => [...registryStore.ids])
const [managing, setManaging] = useState(false)
const [addOpen, setAddOpen] = useState(false)
const [viewOptionsOpen, setViewOptionsOpen] = useState(false)
const [wsPreset, setWsPreset] = useState<string>('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<string | null>(null)
const [float, setFloat] = useState<FloatRect | null>(() =>
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 && <div className="dsh-wt_popBackdrop" onClick={() => setViewOptionsOpen(false)} />}
{viewOptionsOpen && (
<div className="dsh-wt_menu dsh-wt_pop" style={{ position: 'fixed', left: popLeft, top: popTop, width: 200, zIndex: 80 }}>
<div className="dsh-wt_manage dsh-wt_pop dsh-wt_settings" style={{ position: 'fixed', left: popLeft, top: popTop, width: 316, zIndex: 80 }}>
<span className="dsh-wt_menuLabel">{t('sort.label')}</span>
<button type="button" className="dsh-wt_menuItem" data-on={view.orderBy === 'manual'}
onClick={() => { persistView({ orderBy: 'manual' }); setViewOptionsOpen(false) }}>{t('sort.manual')}</button>
onClick={() => persistView({ orderBy: 'manual' })}>{t('sort.manual')}</button>
<button type="button" className="dsh-wt_menuItem" data-on={view.orderBy === 'recent'}
onClick={() => { persistView({ orderBy: 'recent' }); setViewOptionsOpen(false) }}>{t('sort.recent')}</button>
onClick={() => persistView({ orderBy: 'recent' })}>{t('sort.recent')}</button>
<div className="dsh-wt_menuSep" />
<button type="button" className="dsh-wt_menuItem"
onClick={() => { setManaging(true); setViewOptionsOpen(false) }}>{t('menu.manage')}</button>
</div>
)}
{managing && <div className="dsh-wt_popBackdrop" onClick={() => setManaging(false)} />}
{managing && (
<div className="dsh-wt_manage dsh-wt_pop" style={{ position: 'fixed', left: popLeft, top: popTop, width: 316, zIndex: 80 }}>
<div className="dsh-wt_manageHead">
<span className="dsh-wt_manageTitle">{t('manage.title')}</span>
<button type="button" className="dsh-wt_manageDone" onClick={() => setManaging(false)}>{t('manage.done')}</button>
<button type="button" className="dsh-wt_manageDone" onClick={() => setViewOptionsOpen(false)}>{t('manage.done')}</button>
</div>
{effectiveOrder.map((id) => {
const meta = metas[id]
@@ -1048,6 +1092,9 @@ function WorktableSection(props: any) {
<button type="button" className="dsh-wt_manageBtn" title={isHidden ? t('manage.show') : t('manage.hide')} onClick={() => toggleHidden(id)}>
{isHidden ? '🙈' : '👁'}
</button>
{layout && (
<button type="button" className="dsh-wt_manageBtn" title={t('manage.changeView')} onClick={() => setViewPickFor(id)}>🧩</button>
)}
<button
type="button"
className="dsh-wt_manageBtn"
@@ -1075,6 +1122,29 @@ function WorktableSection(props: any) {
</div>
)}
{viewPickFor && <div className="dsh-wt_popBackdrop" style={{ zIndex: 81 }} onClick={() => setViewPickFor(null)} />}
{viewPickFor && (
<div className="dsh-wt_menu dsh-wt_pop" style={{ position: 'fixed', left: popLeft, top: popTop, width: 320, zIndex: 82 }}>
<span className="dsh-wt_menuLabel">{t('viewPick.title')}</span>
<div className="dsh-wt_presets">
{PRESET_DEFS.map((def) => {
const cur = projects.layouts.find((l) => l.id === viewPickFor)
return (
<button
key={def.id}
type="button"
className="dsh-wt_preset"
data-on={cur && presetOf(cur) === def.id ? 'true' : 'false'}
onClick={() => applyLayoutChange(viewPickFor, def.id)}
>
{presetThumb(def.id)}
</button>
)
})}
</div>
</div>
)}
{requestDelete && <div className="dsh-wt_confirmBackdrop" onClick={() => setRequestDelete(null)} />}
{requestDelete && (
<div className="dsh-wt_confirm" role="alertdialog">
@@ -1093,7 +1163,7 @@ function WorktableSection(props: any) {
</div>
)}
<div className="dsh-wt_projects" data-managing={managing ? 'true' : undefined}>
<div className="dsh-wt_projects" data-managing={viewOptionsOpen ? 'true' : undefined}>
{renderProjectSlot
? renderProjectSlot('sidebar.worktable.project', ownerProps)
: <div className="dsh-wt_empty">{t('empty')}</div>}
+6 -10
View File
@@ -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',
+2 -192
View File
@@ -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<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 渲染、其余纯文本 */
/** 本地文件预览:PDF 走原生 iframe(Chrome 内置阅读器)、图片居中、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 <PdfViewer path={props.path} />
return <iframe className="dsh-wt_paneFrame" src={fileUrl} title={basenameOf(props.path)} />
}
if (IMAGE_EXTS.test('.' + ext)) {
return (
+1 -11
View File
@@ -100,17 +100,7 @@ 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}',