@ggboy123/dsh-paper-reader 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -0
- package/cordis.patch.yml +9 -0
- package/dist/host.js +451 -0
- package/dist/index.js +17 -0
- package/dist/library.js +103 -0
- package/dist/preset.js +31 -0
- package/dist/search.js +98 -0
- package/dist/study.js +100 -0
- package/dist/tools.js +307 -0
- package/dist/transcribe.js +185 -0
- package/dist/translate-config.js +104 -0
- package/dist/translate.js +125 -0
- package/lib/client.js +720 -0
- package/package.json +68 -0
- package/presets/paper-reader/agent.cordis.yml +57 -0
- package/presets/paper-reader/preset.yml +3 -0
- package/reader/index.html +433 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
// dsh-paper-reader — BROWSER half(全部走官方 seam 组合,零自绘聊天)。
|
|
2
|
+
//
|
|
3
|
+
// 布局组合(用户习惯:中间论文、最右对话):
|
|
4
|
+
// [侧栏: 专题→论文→会话 树] [中间: PDF 阅读器] [最右: 原生 dsh 对话]
|
|
5
|
+
// - 原生对话组件绑定 main(keyed 'conversation'),无法挪进 rightbar;
|
|
6
|
+
// PDF 页签内容挂在 rightbar。于是做**视觉换位**:PDF 页签存在时,把 rightbar
|
|
7
|
+
// 列钉到 grid 第 2 轨(宽轨=阅读器),center 列自动流入第 3 轨(窄轨=对话)。
|
|
8
|
+
// 纯 CSS grid 放置技巧:JS 只维护 grid-template-columns 的列宽(宽轨给 PDF、
|
|
9
|
+
// 窄轨给对话,正是想要的比例),内容换列不影响拖拽手柄/全屏/折叠逻辑。
|
|
10
|
+
// - sidebar.workspaces single slot 按 priority -1000 接管(lowest renders)——
|
|
11
|
+
// ⚠️ 官方工作区/会话列表在本 profile 侧栏不可见;本插件面向论文伴读专用 profile,
|
|
12
|
+
// 从 cordis.patch.yml 删掉本插件即恢复官方工作区。
|
|
13
|
+
// - 选中即问:阅读器内选中 → /api/ask 注入当前会话 → 原生对话区实时流出。
|
|
14
|
+
//
|
|
15
|
+
// 加载机制:package.json 声明 dsh.client.inject + exports["./client"];require 解析
|
|
16
|
+
// 官方客户端包,不要用裸 import。
|
|
17
|
+
|
|
18
|
+
window.__ModuleLoader__.load({
|
|
19
|
+
id: 'dsh-paper-reader',
|
|
20
|
+
factory: (require) => {
|
|
21
|
+
const React = require('react')
|
|
22
|
+
const { createPortal } = require('react-dom')
|
|
23
|
+
const { useState, useEffect, useCallback } = React
|
|
24
|
+
const h = React.createElement
|
|
25
|
+
|
|
26
|
+
const NS = 'dsh-paper-reader'
|
|
27
|
+
const zh = {
|
|
28
|
+
library: '文献库',
|
|
29
|
+
empty: '文献库为空', hint: '新建专题或上传 PDF 开始', newTopic: '新建专题',
|
|
30
|
+
upload: '上传 PDF 到此专题', newChat: '新建对话', session: '会话', pdfTab: '论文 PDF',
|
|
31
|
+
history: '历史对话',
|
|
32
|
+
sync: '对话还停在另一篇文献的会话里,点击切到当前文献',
|
|
33
|
+
}
|
|
34
|
+
const en = {
|
|
35
|
+
library: 'Library',
|
|
36
|
+
empty: 'Library is empty', hint: 'Create a topic or upload a PDF to start', newTopic: 'New topic',
|
|
37
|
+
upload: 'Upload PDF into this topic', newChat: 'New chat', session: 'Session', pdfTab: 'Paper PDF',
|
|
38
|
+
history: 'Chat history',
|
|
39
|
+
sync: 'Chat is still on another paper — click to switch to the current one',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let t = (k) => k
|
|
43
|
+
const setT = (fn) => { t = fn }
|
|
44
|
+
|
|
45
|
+
/* ---------- 共享状态桥(树 / PDF 页签 / 阅读器 iframe 三方同步) ---------- */
|
|
46
|
+
const sessionKey = (topic, name) => `dpr.sessionN.${topic}/${name}`
|
|
47
|
+
function setCurrentPaper(topic, name, n) {
|
|
48
|
+
localStorage.setItem('dpr.last', JSON.stringify({ topic, name }))
|
|
49
|
+
localStorage.setItem(sessionKey(topic, name), String(n))
|
|
50
|
+
window.dispatchEvent(new CustomEvent('dpr:paper', { detail: { topic, name, n } }))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function api(path, opts) {
|
|
54
|
+
const r = await fetch('/paper-reader/api' + path, opts)
|
|
55
|
+
if (!r.ok) {
|
|
56
|
+
const body = await r.json().catch(() => ({}))
|
|
57
|
+
throw new Error(body.error || body.reason || ('HTTP ' + r.status))
|
|
58
|
+
}
|
|
59
|
+
return r.json()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* ---------- 会话切换护栏:PDF/网格全程冻结,只允许对话区换内容 ----------
|
|
63
|
+
rightbar.session 是 session 作用域 slot,切会话 = seat 整体重挂:
|
|
64
|
+
旧 seat 卸载时汇报 closeRightbar,新 seat 初挂时 surface 尚未建立也汇报
|
|
65
|
+
closed → 右栏轨道塌 0 → 对话列(钉在第 3 轨)消失、PDF 列(第 2 轨)撑满
|
|
66
|
+
→ 换位按轨宽撤钉 → openTab 再撑开 = 用户看到的"整个页面跟着动"(逐帧实测)。
|
|
67
|
+
预开路径救不了新建会话:openTabIn 对未被 adopt 的 store 恒静默 no-op
|
|
68
|
+
(新建会话要等成为当前会话才 adopt,实测创建后等 2.5s 仍 no-op),
|
|
69
|
+
而非当前会话的 surface 又没有任何 API 可读 → 无法验证预开是否生效。
|
|
70
|
+
于是反向兜底,切换窗口(1.5s)内三件事:
|
|
71
|
+
① 吞掉 layout.closeRightbar 汇报 —— grid 冻在当前轨道(openRightbar 照放);
|
|
72
|
+
② ReaderOverlay 锚点测量/几何冻结 —— 锚点被重绑带着消失/滑动都是暂态
|
|
73
|
+
(applySwap 照跑:新面板的 maxWidth 钳制要尽快钉上,防左溢盖侧栏);
|
|
74
|
+
③ [data-rightbar-instant] 连带杀掉面板 transform 过渡(官方只管 grid/handle)。
|
|
75
|
+
窗口结束对账:若右栏面板确实没开着(护栏期间被收起/开页签失败),
|
|
76
|
+
补一次真实 close,让 grid 回到真状态。 */
|
|
77
|
+
const SWITCH_GUARD_MS = 1500
|
|
78
|
+
let switching = false
|
|
79
|
+
let switchTimer = 0
|
|
80
|
+
let realCloseRightbar = null
|
|
81
|
+
let layoutService = null
|
|
82
|
+
|
|
83
|
+
function beginSwitchGuard() {
|
|
84
|
+
switching = true
|
|
85
|
+
document.querySelector('[data-rightbar-col]')?.parentElement?.setAttribute('data-rightbar-instant', '')
|
|
86
|
+
if (layoutService && realCloseRightbar === null && typeof layoutService.closeRightbar === 'function') {
|
|
87
|
+
realCloseRightbar = layoutService.closeRightbar
|
|
88
|
+
layoutService.closeRightbar = () => {}
|
|
89
|
+
}
|
|
90
|
+
clearTimeout(switchTimer)
|
|
91
|
+
switchTimer = setTimeout(() => {
|
|
92
|
+
switching = false
|
|
93
|
+
document.querySelector('[data-rightbar-col]')?.parentElement?.removeAttribute('data-rightbar-instant')
|
|
94
|
+
if (!layoutService || realCloseRightbar === null) return
|
|
95
|
+
layoutService.closeRightbar = realCloseRightbar
|
|
96
|
+
const panelOpen = !!document.querySelector('[data-sidebar-right-panel][data-sidebar-right-open]')
|
|
97
|
+
realCloseRightbar = null
|
|
98
|
+
if (!panelOpen) {
|
|
99
|
+
try { layoutService.closeRightbar() } catch { /* 上下文已销毁 */ }
|
|
100
|
+
}
|
|
101
|
+
}, SWITCH_GUARD_MS)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ---------- 阅读器悬浮层:一个跨会话永不重建的 iframe ----------
|
|
105
|
+
rightbar 页签内容跟随会话 seat 重绑整体重建(TabSlot 以 tab.id 为 key,
|
|
106
|
+
新会话的 rightbar 布局里没有旧 tab)——iframe 每次重载,滚动/缩放全丢
|
|
107
|
+
("新建对话整页都动")。改为:PdfTab 只渲染占位锚点;真正的 iframe 由
|
|
108
|
+
ReaderOverlay 常驻渲染(挂在 LibraryTree 下,不随会话变),fixed 覆盖在锚点矩形上,
|
|
109
|
+
200ms 轮询跟随;锚点消失(关页签/收右栏/全屏)时隐藏但 iframe 存活。 */
|
|
110
|
+
function ReaderOverlay() {
|
|
111
|
+
const [cur, setCur] = useState(() => JSON.parse(localStorage.getItem('dpr.last') || 'null'))
|
|
112
|
+
const [geo, setGeo] = useState({ visible: false, left: 0, top: 0, width: 0, height: 0 })
|
|
113
|
+
const lastShownRef = React.useRef(0)
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const on = (e) => setCur({ topic: e.detail.topic, name: e.detail.name })
|
|
116
|
+
window.addEventListener('dpr:paper', on)
|
|
117
|
+
return () => window.removeEventListener('dpr:paper', on)
|
|
118
|
+
}, [])
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
let alive = true
|
|
121
|
+
// 布局换位:rightbar 列(PDF) ↔ center 列(对话) 交换视觉位置。列+行都钉
|
|
122
|
+
// (auto-placement 只钉列会重叠/塌行,实测);面板锚列右缘且内联宽度=rightbar
|
|
123
|
+
// 配置宽度,列进窄轨会左溢盖侧栏 → 钉 max-width(⚠️ 不抢 width:dsh 在会话 store
|
|
124
|
+
// 每次提交都重写内联 width,互写同属性 = 持续闪烁,实测)。
|
|
125
|
+
// 钉住条件按**右栏轨宽**自判断:右栏折叠(第 3 轨≈0)时撤钉 —— 否则 seat 重绑的
|
|
126
|
+
// 空窗期 PDF 列会吃掉对话列的宽度,双双消失又出现("整屏闪",实测)。
|
|
127
|
+
const setStyle = (el, prop, val) => { if (el.style[prop] !== val) el.style[prop] = val }
|
|
128
|
+
const applySwap = () => {
|
|
129
|
+
const col = document.querySelector('[data-rightbar-col]')
|
|
130
|
+
const center = col?.previousElementSibling // DOM 序固定:sidebar | center | rightbar
|
|
131
|
+
const frame = col?.parentElement
|
|
132
|
+
if (!col || !center || !frame) return
|
|
133
|
+
const tracks = getComputedStyle(frame).gridTemplateColumns.split(' ').map(parseFloat)
|
|
134
|
+
const eff = (tracks[2] ?? 0) >= 50 && !frame.hasAttribute('data-rightbar-fullscreen')
|
|
135
|
+
// 所有写入带值守卫:重复写同值也会触发样式重算,200ms 轮询下会闪
|
|
136
|
+
setStyle(col, 'gridColumn', eff ? '2' : '')
|
|
137
|
+
setStyle(col, 'gridRow', eff ? '1' : '')
|
|
138
|
+
setStyle(center, 'gridColumn', eff ? '3' : '')
|
|
139
|
+
setStyle(center, 'gridRow', eff ? '1' : '')
|
|
140
|
+
const panel = col.querySelector('[data-sidebar-right-panel]')
|
|
141
|
+
if (!panel) return
|
|
142
|
+
setStyle(panel, 'maxWidth', eff ? '100%' : '')
|
|
143
|
+
// dsh 把面板内联宽度写成 cols.rightbar 且锚在列右缘。换位后 col 落在 1fr 轨,
|
|
144
|
+
// 拖窄对话列时该轨会比配置宽度**宽**——面板不撑满就缩在列右半边,锚点跟着跑,
|
|
145
|
+
// 阅读器整个右移并变窄(实测 x=800 w=300,而列是 280..1100)。
|
|
146
|
+
// 只补 minWidth,不碰 width:dsh 每次会话提交都重写内联 width,抢同一属性会持续闪烁。
|
|
147
|
+
setStyle(panel, 'minWidth', eff ? '100%' : '')
|
|
148
|
+
}
|
|
149
|
+
const tick = () => {
|
|
150
|
+
if (!alive) return
|
|
151
|
+
// 切换护栏期:锚点测量/overlay 几何冻结 —— 锚点消失、被面板 transform
|
|
152
|
+
// 带着滑动、未钳制时左溢错位,都是 seat 重绑的暂态,追着跑 = overlay 乱跳
|
|
153
|
+
// (实测)。只冻结"正在显示"的场景;首开(从未显示过)不冻结,PDF 照常出现。
|
|
154
|
+
const frozen = switching && lastShownRef.current && Date.now() - lastShownRef.current < 5000
|
|
155
|
+
if (!frozen) {
|
|
156
|
+
const anchor = document.querySelector('[data-dsh-surface="rightbar-tab-anchor"]')
|
|
157
|
+
let next = null
|
|
158
|
+
if (anchor) {
|
|
159
|
+
const r = anchor.getBoundingClientRect()
|
|
160
|
+
if (r.width > 50 && r.height > 50) {
|
|
161
|
+
next = { visible: true, left: Math.round(r.left), top: Math.round(r.top), width: Math.round(r.width), height: Math.round(r.height) }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// 锚点消失只藏不拆,且隐藏带 900ms 迟滞:seat 重绑的百毫秒间隙里
|
|
165
|
+
// PDF 保持原位显示,不闪(iframe 常驻存活,拆了再挂就重载丢滚动)
|
|
166
|
+
setGeo((prev) => {
|
|
167
|
+
if (next) {
|
|
168
|
+
lastShownRef.current = Date.now()
|
|
169
|
+
return (prev.visible && prev.left === next.left && prev.top === next.top && prev.width === next.width && prev.height === next.height) ? prev : next
|
|
170
|
+
}
|
|
171
|
+
if (prev.visible && Date.now() - lastShownRef.current < 900) return prev
|
|
172
|
+
return prev.visible ? { ...prev, visible: false } : prev
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
// applySwap 冻结期照跑(值守卫幂等):新 seat 的面板是全新 DOM,宽度钳制
|
|
176
|
+
// (maxWidth)必须尽快钉上,否则面板按内联宽左溢盖住侧栏(实测锚点 x=168)
|
|
177
|
+
applySwap()
|
|
178
|
+
}
|
|
179
|
+
tick()
|
|
180
|
+
const timer = setInterval(tick, 200)
|
|
181
|
+
return () => { alive = false; clearInterval(timer) }
|
|
182
|
+
}, [])
|
|
183
|
+
if (!cur) return null
|
|
184
|
+
const src = `/paper-reader/?topic=${encodeURIComponent(cur.topic)}&name=${encodeURIComponent(cur.name)}`
|
|
185
|
+
return createPortal(
|
|
186
|
+
h('iframe', {
|
|
187
|
+
src, title: t('pdfTab'),
|
|
188
|
+
'data-dsh-plugin': 'dsh-paper-reader', 'data-dsh-surface': 'reader-overlay',
|
|
189
|
+
style: {
|
|
190
|
+
position: 'fixed', left: geo.left + 'px', top: geo.top + 'px',
|
|
191
|
+
width: geo.width + 'px', height: geo.height + 'px', border: 'none',
|
|
192
|
+
// 不用 display:none(布局归零会把 iframe 内滚动位置清零);visibility 保留布局
|
|
193
|
+
visibility: geo.visible ? 'visible' : 'hidden',
|
|
194
|
+
pointerEvents: geo.visible ? 'auto' : 'none',
|
|
195
|
+
// z-index 低于 overlayLayer(20) 与按钮组(45):不挡拖拽手柄和弹层
|
|
196
|
+
zIndex: 10, background: 'var(--dsw-alias-bg-layer-1, #f6f7f9)',
|
|
197
|
+
},
|
|
198
|
+
}),
|
|
199
|
+
document.body,
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* ---------- rightbar PDF 页签(只剩占位锚点 + 布局换位) ---------- */
|
|
204
|
+
function PdfTab() {
|
|
205
|
+
// 真正的阅读器是 ReaderOverlay 的常驻 iframe;这里只提供定位锚点
|
|
206
|
+
const hasPaper = !!localStorage.getItem('dpr.last')
|
|
207
|
+
return h('div', {
|
|
208
|
+
'data-dsh-surface': 'rightbar-tab-anchor',
|
|
209
|
+
style: { width: '100%', height: '100%' },
|
|
210
|
+
}, hasPaper ? null : h('div', { style: { padding: '16px', fontSize: '13px', color: 'var(--dsw-alias-label-secondary, #656d76)' } }, '← 从文献库选择一篇论文'))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* ---------- 对话列头部的 [🕐历史 / +新建] 浮动按钮组 ----------
|
|
214
|
+
官方 header.actions 是 session 作用域 slot,动态包(ModuleLoader)注册的条目
|
|
215
|
+
不被渲染(实测:register 成功但零渲染)——退化为 fixed 浮动组,portal 到 body,
|
|
216
|
+
200ms 轮询对齐对话列头部右侧原生按钮的左缘;仅当前会话是伴读会话(dpr-)时显示。
|
|
217
|
+
历史点开下拉:该论文全部会话(真实标题+日期),点哪条进哪条。 */
|
|
218
|
+
function parseDprSession(sid) {
|
|
219
|
+
if (typeof sid !== 'string' || !sid.startsWith('dpr-')) return null
|
|
220
|
+
try {
|
|
221
|
+
const body = sid.slice(4)
|
|
222
|
+
const m = body.match(/-(\d+)$/)
|
|
223
|
+
const n = m ? +m[1] : 1
|
|
224
|
+
const b64 = (m ? body.slice(0, -m[0].length) : body).replaceAll('-', '+').replaceAll('_', '/')
|
|
225
|
+
const bin = atob(b64)
|
|
226
|
+
const decoded = decodeURIComponent([...bin].map((c) => '%' + c.charCodeAt(0).toString(16).padStart(2, '0')).join(''))
|
|
227
|
+
const sep = decoded.indexOf('/')
|
|
228
|
+
if (sep <= 0 || sep === decoded.length - 1) return null
|
|
229
|
+
return { topic: decoded.slice(0, sep), name: decoded.slice(sep + 1), n }
|
|
230
|
+
} catch { return null }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function SessionActions(props) {
|
|
234
|
+
const { uiWorkspace } = props
|
|
235
|
+
const [curSid, setCurSid] = useState(null)
|
|
236
|
+
const [anchor, setAnchor] = useState(null) // {top, right} | null
|
|
237
|
+
const [open, setOpen] = useState(false)
|
|
238
|
+
const [list, setList] = useState(null)
|
|
239
|
+
const btnRef = React.useRef(null)
|
|
240
|
+
const popRef = React.useRef(null)
|
|
241
|
+
const [popPos, setPopPos] = useState(null)
|
|
242
|
+
|
|
243
|
+
// 轮询:当前会话 + 对话列头部锚点(跟随换位/拖拽/折叠/会话切换)
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
let alive = true
|
|
246
|
+
const tick = () => {
|
|
247
|
+
if (!alive) return
|
|
248
|
+
let next = null
|
|
249
|
+
let docked = null
|
|
250
|
+
try {
|
|
251
|
+
const snap = uiWorkspace.sessions?.list?.getSnapshot?.()
|
|
252
|
+
const cur = snap?.current ?? null
|
|
253
|
+
setCurSid((prev) => (prev === cur ? prev : cur))
|
|
254
|
+
const col = document.querySelector('[data-rightbar-col]')?.previousElementSibling
|
|
255
|
+
const conv = col?.querySelector('[data-phase]')
|
|
256
|
+
if (col && conv && typeof cur === 'string' && cur.startsWith('dpr-')) {
|
|
257
|
+
const r = col.getBoundingClientRect()
|
|
258
|
+
if (r.width > 200) {
|
|
259
|
+
// 贴头部右端:preset 徽标之右、系统控件(utilities/corner,哪个可见用哪个)之左;
|
|
260
|
+
// 给该控件加 margin-left 腾出 80px 空隙(flex 行内原生腾位,不压任何文字)
|
|
261
|
+
const vis = (el) => (el && el.getBoundingClientRect().width > 0 ? el : null)
|
|
262
|
+
const dock = vis(col.querySelector('[class*="headerUtilities"]')) || vis(col.querySelector('[class*="headerCorner"]'))
|
|
263
|
+
const leftEdge = dock ? dock.getBoundingClientRect().left : r.right - 12
|
|
264
|
+
if (dock && dock.style.marginLeft !== '80px') { dock.dataset.dprPad = '1'; dock.style.marginLeft = '80px' }
|
|
265
|
+
docked = dock
|
|
266
|
+
next = { top: Math.round(r.top + 10), right: Math.round(window.innerWidth - leftEdge + 6) }
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (!next) {
|
|
270
|
+
const padded = document.querySelector('[data-dpr-pad]')
|
|
271
|
+
if (padded) { delete padded.dataset.dprPad; padded.style.marginLeft = '' }
|
|
272
|
+
}
|
|
273
|
+
} catch { /* 目录未就绪 */ }
|
|
274
|
+
setAnchor((prev) => (prev && next && prev.top === next.top && prev.right === next.right) || (!prev && !next) ? prev : next)
|
|
275
|
+
}
|
|
276
|
+
tick()
|
|
277
|
+
const timer = setInterval(tick, 200)
|
|
278
|
+
return () => {
|
|
279
|
+
alive = false
|
|
280
|
+
clearInterval(timer)
|
|
281
|
+
const padded = document.querySelector('[data-dpr-pad]')
|
|
282
|
+
if (padded) { delete padded.dataset.dprPad; padded.style.marginLeft = '' }
|
|
283
|
+
}
|
|
284
|
+
}, [uiWorkspace])
|
|
285
|
+
|
|
286
|
+
// 阅读器当前文献(dpr.last 恢复 + 跟随 dpr:paper)。侧栏点论文只保证阅读器切换,
|
|
287
|
+
// 会话列切换是尽力而为(activateSession 轮询 6s 后静默放弃)——两者可能脱钩:
|
|
288
|
+
// 此时「历史/新建」若跟着会话列走,会在用户正看的另一篇论文上建会话(实测踩坑)。
|
|
289
|
+
// 这里以「正在看的文献」为准;脱钩时给 ⚠ 一键对齐入口(把会话列切回当前文献)。
|
|
290
|
+
const [viewed, setViewed] = useState(() => {
|
|
291
|
+
try { return JSON.parse(localStorage.getItem('dpr.last') || 'null') } catch { return null }
|
|
292
|
+
})
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
const on = (e) => setViewed({ topic: e.detail.topic, name: e.detail.name, n: e.detail.n })
|
|
295
|
+
window.addEventListener('dpr:paper', on)
|
|
296
|
+
return () => window.removeEventListener('dpr:paper', on)
|
|
297
|
+
}, [])
|
|
298
|
+
|
|
299
|
+
const chatPaper = parseDprSession(curSid)
|
|
300
|
+
const paper = viewed || chatPaper
|
|
301
|
+
const mismatch = !!(chatPaper && viewed && viewed.topic && (chatPaper.topic !== viewed.topic || chatPaper.name !== viewed.name))
|
|
302
|
+
const activeN = chatPaper?.n ?? paper?.n
|
|
303
|
+
|
|
304
|
+
// 下拉打开时拉该论文的会话列表;点外面关闭
|
|
305
|
+
useEffect(() => {
|
|
306
|
+
if (!open || !paper) return undefined
|
|
307
|
+
setList(null)
|
|
308
|
+
api(`/sessions?topic=${encodeURIComponent(paper.topic)}&name=${encodeURIComponent(paper.name)}`)
|
|
309
|
+
.then((d) => setList(d.sessions)).catch(() => setList([]))
|
|
310
|
+
const onDown = (e) => {
|
|
311
|
+
if (popRef.current?.contains(e.target) || btnRef.current?.contains(e.target)) return
|
|
312
|
+
setOpen(false)
|
|
313
|
+
}
|
|
314
|
+
document.addEventListener('mousedown', onDown)
|
|
315
|
+
return () => document.removeEventListener('mousedown', onDown)
|
|
316
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
317
|
+
}, [open, paper?.topic, paper?.name])
|
|
318
|
+
|
|
319
|
+
if (!paper || !anchor) return null
|
|
320
|
+
|
|
321
|
+
let titles = {}
|
|
322
|
+
try {
|
|
323
|
+
const snap = uiWorkspace.sessions?.list?.getSnapshot?.()
|
|
324
|
+
for (const s of list ?? []) {
|
|
325
|
+
const title = snap?.byId?.[s.sessionId]?.title
|
|
326
|
+
if (title) titles[s.sessionId] = title
|
|
327
|
+
}
|
|
328
|
+
} catch { /* 标题不可读时用 会话N */ }
|
|
329
|
+
|
|
330
|
+
const iconBtn = {
|
|
331
|
+
width: '28px', height: '28px', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '14px',
|
|
332
|
+
display: 'grid', placeItems: 'center',
|
|
333
|
+
color: 'var(--dsw-alias-label-tertiary, #656d76)',
|
|
334
|
+
background: 'var(--dsw-alias-bg-base, #fff)',
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return createPortal(
|
|
338
|
+
h('div', {
|
|
339
|
+
'data-dsh-plugin': 'dsh-paper-reader', 'data-dsh-surface': 'session-actions',
|
|
340
|
+
style: { position: 'fixed', top: anchor.top + 'px', right: anchor.right + 'px', display: 'flex', gap: '2px', zIndex: 45 },
|
|
341
|
+
},
|
|
342
|
+
mismatch && h('button', {
|
|
343
|
+
style: { ...iconBtn, color: 'var(--dsw-alias-state-warn, #d29922)' },
|
|
344
|
+
title: t('sync'),
|
|
345
|
+
onClick: () => window.dispatchEvent(new CustomEvent('dpr:enter', { detail: { topic: viewed.topic, name: viewed.name } })),
|
|
346
|
+
}, '⚠'),
|
|
347
|
+
h('button', {
|
|
348
|
+
ref: btnRef, style: iconBtn, title: t('history'),
|
|
349
|
+
onClick: () => {
|
|
350
|
+
if (!open && btnRef.current) {
|
|
351
|
+
const r = btnRef.current.getBoundingClientRect()
|
|
352
|
+
setPopPos({ top: Math.round(r.bottom + 6), right: Math.round(window.innerWidth - r.right) })
|
|
353
|
+
}
|
|
354
|
+
setOpen(!open)
|
|
355
|
+
},
|
|
356
|
+
}, '🕐'),
|
|
357
|
+
h('button', {
|
|
358
|
+
style: iconBtn, title: t('newChat'),
|
|
359
|
+
onClick: () => window.dispatchEvent(new CustomEvent('dpr:newchat', { detail: { topic: paper.topic, name: paper.name } })),
|
|
360
|
+
}, '+'),
|
|
361
|
+
open && createPortal(
|
|
362
|
+
h('div', {
|
|
363
|
+
ref: popRef,
|
|
364
|
+
'data-dsh-plugin': 'dsh-paper-reader', 'data-dsh-surface': 'session-history',
|
|
365
|
+
style: {
|
|
366
|
+
position: 'fixed', top: (popPos?.top ?? 40) + 'px', right: (popPos?.right ?? 20) + 'px',
|
|
367
|
+
minWidth: '220px', maxWidth: '320px', maxHeight: '50vh', overflowY: 'auto', zIndex: 90,
|
|
368
|
+
background: 'var(--dsw-alias-bg-base, #fff)', border: '.5px solid var(--dsw-alias-border-l3, #e2e5ea)',
|
|
369
|
+
borderRadius: '10px', boxShadow: '0 8px 30px rgba(0,0,0,.14)', padding: '4px',
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
list === null
|
|
373
|
+
? h('div', { style: { padding: '10px 12px', fontSize: '12px', color: 'var(--dsw-alias-label-tertiary, #656d76)' } }, '…')
|
|
374
|
+
: list.map((s) => {
|
|
375
|
+
const active = s.n === activeN
|
|
376
|
+
return h('div', {
|
|
377
|
+
key: s.sessionId,
|
|
378
|
+
onClick: () => {
|
|
379
|
+
setOpen(false)
|
|
380
|
+
if (!active) window.dispatchEvent(new CustomEvent('dpr:enter', { detail: { topic: paper.topic, name: paper.name, n: s.n } }))
|
|
381
|
+
},
|
|
382
|
+
style: {
|
|
383
|
+
padding: '7px 10px', borderRadius: '7px', cursor: 'pointer', fontSize: '13px',
|
|
384
|
+
color: active ? 'var(--dsw-alias-state-business-primary, #0969da)' : 'var(--dsw-alias-label-primary, #24292f)',
|
|
385
|
+
background: active ? 'var(--dsw-alias-bg-active, rgba(9,105,218,.10))' : 'none',
|
|
386
|
+
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
(s.running ? '● ' : '') + (titles[s.sessionId] || `${t('session')} ${s.n}`),
|
|
390
|
+
h('span', { style: { float: 'right', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary, #656d76)', marginLeft: '12px' } },
|
|
391
|
+
s.updatedAt ? new Date(s.updatedAt).toLocaleDateString(undefined, { month: 'numeric', day: 'numeric' }) : ''),
|
|
392
|
+
)
|
|
393
|
+
}),
|
|
394
|
+
),
|
|
395
|
+
document.body,
|
|
396
|
+
),
|
|
397
|
+
),
|
|
398
|
+
document.body,
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/* ---------- 侧栏文献库树(接管 sidebar.workspaces) ---------- */
|
|
403
|
+
const S = {
|
|
404
|
+
root: { padding: '4px 0 12px', fontSize: '13px', color: 'var(--dsw-alias-label-primary, #24292f)' },
|
|
405
|
+
head: { display: 'flex', alignItems: 'center', gap: '4px', padding: '8px 10px 6px', fontSize: '12px', fontWeight: 600, color: 'var(--dsw-alias-label-secondary, #656d76)' },
|
|
406
|
+
iconBtn: { cursor: 'pointer', border: 'none', background: 'none', color: 'inherit', padding: '0 4px', fontSize: '13px', opacity: .75 },
|
|
407
|
+
topicHead: { display: 'flex', alignItems: 'center', gap: '6px', padding: '5px 8px', margin: '1px 6px 0', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, userSelect: 'none' },
|
|
408
|
+
caret: { fontSize: '10px', color: 'var(--dsw-alias-label-secondary, #656d76)', transition: 'transform .12s', display: 'inline-block' },
|
|
409
|
+
items: { marginLeft: '14px' },
|
|
410
|
+
item: { display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 8px', margin: '0 6px', borderRadius: '6px', cursor: 'pointer', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
|
|
411
|
+
active: { background: 'var(--dsw-alias-bg-active, rgba(9,105,218,.12))', color: 'var(--dsw-alias-label-action-primary, #0969da)', fontWeight: 600 },
|
|
412
|
+
sessionItem: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary, #656d76)' },
|
|
413
|
+
empty: { padding: '10px 12px', fontSize: '12px', color: 'var(--dsw-alias-label-secondary, #656d76)', lineHeight: 1.6 },
|
|
414
|
+
running: { color: 'var(--dsw-alias-state-success-primary, #1a7f37)', fontSize: '9px' },
|
|
415
|
+
// 收起态(rail):官方 rail 的控件是 36×36 圆形按钮、左对齐
|
|
416
|
+
rail: { display: 'flex', flexDirection: 'column', alignItems: 'flex-start' },
|
|
417
|
+
railBtn: { width: '36px', height: '36px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', border: 'none', background: 'none', borderRadius: '50%', color: 'var(--dsw-alias-label-primary, #24292f)', cursor: 'pointer', fontSize: '17px', padding: '0' },
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function LibraryTree(props) {
|
|
421
|
+
const { layout, uiWorkspace, sidebarRight, wide, expandSidebar } = props
|
|
422
|
+
const [lib, setLib] = useState(null)
|
|
423
|
+
const [closed, setClosed] = useState(() => JSON.parse(localStorage.getItem('dpr.treeClosed') || '{}'))
|
|
424
|
+
const [sessions, setSessions] = useState({}) // `${topic}/${name}` → [{n, sessionId, running}]
|
|
425
|
+
const [cur, setCur] = useState(() => JSON.parse(localStorage.getItem('dpr.last') || 'null'))
|
|
426
|
+
const fileInput = React.useRef(null)
|
|
427
|
+
const uploadTopic = React.useRef('')
|
|
428
|
+
|
|
429
|
+
const refresh = useCallback(() => {
|
|
430
|
+
api('/library').then(setLib).catch(() => {})
|
|
431
|
+
}, [])
|
|
432
|
+
useEffect(() => {
|
|
433
|
+
refresh()
|
|
434
|
+
const timer = setInterval(refresh, 30000)
|
|
435
|
+
return () => clearInterval(timer)
|
|
436
|
+
}, [refresh])
|
|
437
|
+
|
|
438
|
+
const loadSessions = useCallback((topic, name) => {
|
|
439
|
+
api(`/sessions?topic=${encodeURIComponent(topic)}&name=${encodeURIComponent(name)}`)
|
|
440
|
+
.then((d) => setSessions((m) => ({ ...m, [`${topic}/${name}`]: d.sessions })))
|
|
441
|
+
.catch(() => {})
|
|
442
|
+
}, [])
|
|
443
|
+
|
|
444
|
+
// 打开 PDF 页签:等 rightbar seat 绑定到目标会话后只调一次 openTab(成功即停,
|
|
445
|
+
// 不再轮询——否则用户关页签会被后续 tick 重开,「关很多次才能关掉」)。
|
|
446
|
+
const openPdfTab = useCallback((sessionId) => {
|
|
447
|
+
let tries = 30
|
|
448
|
+
const tick = () => {
|
|
449
|
+
try {
|
|
450
|
+
if (sidebarRight.binding?.sessionId !== sessionId) throw new Error('seat-not-bound')
|
|
451
|
+
sidebarRight.openTab('dpr-reader', { revealIfOpened: true })
|
|
452
|
+
} catch {
|
|
453
|
+
// 快轮询:seat 重绑一完成就开页签,把"面板空白"窗口压到最短
|
|
454
|
+
if (--tries > 0) setTimeout(tick, 100)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
tick()
|
|
458
|
+
}, [sidebarRight])
|
|
459
|
+
|
|
460
|
+
// 激活会话:客户端会话目录(catalog)感知到新会话有时延,轮询到「已列出」再
|
|
461
|
+
// openSession,并以 current === sessionId 为成功标准(否则原生对话区不切换)。
|
|
462
|
+
const activateSession = useCallback((sessionId) => {
|
|
463
|
+
let tries = 20
|
|
464
|
+
const tick = () => {
|
|
465
|
+
try {
|
|
466
|
+
const snap = uiWorkspace.sessions?.list?.getSnapshot?.()
|
|
467
|
+
if (snap && snap.current === sessionId) return // 已是当前会话
|
|
468
|
+
const listed = !!(snap && (snap.byId?.[sessionId] || snap.ids?.includes(sessionId)))
|
|
469
|
+
if (listed) uiWorkspace.openSession(sessionId)
|
|
470
|
+
if (--tries > 0) setTimeout(tick, 300)
|
|
471
|
+
} catch {
|
|
472
|
+
if (--tries > 0) setTimeout(tick, 300)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
tick()
|
|
476
|
+
}, [uiWorkspace])
|
|
477
|
+
|
|
478
|
+
// 预开 PDF 页签到目标会话的 rightbar(在切换前):先等客户端目录收录该会话
|
|
479
|
+
// (否则 openTabIn 对未收编 store 静默 no-op),收录后 openTabIn 成功再切会话。
|
|
480
|
+
// 不做这步的话,新会话的 rightbar 初始为空 → 列折叠 → openTab 再撑开
|
|
481
|
+
// = PDF 消失又出现(实测逐帧确认)。
|
|
482
|
+
const preOpenPdfTab = useCallback((sessionId, done) => {
|
|
483
|
+
let tries = 30
|
|
484
|
+
const tick = () => {
|
|
485
|
+
try {
|
|
486
|
+
const snap = uiWorkspace.sessions?.list?.getSnapshot?.()
|
|
487
|
+
const listed = !!(snap && (snap.byId?.[sessionId] || snap.ids?.includes(sessionId)))
|
|
488
|
+
if (listed) {
|
|
489
|
+
// 对目录里早已存在的会话这步能真正预开(store 已 adopt);
|
|
490
|
+
// 对刚创建的会话恒 no-op —— 靠切换护栏兜底(见 beginSwitchGuard)。
|
|
491
|
+
sidebarRight.openTabIn(sessionId, 'dpr-reader', { revealIfOpened: true })
|
|
492
|
+
done?.()
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
} catch { /* 目录未就绪 */ }
|
|
496
|
+
if (--tries > 0) setTimeout(tick, 150)
|
|
497
|
+
else done?.() // 兜底:超时也照常切(护栏 + openPdfTab 兜底路径)
|
|
498
|
+
}
|
|
499
|
+
tick()
|
|
500
|
+
}, [sidebarRight, uiWorkspace])
|
|
501
|
+
|
|
502
|
+
// 激活某个具体会话(原生对话区 + PDF 页签 + 阅读器同步)。纯函数式操作,不碰列表状态。
|
|
503
|
+
const activate = useCallback((topic, name, target) => {
|
|
504
|
+
// 离开的若是空白草稿(点了新建又没问任何问题)→ 后台归档,不留在会话列表里
|
|
505
|
+
try {
|
|
506
|
+
const snap = uiWorkspace.sessions?.list?.getSnapshot?.()
|
|
507
|
+
const leaving = snap?.current
|
|
508
|
+
if (leaving && typeof leaving === 'string' && leaving.startsWith('dpr-') && leaving !== target.sessionId
|
|
509
|
+
&& snap?.byId?.[leaving]?.blank && !snap.byId[leaving].running) {
|
|
510
|
+
api('/sessions/archive', {
|
|
511
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
512
|
+
body: JSON.stringify({ sessionId: leaving, topic, name }),
|
|
513
|
+
}).then(() => loadSessions(topic, name)).catch(() => {})
|
|
514
|
+
}
|
|
515
|
+
} catch { /* 目录未就绪就不归档(host 侧对账会兜底) */ }
|
|
516
|
+
setCur({ topic, name, n: target.n })
|
|
517
|
+
setCurrentPaper(topic, name, target.n)
|
|
518
|
+
// 切换护栏:冻结右栏轨道/阅读器几何/面板过渡,只有对话区换内容(见 beginSwitchGuard)
|
|
519
|
+
beginSwitchGuard()
|
|
520
|
+
preOpenPdfTab(target.sessionId, () => activateSession(target.sessionId))
|
|
521
|
+
openPdfTab(target.sessionId) // 兜底:seat 重绑后立即开页签(幂等 reveal)
|
|
522
|
+
}, [activateSession, loadSessions, openPdfTab, preOpenPdfTab, uiWorkspace])
|
|
523
|
+
|
|
524
|
+
// 进入伴读:永远拉最新会话列表(缓存的 sessions 可能过期)。
|
|
525
|
+
// 只在「未指定会话且一个都没有」时才创建会话1;指定了 n 就绝不擅自新建(避免重复创建)。
|
|
526
|
+
const enter = useCallback(async (topic, name, n) => {
|
|
527
|
+
let list = []
|
|
528
|
+
try {
|
|
529
|
+
const d = await api(`/sessions?topic=${encodeURIComponent(topic)}&name=${encodeURIComponent(name)}`)
|
|
530
|
+
list = d.sessions
|
|
531
|
+
setSessions((m) => ({ ...m, [`${topic}/${name}`]: d.sessions }))
|
|
532
|
+
} catch { /* 列表拉取失败按空处理 */ }
|
|
533
|
+
let target = n ? list.find((s) => s.n === n) : list[list.length - 1]
|
|
534
|
+
if (!target) {
|
|
535
|
+
if (n) return // 指定的会话不存在,不做
|
|
536
|
+
target = await api('/sessions/new', {
|
|
537
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
538
|
+
body: JSON.stringify({ topic, name }),
|
|
539
|
+
})
|
|
540
|
+
setSessions((m) => ({ ...m, [`${topic}/${name}`]: [...(m[`${topic}/${name}`] || []), target] }))
|
|
541
|
+
}
|
|
542
|
+
activate(topic, name, target)
|
|
543
|
+
}, [activate])
|
|
544
|
+
|
|
545
|
+
// 新建对话:host 建好后直接激活它(不进 enter 的查找路径,杜绝二次创建)
|
|
546
|
+
const newChat = useCallback(async (topic, name) => {
|
|
547
|
+
const created = await api('/sessions/new', {
|
|
548
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
549
|
+
body: JSON.stringify({ topic, name }),
|
|
550
|
+
})
|
|
551
|
+
const key = `${topic}/${name}`
|
|
552
|
+
setSessions((m) => ({ ...m, [key]: [...(m[key] || []), created] }))
|
|
553
|
+
activate(topic, name, created)
|
|
554
|
+
loadSessions(topic, name) // 后台对账:让 host 的空白过滤把被顶替的旧空白会话收掉
|
|
555
|
+
}, [activate, loadSessions])
|
|
556
|
+
|
|
557
|
+
const newTopic = useCallback(async () => {
|
|
558
|
+
const name = window.prompt(t('newTopic') + ':')
|
|
559
|
+
if (!name?.trim()) return
|
|
560
|
+
await api('/library/topic', {
|
|
561
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
562
|
+
body: JSON.stringify({ name: name.trim() }),
|
|
563
|
+
})
|
|
564
|
+
refresh()
|
|
565
|
+
}, [refresh])
|
|
566
|
+
|
|
567
|
+
const upload = useCallback((topic) => {
|
|
568
|
+
uploadTopic.current = topic
|
|
569
|
+
fileInput.current?.click()
|
|
570
|
+
}, [])
|
|
571
|
+
|
|
572
|
+
const onFile = useCallback(async (e) => {
|
|
573
|
+
const file = e.target.files?.[0]
|
|
574
|
+
e.target.value = ''
|
|
575
|
+
if (!file) return
|
|
576
|
+
await fetch('/paper-reader/api/library/upload', {
|
|
577
|
+
method: 'POST',
|
|
578
|
+
// header 值只允许 ISO-8859-1:中文专题名/文件名必须先编码,否则 fetch 在发请求前就抛错
|
|
579
|
+
headers: { 'x-dpr-topic': encodeURIComponent(uploadTopic.current), 'x-dpr-name': encodeURIComponent(file.name), 'content-type': 'application/pdf' },
|
|
580
|
+
body: file,
|
|
581
|
+
}).then(async (r) => {
|
|
582
|
+
if (!r.ok) {
|
|
583
|
+
const body = await r.json().catch(() => ({}))
|
|
584
|
+
throw new Error(body.error || body.reason || ('HTTP ' + r.status))
|
|
585
|
+
}
|
|
586
|
+
}).catch((err) => window.alert('上传失败: ' + err.message))
|
|
587
|
+
refresh()
|
|
588
|
+
}, [refresh])
|
|
589
|
+
|
|
590
|
+
const topics = lib?.topics ?? []
|
|
591
|
+
const papers = lib?.papers ?? []
|
|
592
|
+
|
|
593
|
+
// 对话头部按钮 → 树的既有动作(enter/newChat 闭包在这里)
|
|
594
|
+
useEffect(() => {
|
|
595
|
+
const onEnter = (e) => enter(e.detail.topic, e.detail.name, e.detail.n)
|
|
596
|
+
const onNew = (e) => newChat(e.detail.topic, e.detail.name)
|
|
597
|
+
window.addEventListener('dpr:enter', onEnter)
|
|
598
|
+
window.addEventListener('dpr:newchat', onNew)
|
|
599
|
+
return () => {
|
|
600
|
+
window.removeEventListener('dpr:enter', onEnter)
|
|
601
|
+
window.removeEventListener('dpr:newchat', onNew)
|
|
602
|
+
}
|
|
603
|
+
}, [enter, newChat])
|
|
604
|
+
|
|
605
|
+
// 收起态(rail):官方契约 —— sidebar.workspaces 在窄栏里只该给一个图标入口,点击请求
|
|
606
|
+
// 宿主展开。整棵树不渲染:塞进 56px 会被挤成竖排文字(实测)。
|
|
607
|
+
// 用 wide === false 而非 !wide:宿主万一没传这个 prop 时按展开态走,即改动前的行为。
|
|
608
|
+
const tree = wide === false
|
|
609
|
+
? h('div', { style: S.rail },
|
|
610
|
+
h('button', {
|
|
611
|
+
type: 'button', style: S.railBtn, 'data-dpr-rail': '', title: t('library'),
|
|
612
|
+
'aria-label': t('library'), onClick: () => expandSidebar && expandSidebar(),
|
|
613
|
+
}, '📚'))
|
|
614
|
+
: h(React.Fragment, null,
|
|
615
|
+
h('div', { style: S.head },
|
|
616
|
+
h('span', { style: { flex: 1 } }, t('library')),
|
|
617
|
+
h('button', { style: S.iconBtn, title: t('newTopic'), onClick: newTopic }, '+'),
|
|
618
|
+
h('button', { style: S.iconBtn, title: 'refresh', onClick: refresh }, '⟳'),
|
|
619
|
+
),
|
|
620
|
+
lib && topics.length === 0 && h('div', { style: S.empty }, `${t('empty')}: ${t('hint')}`),
|
|
621
|
+
topics.map((topic) => {
|
|
622
|
+
const isClosed = !!closed[topic]
|
|
623
|
+
return h('div', { key: topic },
|
|
624
|
+
h('div', {
|
|
625
|
+
style: S.topicHead,
|
|
626
|
+
onClick: () => {
|
|
627
|
+
const next = { ...closed, [topic]: !isClosed }
|
|
628
|
+
setClosed(next)
|
|
629
|
+
localStorage.setItem('dpr.treeClosed', JSON.stringify(next))
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
h('span', { style: { ...S.caret, transform: isClosed ? 'rotate(-90deg)' : 'none' } }, '▾'),
|
|
633
|
+
h('span', { style: { flex: 1 } }, topic),
|
|
634
|
+
h('button', {
|
|
635
|
+
style: S.iconBtn, title: t('upload'),
|
|
636
|
+
onClick: (e) => { e.stopPropagation(); upload(topic) },
|
|
637
|
+
}, '⬆'),
|
|
638
|
+
),
|
|
639
|
+
!isClosed && h('div', { style: S.items },
|
|
640
|
+
papers.filter((p) => p.topic === topic).map((p) => {
|
|
641
|
+
const isCurrentPaper = cur && cur.topic === p.topic && cur.name === p.name
|
|
642
|
+
return h('div', {
|
|
643
|
+
key: p.name,
|
|
644
|
+
style: { ...S.item, ...(isCurrentPaper ? S.active : {}) },
|
|
645
|
+
title: p.name,
|
|
646
|
+
onClick: () => enter(p.topic, p.name),
|
|
647
|
+
}, '📄 ', h('span', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' } }, p.name))
|
|
648
|
+
}),
|
|
649
|
+
),
|
|
650
|
+
)
|
|
651
|
+
}),
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
return h('div', { style: S.root, 'data-dsh-plugin': 'dsh-paper-reader', 'data-dsh-surface': 'sidebar' },
|
|
655
|
+
// 常驻阅读器 iframe(覆盖在 PDF 页签的锚点上;不随会话重建)
|
|
656
|
+
h(ReaderOverlay, null),
|
|
657
|
+
// 对话列头部的历史/新建浮动按钮组(自渲染,不占树)
|
|
658
|
+
h(SessionActions, { uiWorkspace }),
|
|
659
|
+
h('input', { ref: fileInput, type: 'file', accept: '.pdf', style: { display: 'none' }, onChange: onFile }),
|
|
660
|
+
// 阅读器和浮动按钮都不属于侧栏,收起侧栏时照常挂载;只换掉树本身
|
|
661
|
+
tree,
|
|
662
|
+
)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return {
|
|
666
|
+
inject: ['slots', 'locale', 'layout', 'uiWorkspace', 'sidebarRight', 'sidebarRightTabs'],
|
|
667
|
+
apply(ctx) {
|
|
668
|
+
try {
|
|
669
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-paper-reader: dicts')
|
|
670
|
+
const bound = ctx.locale.bind(NS)
|
|
671
|
+
setT(bound)
|
|
672
|
+
|
|
673
|
+
// 切换护栏要挂到共享的 LayoutController 上(所有插件拿到的是同一实例)
|
|
674
|
+
layoutService = ctx.layout
|
|
675
|
+
// 官方 data-rightbar-instant 只瞬时化 grid 轨道和拖拽手柄;
|
|
676
|
+
// 右栏面板自身是 transform 滑动过渡,补一条规则让它在切换窗口内也瞬时
|
|
677
|
+
if (!document.getElementById('dpr-switch-instant')) {
|
|
678
|
+
const styleEl = document.createElement('style')
|
|
679
|
+
styleEl.id = 'dpr-switch-instant'
|
|
680
|
+
styleEl.textContent = '[data-rightbar-instant] [data-sidebar-right-panel]{transition:none!important}'
|
|
681
|
+
document.head.appendChild(styleEl)
|
|
682
|
+
}
|
|
683
|
+
// 自绘元素里 :hover 这类伪类内联 style 表达不了。内联仍写死态(注入失败时有个安全默认),
|
|
684
|
+
// 这里用 !important 盖过内联给出 hover。
|
|
685
|
+
if (!document.getElementById('dpr-surface-css')) {
|
|
686
|
+
const styleEl = document.createElement('style')
|
|
687
|
+
styleEl.id = 'dpr-surface-css'
|
|
688
|
+
styleEl.textContent = '[data-dpr-rail]:hover{background:var(--dsw-alias-interactive-bg-hover)!important}'
|
|
689
|
+
document.head.appendChild(styleEl)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// rightbar PDF 页签类型 + 内容
|
|
693
|
+
ctx.effect(() => ctx.sidebarRightTabs.register({
|
|
694
|
+
id: 'dpr-reader', kind: 'dpr-reader', title: () => bound('pdfTab'),
|
|
695
|
+
}), 'dsh-paper-reader: tab type')
|
|
696
|
+
ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
|
|
697
|
+
name: 'sidebar.right.pane.tab', key: 'dpr-reader', locale: NS,
|
|
698
|
+
}, PdfTab))
|
|
699
|
+
|
|
700
|
+
// (对话头部的历史/新建按钮不走 slot —— session 作用域 slot 不渲染动态包条目;
|
|
701
|
+
// SessionActions 由 LibraryTree 挂载,见下)
|
|
702
|
+
|
|
703
|
+
// 侧栏中部:专题→论文→会话 树。
|
|
704
|
+
// 槽传 { wide, expandSidebar }:收起成 rail 时 wide=false,注册方必须自己给一个
|
|
705
|
+
// 图标入口(官方 WorkspaceBrowser 同款契约)——否则整棵树被塞进 56px 挤成竖排文字。
|
|
706
|
+
ctx.slots.inject('sidebar.workspaces', () => ctx.slots.register({
|
|
707
|
+
name: 'sidebar.workspaces', priority: -1000, locale: NS,
|
|
708
|
+
}, function LibrarySection(slot) {
|
|
709
|
+
return h(LibraryTree, {
|
|
710
|
+
layout: ctx.layout, uiWorkspace: ctx.uiWorkspace, sidebarRight: ctx.sidebarRight,
|
|
711
|
+
wide: slot?.wide, expandSidebar: slot?.expandSidebar,
|
|
712
|
+
})
|
|
713
|
+
}))
|
|
714
|
+
} catch (e) {
|
|
715
|
+
console.error('[dsh-paper-reader] client apply failed:', e)
|
|
716
|
+
}
|
|
717
|
+
},
|
|
718
|
+
}
|
|
719
|
+
},
|
|
720
|
+
})
|