dsh-baleen 0.1.1 → 0.2.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.
@@ -14,7 +14,7 @@
14
14
  }
15
15
 
16
16
  let clientCtx = null
17
- const inject = ['slots', 'layout']
17
+ const inject = ['slots', 'layout', 'betterSidebar']
18
18
 
19
19
  /** 只有声明过的服务读得到;这条 helper 存在的意义就是不让人手写裸属性回退 */
20
20
  function safeGet(name) {
@@ -31,6 +31,45 @@
31
31
  try { return (clientCtx && clientCtx.layout) || null } catch { return null }
32
32
  }
33
33
 
34
+ /**
35
+ * 侧栏播放(M7)只由 better-sidebar 的 urlTarget 认领触发:用户点会话里的视频链接时
36
+ * 必然有会话在前台,那是唯一满足原生挂载条件的路径。面板里不放「侧栏播放」按钮——
37
+ * 实测面板在前台时 openTab 只进 pending 队列(见上面的机制注释),点了什么都不出现,
38
+ * 和 2026-10-31 撤掉的那个死按钮是同一个坑。想看就用抽屉里的内嵌播放器。
39
+ */
40
+ let takeoverOn = true // 偏好读回来之前按默认值,与 DEFAULTS.sidebar_takeover 一致
41
+ let cropOn = true
42
+
43
+ /**
44
+ * 侧栏播放能力(抽屉「侧栏播放」按钮的落点)。available 与 open 都在调用时现取服务:
45
+ * better-sidebar 可能被禁用或根本没装,取不到就如实返回 false(按钮压根不画、点了明说
46
+ * 没接住),面板其余功能不牵连。手动 open 与认领(urlTarget)互不挂钩——关掉
47
+ * 「侧栏接管链接」只是不抢会话里的链接,按钮照样能用。
48
+ */
49
+ const sidebarTab = {
50
+ get available() {
51
+ const bs = safeGet('betterSidebar')
52
+ return Boolean(bs && typeof bs.openTab === 'function')
53
+ },
54
+ open(url, title) {
55
+ const bs = safeGet('betterSidebar')
56
+ if (!bs || typeof bs.openTab !== 'function' || !parseVideoUrl(url)) return false
57
+ try { bs.openTab(videoTabSeed(url, title)); return true }
58
+ catch (e) {
59
+ console.error('[dsh-baleen] 侧栏没接住这条:' + ((e && e.message) || e))
60
+ return false
61
+ }
62
+ }
63
+ }
64
+
65
+ /** 打开原链接只剩一条路:宿主侧 /open 路由。2026-10-31 真机验收实证,本宿主把
66
+ * 客户端页面的弹窗与外链全部静默拦下(webview 客体的 window-open 处理),window.open
67
+ * 与 <a target=_blank> 都到不了系统浏览器。
68
+ * M7 补了机制(docs/verification.md 的探针):dsh-better-sidebar 的原生挂载按
69
+ * `mountedSessionId(ctx) === sessionId` 决定放不放行,会话不在前台时 open 是**进队列
70
+ * 等着**,`openTab` 返回 void、调用方拿不到失败信号——所以"面板在前台开侧栏"这条
71
+ * 不是被拒绝,是看起来什么都没发生。 */
72
+
34
73
  /** 导航项 ↔ 渲染器。VIEW_RENDER 的键必须与 view-state 的 VIEWS 一字不差(测试守着)。
35
74
  * 六个值全是视图本身:收藏页要多带的夹列表、草稿与二次确认态由 Panel 在调用时并进 props
36
75
  * (那些 state 只存在于组件里,包在 VIEW_RENDER 里反而取不到)。 */
@@ -56,6 +95,28 @@
56
95
  /** 取数的请求序号(终审 C14):只在工厂作用域,Panel 每帧重跑不能把它清零。 */
57
96
  let refreshSeq = 0
58
97
 
98
+ /** 抽屉滚动位置的记忆:宿主侧的重渲染会波及面板 DOM,把 .bl-dmain 的 scrollTop
99
+ * 钳回 0(2026-10-31 真机实证:同节点、scrollHeight 不变、scrollTop 归零,且面板
100
+ * 自己没有定时器——唯一执行源就是重渲染)。对策是渲染期捕捉提交前的位置、提交后
101
+ * 回填,重渲染从哪来都免疫。两个纯函数只算"记不记、回不回",DOM 读写挂线在
102
+ * Panel 里,Node 测试不碰 document。 */
103
+ let drawerScrollMemo = { id: null, top: 0, max: 0 }
104
+
105
+ /** 记忆更新:换条目从零记;同条目只记"自洽"的滚动(top 在 [0, 可滚上限] 内)——
106
+ * 内容塌陷瞬间的钳位读数(top=0 而 max 为负)不许覆盖旧值,否则记忆会被弄丢
107
+ * 它的那一下自己抹掉。 */
108
+ function scrollMemoNext(memo, id, top, max) {
109
+ if (id !== memo.id) return { id, top: 0, max: 0 }
110
+ if (top < 0 || top > max) return memo
111
+ return { id, top, max }
112
+ }
113
+
114
+ /** 回填判定:同一条目、记忆停得比现在深、且那个位置在当前布局下仍达得到,才回填。 */
115
+ function scrollRestoreTarget(memo, id, top, max) {
116
+ if (memo.id !== id || memo.top <= top || memo.top > max) return null
117
+ return memo.top
118
+ }
119
+
59
120
  function Panel() {
60
121
  // api 与首屏状态都得只建一次:useState 的惰性初始化是唯一入口(useMemo 在桩里等价于直接调)
61
122
  const [api] = React.useState(() => createApi({ fetch: globalThis.fetch, storage: globalThis.localStorage }))
@@ -73,6 +134,9 @@
73
134
  // 第二次会拿到 name_taken,回显对着刚建好的夹说"已经有这个夹了"。
74
135
  const [folderBusy, setFolderBusy] = React.useState(false)
75
136
  const [knownFolders, setKnownFolders] = React.useState(null)
137
+ // 「抓取正文」的在途条目(照抄 collecting 的规矩:没有它,连点会叠请求,
138
+ // 也说不清"正在抓的到底是哪一条")。null = 没在抓。
139
+ const [fetchingId, setFetchingId] = React.useState(null)
76
140
  // 收藏页的瞬时态(一律不进 reducer:刷新就没的输入草稿 + 一次二次确认 + 一行回显)。
77
141
  // renameId 记着"这份草稿是改哪个夹的",换夹就丢掉,不许把上一个夹的半截名字带过去。
78
142
  const [pendingDelete, setPendingDelete] = React.useState(null)
@@ -113,6 +177,16 @@
113
177
  })
114
178
  }
115
179
  React.useEffect(() => { refresh() }, [snap.view, snap.filters.collections?.folder])
180
+ // 抽屉滚动回填(无依赖:每帧提交后都跑)。回调引用了下方才声明的 drawerOpen /
181
+ // snap——执行时它们已初始化。测试桩会执行 effect 且 document 是没有 querySelector
182
+ // 的假货,守卫要查到函数级。
183
+ React.useEffect(() => {
184
+ if (!drawerOpen || typeof document === 'undefined' || typeof document.querySelector !== 'function') return
185
+ const dmEl = document.querySelector('.bl-dmain')
186
+ if (!dmEl) return
187
+ const target = scrollRestoreTarget(drawerScrollMemo, snap.drawerItemId, dmEl.scrollTop, dmEl.scrollHeight - dmEl.clientHeight)
188
+ if (target != null) dmEl.scrollTop = target
189
+ })
116
190
 
117
191
  function onRun() {
118
192
  setBusy(true)
@@ -191,7 +265,12 @@
191
265
  onFacet: dispatch,
192
266
  onSelect: (id) => dispatch({ type: 'select', id }),
193
267
  onOpen: (id) => dispatch({ type: 'open-drawer', id }),
194
- onPickView: pickView
268
+ onPickView: pickView,
269
+ // 「抓取正文」只归文章区认领。忙态按"正在抓的是不是右栏这一条"算——
270
+ // 拿 fetchingId 一刀切会让右栏换了条之后,忙态贴在一条没在抓的正文上。
271
+ onFulltext: runFulltext,
272
+ fetching: view === 'article' && fetchingId != null &&
273
+ currentArticle(data.items, snap.filters.article || {}, snap.selectedId)?.id === fetchingId
195
274
  }
196
275
  const body = render(view === 'collections' ? { ...baseProps, ...collectionsProps } : baseProps)
197
276
  const drawerOpen = snap.drawerItemId != null
@@ -200,6 +279,15 @@
200
279
  // 所以收藏成功后要把服务端读回的归属覆盖上去——只覆盖抽屉这一条的展示,不改 data.items。
201
280
  // 覆盖规则抽成纯函数 drawerItemWithFolders(views/drawer.js),连点不互抹的不变量由测试钉住。
202
281
  const drawerItem = drawerItemWithFolders(found, knownFolders)
282
+ // 渲染期捕捉(提交前):此刻的 scrollTop 还是上一帧的,正是上游重渲染可能弄丢的
283
+ // 那个值。抽屉刚开时 memo.id 不匹配,从零起记。测试桩的 document 是没有
284
+ // querySelector 的假货,守卫要查到函数级。
285
+ if (drawerOpen && typeof document !== 'undefined' && typeof document.querySelector === 'function') {
286
+ const dmEl = document.querySelector('.bl-dmain')
287
+ if (dmEl) {
288
+ drawerScrollMemo = scrollMemoNext(drawerScrollMemo, snap.drawerItemId, dmEl.scrollTop, dmEl.scrollHeight - dmEl.clientHeight)
289
+ }
290
+ }
203
291
  // 包一层 collecting:点一下即生效意味着连点会叠请求,全量替换下后到的旧集合会盖掉新决定。
204
292
  function runCollect(...args) {
205
293
  setCollecting(true)
@@ -208,6 +296,40 @@
208
296
  (e) => { setCollecting(false); throw e }
209
297
  )
210
298
  }
299
+ /**
300
+ * 「抓取正文」:面板只报条目 id,抓哪条、抓成什么样由宿主定(/fulltext)。
301
+ * 成败都要收口:成功就重读当前视图,让右栏换成新正文;失败给一句人话并指回原文——
302
+ * 静默失败会让人以为"点了没反应",而这颗按钮恰恰是摘要条目唯一的出路。
303
+ * 在途标记按"是不是这一条"清,不吃掉别人后来点的那一次。
304
+ */
305
+ function runFulltext(itemId) {
306
+ if (!Number.isInteger(itemId)) return Promise.resolve()
307
+ setFetchingId(itemId)
308
+ return api.fulltext(itemId).then(
309
+ (r) => {
310
+ setFetchingId((cur) => (cur === itemId ? null : cur))
311
+ if (!r.ok) {
312
+ setFeedbackMsg('这次没抓到正文,还是只有摘要——原文链接在上面')
313
+ return undefined
314
+ }
315
+ return refresh()
316
+ },
317
+ (e) => {
318
+ setFetchingId((cur) => (cur === itemId ? null : cur))
319
+ setFeedbackMsg(`这次没抓到正文,还是只有摘要——原文链接在上面(${(e && e.message) || '宿主接口没应答'})`)
320
+ return undefined
321
+ }
322
+ )
323
+ }
324
+ // 「浏览器打开」:成功不需要说什么——浏览器自己会弹到前头;失败要说明白,
325
+ // 顺手给出路(复制地址)。清掉旧回显免得上一句收藏反馈还挂在上面。
326
+ function runOpenExternal(itemId) {
327
+ if (!Number.isInteger(itemId)) return Promise.resolve()
328
+ return api.openItem(itemId).then(
329
+ () => setFeedbackMsg(''),
330
+ () => setFeedbackMsg('宿主没能拉起系统浏览器,直接复制上面的地址吧。')
331
+ )
332
+ }
211
333
  return h('div', { className: 'bl-root' }, [
212
334
  // 外壳三件(Nav / TopBar / NotReady)与视图同构,都产纯对象,所以一律经 toElement,
213
335
  // 不能 h(Nav):函数组件返回普通对象的话,真 React 会直接抛"Objects are not valid as a React child"。
@@ -223,6 +345,8 @@
223
345
  collecting,
224
346
  newFolderName,
225
347
  onNewFolderName: setNewFolderName,
348
+ onOpenUrl: (url, itemId) => runOpenExternal(itemId),
349
+ sidebar: sidebarTab,
226
350
  onClose: () => { setFeedbackMsg(''); setNewFolderName(''); dispatch({ type: 'close-drawer' }) },
227
351
  onDone: (r) => {
228
352
  setFeedbackMsg(r.message)
@@ -269,6 +393,10 @@
269
393
  // 凭据页签
270
394
  const [cookieDraft, setCookieDraft] = React.useState('')
271
395
  const [diagText, setDiagText] = React.useState(null)
396
+ // 小红书(M6):登录状态与扫码二维码。都是手动触发(探活会按需拉起 MCP 子进程,
397
+ // 挂载即打会让每次开设置页都 spawn 一个浏览器栈)。
398
+ const [xhsInfo, setXhsInfo] = React.useState(null)
399
+ const [xhsQr, setXhsQr] = React.useState(null)
272
400
  // 库页签
273
401
  const [cleanupDays, setCleanupDays] = React.useState('')
274
402
  const [askCleanup, setAskCleanup] = React.useState(false)
@@ -358,6 +486,28 @@
358
486
  return undefined
359
487
  })
360
488
  }
489
+ /** 小红书(M6):登录态探活与扫码。成功失败都落在 xhsInfo/回显里,不静默。 */
490
+ function onXhsStatus() {
491
+ setBusy(true)
492
+ api.xhsStatus().then((r) => {
493
+ setBusy(false)
494
+ if (r.ok && r.data) setXhsInfo(r.data)
495
+ else setNote(`小红书状态没读到(${r.error || '宿主接口没应答'})`)
496
+ })
497
+ }
498
+ function onXhsQr() {
499
+ setBusy(true)
500
+ api.xhsLoginQr().then((r) => {
501
+ setBusy(false)
502
+ if (r.ok && r.data && r.data.image) {
503
+ setXhsQr(r.data.image)
504
+ setXhsInfo({ configured: true, loggedIn: false, text: r.data.text })
505
+ setNote('二维码两分钟内有效——扫完点「刷新登录状态」')
506
+ return
507
+ }
508
+ setNote(`二维码没拿到(${r.error || '宿主接口没应答'})`)
509
+ })
510
+ }
361
511
  /** 复制诊断:tail 已在服务端逐行脱敏;剪贴板拿不到就降级成文本块让人手动复制。 */
362
512
  function onCopyDiag() {
363
513
  api.diagTail(50).then((r) => {
@@ -423,6 +573,7 @@
423
573
  onSave: putSection,
424
574
  askResetTuning, onAskResetTuning: () => setAskResetTuning(true), onCancelResetTuning: () => setAskResetTuning(false), onResetTuning,
425
575
  cred, env, cookieDraft, onCookieDraft: setCookieDraft, onCookieSubmit, diagText, onCopyDiag,
576
+ xhs: xhsInfo, xhsQr, onXhsQr, onXhsStatus,
426
577
  home: hostState?.home ?? '<HOME>',
427
578
  cleanupDays, onCleanupDays: setCleanupDays, askCleanup, onAskCleanup: () => setAskCleanup(true), onCancelCleanup: () => setAskCleanup(false), onCleanup,
428
579
  wipeText, onWipeText: setWipeText, onWipe, onExport
@@ -436,7 +587,7 @@
436
587
  const slots = safeGet('slots')
437
588
  if (!slots) {
438
589
  console.error('[dsh-baleen] 拿不到 slots 服务,界面不注册')
439
- return { registeredSlots: [] }
590
+ return { registeredSlots: [], sidebarTab }
440
591
  }
441
592
  const registered = []
442
593
 
@@ -474,7 +625,23 @@
474
625
  () => h(SettingsView, { key: 'settings' }),
475
626
  'settings.section')
476
627
 
477
- return { registeredSlots: registered }
628
+ // 侧栏播放 tab(M7):只在拿得到服务时注册。取不到就整段跳过,面板其余照旧。
629
+ const betterSidebar = safeGet('betterSidebar')
630
+ if (betterSidebar && typeof betterSidebar.registerTab === 'function') {
631
+ ctx.effect(() => registerSidebarTab(betterSidebar, buildTabDescriptor({
632
+ flags: { takeover: () => takeoverOn },
633
+ render: ({ url, title }) => toElement(PlayerTab({ url, title, crop: cropOn }))
634
+ })))
635
+ // urlTarget 是同步谓词,所以偏好只能读一次缓存进布尔;读不到就保持默认开
636
+ const prefApi = createApi({ fetch: globalThis.fetch, storage: globalThis.localStorage })
637
+ prefApi.preferenceGet().then((r) => {
638
+ const p = r && r.data && r.data.pref // plainGet 的形状是 {ok, stale, data},pref 在 data 里
639
+ if (typeof p?.sidebar_takeover === 'boolean') takeoverOn = p.sidebar_takeover
640
+ if (typeof p?.sidebar_crop === 'boolean') cropOn = p.sidebar_crop
641
+ }).catch(() => {})
642
+ }
643
+
644
+ return { registeredSlots: registered, sidebarTab }
478
645
  }
479
646
 
480
647
  exports.apply = apply
@@ -482,6 +649,8 @@
482
649
  exports.__test = {
483
650
  PANEL_ID, VIEW_KEY, API_BASE, SNAPSHOT_KEY, VIEWS, VIEW_RENDER, viewQuery, NAV_ITEMS,
484
651
  Nav, NotReady, TopBar, Panel, toElement, createApi, getLayout, safeGet,
652
+ scrollMemoNext, scrollRestoreTarget,
653
+ scrollMemoNext, scrollRestoreTarget,
485
654
  reduce, encodeState, decodeState, TodayView, Drawer, CollectionsView,
486
655
  SettingsView, SettingsPage, parseThemes, serializeThemes, splitThemes
487
656
  }
package/src/client/css.js CHANGED
@@ -24,9 +24,11 @@ export const CSS = `
24
24
  .bl-issues{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--dsw-alias-state-error-primary)}
25
25
  .bl-pending{font-size:12px;color:var(--dsw-alias-label-secondary)}
26
26
  .bl-actions{display:flex;gap:8px;margin:10px 0 2px}
27
- .bl-actions button,.bl-detail{appearance:none;font-family:inherit;font-size:12px;min-height:32px;padding:5px 11px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer}
28
- .bl-actions button:hover,.bl-detail:hover{background:var(--dsw-alias-interactive-bg-hover)}
29
- .bl-actions button:disabled{color:var(--dsw-alias-label-dimmed);cursor:progress}
27
+ .bl-actions button,.bl-detail,.bl-fetch{appearance:none;font-family:inherit;font-size:12px;min-height:32px;padding:5px 11px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer}
28
+ .bl-actions button:hover,.bl-detail:hover,.bl-fetch:hover{background:var(--dsw-alias-interactive-bg-hover)}
29
+ .bl-actions button:disabled,.bl-fetch:disabled{color:var(--dsw-alias-label-dimmed);cursor:progress}
30
+ /* 「抓取正文」是这一屏唯一会出去抓东西的按钮,给个品牌色边框让它站得出来(仍走宿主 token) */
31
+ .bl-fetch{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary);align-self:flex-start}
30
32
  .bl-today,.bl-video,.bl-article,.bl-digest{display:flex;flex-direction:column;gap:8px;min-width:0}
31
33
  .bl-rail{margin-top:18px;min-width:0}
32
34
  .bl-rail h3{font-size:13px;font-weight:500;margin:0 0 8px;color:var(--dsw-alias-label-primary)}
@@ -38,20 +40,25 @@ export const CSS = `
38
40
  .bl-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(260px, 1fr));gap:12px;min-width:0}
39
41
  .bl-card,.bl-row{appearance:none;text-align:left;font-family:inherit;color:inherit;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:9px 10px;cursor:pointer;display:flex;flex-direction:column;gap:5px;min-height:32px;min-width:0}
40
42
  .bl-card:hover,.bl-row:hover,.bl-row[aria-selected="true"]{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-border-l2)}
41
- .bl-card:focus-visible,.bl-row:focus-visible,.bl-navitem:focus-visible,.bl-actions button:focus-visible,.bl-detail:focus-visible,.bl-dbtns button:focus-visible,.bl-dbar button:focus-visible,.bl-facets input:focus-visible,.bl-collect input:focus-visible,.bl-newfolder input:focus-visible,.bl-newfolder button:focus-visible,.bl-toolbar button:focus-visible,.bl-foldertab:focus-visible,.bl-confirm button:focus-visible,.bl-setsave:focus-visible,.bl-setflag input:focus-visible,.bl-srcrow-head button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
43
+ .bl-card:focus-visible,.bl-row:focus-visible,.bl-navitem:focus-visible,.bl-actions button:focus-visible,.bl-detail:focus-visible,.bl-dbtns button:focus-visible,.bl-dbar button:focus-visible,.bl-facets input:focus-visible,.bl-collect input:focus-visible,.bl-newfolder input:focus-visible,.bl-newfolder button:focus-visible,.bl-toolbar button:focus-visible,.bl-foldertab:focus-visible,.bl-openbtns button:focus-visible,.bl-url:focus-visible,.bl-confirm button:focus-visible,.bl-setsave:focus-visible,.bl-setflag input:focus-visible,.bl-srcrow-head button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
42
44
  .bl-card-title,.bl-row-title{font-size:13px;line-height:1.4;color:var(--dsw-alias-label-primary)}
43
45
  .bl-card-meta,.bl-row-meta{font-size:11px;color:var(--dsw-alias-label-tertiary);display:flex;gap:6px;flex-wrap:wrap}
44
46
  .bl-why,.bl-row-why{font-size:12px;color:var(--dsw-alias-label-secondary);border-left:2px solid var(--dsw-alias-border-l2);padding-left:7px;margin-top:auto}
45
47
  .bl-tier{font-size:11px;color:var(--dsw-alias-label-primary)}
46
48
  .bl-play{font-size:11px;color:var(--dsw-alias-label-dimmed)}
47
- .bl-thumb{position:relative;aspect-ratio:16/9;border-radius:6px;background:var(--dsw-alias-bg-layer-3);display:block}
49
+ .bl-thumb{position:relative;aspect-ratio:16/9;border-radius:6px;background:var(--dsw-alias-bg-layer-3);display:block;overflow:hidden}
50
+ .bl-thumb img{width:100%;height:100%;object-fit:cover;display:block}
48
51
  .bl-dur{position:absolute;right:5px;bottom:5px;font-size:11px;padding:1px 5px;border-radius:4px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary)}
49
52
  .bl-two{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.1fr);gap:14px;align-items:start;min-width:0}
50
53
  .bl-list{display:flex;flex-direction:column;gap:8px;min-width:0}
51
- .bl-reader{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:13px 15px;min-height:200px;min-width:0;display:flex;flex-direction:column;gap:8px}
52
- .bl-reader h4{margin:0;font-size:13px;font-weight:500;line-height:1.4;color:var(--dsw-alias-label-primary)}
54
+ .bl-reader{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:16px 18px;min-height:200px;min-width:0;display:flex;flex-direction:column;gap:9px}
55
+ .bl-reader h4{margin:0;font-size:15px;font-weight:500;line-height:1.45;color:var(--dsw-alias-label-primary)}
53
56
  .bl-reader-by,.bl-reader-hint,.bl-reader-link{font-size:11px;color:var(--dsw-alias-label-secondary);margin:0}
54
- .bl-reader-body{font-size:13px;white-space:pre-wrap;word-break:break-word;color:var(--dsw-alias-label-primary)}
57
+ /* 阅读排版(护眼三件事):14px/1.8 的中文长文才读得动;行长封顶 42em(约 42 个汉字,
58
+ 超过就得转头);空行成段、段间 0.75em,段内单换行原样保留(字幕逐行紧排靠它)。
59
+ 限宽只落在文字本身,阅读卡照常填满栏宽——不碰「容器不许限宽」的那条规矩。 */
60
+ .bl-reader-body{font-size:14px;line-height:1.8;white-space:pre-wrap;word-break:break-word;color:var(--dsw-alias-label-primary);max-width:42em;display:flex;flex-direction:column;gap:0.75em}
61
+ .bl-reader-body p{margin:0}
55
62
  .bl-library{display:grid;grid-template-columns:150px minmax(0,1fr);gap:14px;align-items:start;min-width:0}
56
63
  .bl-library-body{display:flex;flex-direction:column;gap:10px;min-width:0}
57
64
  .bl-facets{display:flex;flex-direction:column;gap:12px;font-size:12px;min-width:0}
@@ -83,7 +90,7 @@ export const CSS = `
83
90
  .bl-dmain{padding:12px 14px;display:flex;flex-direction:column;gap:11px;overflow-y:auto;font-size:12px;min-width:0}
84
91
  .bl-dmain section{display:flex;flex-direction:column;gap:4px}
85
92
  .bl-dmain h5{margin:0 0 3px;font-size:11px;font-weight:500;color:var(--dsw-alias-label-tertiary)}
86
- .bl-dmain p{margin:0;word-break:break-word;color:var(--dsw-alias-label-primary)}
93
+ .bl-dmain p{margin:0;word-break:break-word;color:var(--dsw-alias-label-primary);line-height:1.65}
87
94
  .bl-kv{display:flex;gap:8px;flex-wrap:wrap;font-size:11px;color:var(--dsw-alias-label-secondary)}
88
95
  .bl-missing{color:var(--dsw-alias-state-error-primary)}
89
96
  .bl-collect{display:flex;flex-wrap:wrap;gap:2px 12px;font-size:12px}
@@ -97,7 +104,36 @@ export const CSS = `
97
104
  .bl-newfolder button:hover{background:var(--dsw-alias-interactive-bg-hover)}
98
105
  .bl-newfolder button:disabled{color:var(--dsw-alias-label-dimmed);cursor:progress}
99
106
  .bl-kw{font-size:11px;color:var(--dsw-alias-label-secondary)}
100
- .bl-url{font-size:12px;color:var(--dsw-alias-brand-primary);word-break:break-all}
107
+ .bl-url{font-size:12px;color:var(--dsw-alias-brand-primary);word-break:break-all;text-decoration:underline}
108
+ /* 内嵌播放器(V5)与侧栏播放(M7)共用一套舞台。宿主不拦 iframe(真机实证),
109
+ bilibili 试图跳出顶层会被 Chromium 自己拦下,无需 sandbox(加了反而杀播放器脚本) */
110
+ .bl-player{display:block;width:100%;border:0;background:var(--dsw-alias-bg-layer-2)}
111
+ /* 舞台:裁切窗与 16:9 视频面等大(aspect-ratio 跟容器宽度走),不再是写死的像素对——
112
+ 早先 360px 窗 + 900px iframe,窄容器漏推荐流、宽容器把 B 站控制条(音量在那)裁出窗外。
113
+ YouTube embed 本来就是纯 16:9 播放器,窗口精确合围即止。
114
+ B 站是完整页,实测(无头 Edge 量四档宽度,见 docs/verification.md):视频面恒为
115
+ ~660x371(版心固定;容器再宽只是右侧多出推荐列,更窄则横向溢出),页头到视频顶
116
+ ~170px。所以 iframe 恒定 660 虚拟宽(永不进"播放器+推荐列"的宽版式)、按容器宽
117
+ 等比缩放(tan(atan2()) 是 CSS 做长度除法的既定写法,Chromium 111+;宿主是 2026 年
118
+ 的 Electron,Chromium 远新于此,不支持的老引擎会退化成"页头占掉窗口"的坏样子,
119
+ 那不是可支持的配置),页头偏移同比上移——任何宽度下视频都精确 16:9 填满
120
+ 窗口、控制条(音量)完整在窗内、页头/推荐流/弹幕侧栏都推出窗外。两个常数是真机
121
+ 待校值,平台改版错位的退路仍是设置页「裁掉推荐区」开关。 */
122
+ .bl-stage{--bl-nav:170px;--bl-frame:660px;container-type:inline-size;width:100%;overflow:hidden;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}
123
+ .bl-stage-crop{aspect-ratio:16/9}
124
+ .bl-stage>.bl-player{border-radius:0}
125
+ .bl-stage:not(.bl-stage-crop)>.bl-player{height:640px}
126
+ .bl-stage-crop>.bl-player{height:100%}
127
+ .bl-stage-bili>.bl-player{--bl-scale:tan(atan2(100cqw, var(--bl-frame)));width:var(--bl-frame);height:calc(var(--bl-nav) + var(--bl-frame) * 0.5625);margin-top:calc(var(--bl-nav) * var(--bl-scale) * -1);transform:scale(var(--bl-scale));transform-origin:0 0}
128
+ /* 内层全屏(播放器自己的全屏键)把宿主 iframe 提成 :fullscreen——把缩放与偏移还原,
129
+ 不然全屏画面缩在角落 */
130
+ .bl-player:fullscreen{width:100%;height:100%;margin-top:0;transform:none}
131
+ .bl-tab{padding:8px;display:flex;flex-direction:column;gap:6px}
132
+ .bl-tab-head{display:flex;align-items:center;gap:8px;min-width:0;margin:0}
133
+ .bl-tab-title{flex:1;min-width:0;font-size:12px;color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
134
+ .bl-openbtns{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}
135
+ .bl-openbtns button{appearance:none;font-family:inherit;font-size:12px;min-height:32px;padding:5px 11px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap}
136
+ .bl-openbtns button:hover{background:var(--dsw-alias-interactive-bg-hover)}
101
137
  .bl-feedback{margin:0;padding:7px 14px;font-size:12px;background:var(--dsw-alias-bg-layer-2);border-top:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary)}
102
138
  .bl-dbtns{display:flex;gap:8px;padding:11px 14px;border-top:1px solid var(--dsw-alias-border-l1)}
103
139
  .bl-dbtns button{flex:1;appearance:none;font-family:inherit;font-size:12px;min-height:32px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer}
@@ -119,6 +155,8 @@ export const CSS = `
119
155
  .bl-setsave{appearance:none;font-family:inherit;font-size:12px;min-height:32px;padding:5px 11px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;align-self:flex-start}
120
156
  .bl-setsave:hover{background:var(--dsw-alias-interactive-bg-hover)}
121
157
  .bl-setsave:disabled{color:var(--dsw-alias-label-dimmed);cursor:progress}
158
+ /* 小红书扫码二维码(M6):PNG 自带白底(QR 可读性在图里),尺寸固定不参与伸缩 */
159
+ .bl-xhsqr{width:180px;height:180px;padding:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px}
122
160
  .bl-setflag{display:flex;gap:6px;align-items:center;flex-wrap:wrap;min-height:32px;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer}
123
161
  .bl-setflag:hover{color:var(--dsw-alias-label-primary)}
124
162
  .bl-setflag input{width:14px;height:14px;flex:none;cursor:pointer;accent-color:var(--dsw-alias-brand-primary)}
@@ -36,3 +36,18 @@ export const foldersUnreadNote = `读不到夹(${readbackMiss('夹列表')})
36
36
  * 两处各写一遍的话,迟早漂移成两种说法,用户就得猜哪个才是对的。
37
37
  */
38
38
  export const judgeHint = '对会话说一句「把今天的候选判一下」'
39
+
40
+ /**
41
+ * 只认 http/https 的绝对地址,其余(空、非字符串、别的协议、javascript:)一律 null。
42
+ * 库里 url/thumb 都是平台给的原样字段,直接塞进 href/src 等于把外来的字符串当可执行入口。
43
+ */
44
+ export function httpUrl(v) {
45
+ const s = typeof v === 'string' ? v.trim() : ''
46
+ return /^https?:\/\//i.test(s) ? s : null
47
+ }
48
+
49
+ /**
50
+ * 封面地址:和链接同一套判定。视图拿它决定画不画 <img>——坏值不许变成
51
+ * "空 src 的裂图标",那比灰占位更难看也更可疑。
52
+ */
53
+ export const thumbSrc = (thumb) => httpUrl(thumb)
@@ -0,0 +1,56 @@
1
+ /**
2
+ * 侧栏播放 tab 的纯构建层:外链认领谓词 + descriptor + 手动播放的 seed。
3
+ * 这层不认识宿主也不认识 ctx——装配(真 ctx)留在 _footer,全部可以拿假 service 单测。
4
+ *
5
+ * 页签有两个入口:① 认领——better-sidebar 自己在拦截会话外链时打开(URL 挂在
6
+ * `tab.path` 上,实测);② 手动——抽屉「侧栏播放」按钮经 videoTabSeed 构造 seed
7
+ * 调 openTab。两条路都走 `url` 载荷(实测定的,docs/verification.md 的 M7 探针):
8
+ * 带 url/path 的 open 落 DSH 原生右侧栏。面板在前台时 open 会先排队,
9
+ * 会话回到前台由 better-sidebar 的 flushPending 落地——回显必须把这话讲清楚。
10
+ */
11
+ import { playerEmbed } from './views/drawer.js'
12
+
13
+ export const TAB_ID = 'dsh-baleen:player'
14
+
15
+ /** 手动播放的 seed:url 载荷进原生右侧栏,标题缺省给个能认的名字 */
16
+ export function videoTabSeed(url, title = '') {
17
+ return { type: TAB_ID, url, title: title || 'Baleen 播放' }
18
+ }
19
+
20
+ /** 认不出来就返回 null:宁可让链接照旧去系统浏览器,也不开一个播不了的门 */
21
+ export function parseVideoUrl(raw) {
22
+ let u = null
23
+ try { u = raw instanceof URL ? raw : new URL(String(raw ?? '')) } catch { return null }
24
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return null
25
+ if (u.username || u.password) return null
26
+ const embed = playerEmbed(u.href)
27
+ return embed ? { ...embed, url: u.href } : null
28
+ }
29
+
30
+ /** 原生侧栏把认领来的 URL 挂在页签记录的 path 上(实测 tab18),不是 tab.url */
31
+ const urlOf = (tab) => String(tab?.path ?? '')
32
+
33
+ export function buildTabDescriptor({ render, flags }) {
34
+ return {
35
+ id: TAB_ID,
36
+ title: 'Baleen 播放',
37
+ description: '把 B 站 / YouTube 视频在侧栏里播,不跳平台',
38
+ order: 60,
39
+ single: false,
40
+ dedupeKey: (tab) => TAB_ID + ':' + urlOf(tab),
41
+ urlTarget: (url) => Boolean(flags.takeover()) && Boolean(parseVideoUrl(url)),
42
+ component: (props) => render({ url: urlOf(props?.tab), title: String(props?.tab?.title ?? 'Baleen 播放') })
43
+ }
44
+ }
45
+
46
+ export function registerSidebarTab(service, descriptor) {
47
+ const noop = () => {}
48
+ if (!service || typeof service.registerTab !== 'function') return noop
49
+ try {
50
+ const disposer = service.registerTab(descriptor)
51
+ return typeof disposer === 'function' ? disposer : noop
52
+ } catch (e) {
53
+ console.error('[dsh-baleen] 侧栏 tab 注册失败:' + ((e && e.message) || e))
54
+ return noop
55
+ }
56
+ }
@@ -121,6 +121,16 @@ export function createApi({ fetch: fetchImpl = globalThis.fetch, storage = globa
121
121
  ...(typeof newFolder === 'string' && newFolder.trim() ? { new_folder: newFolder.trim() } : {})
122
122
  }),
123
123
  folderOp: (action, { id, name } = {}) => post('/folder', { action, ...(id != null ? { id } : {}), ...(name != null ? { name } : {}) }),
124
+ // 「浏览器打开」走宿主侧 /open:面板只报条目 id,URL 由宿主从库里取(终审真修——
125
+ // 本宿主把客户端页面的弹窗与外链全部静默拦下,window.open 与 <a target=_blank> 都到不了系统浏览器)。
126
+ openItem: (itemId) => post('/open', { item_id: itemId }),
127
+ // 「抓取正文」:面板→宿主的补抓路由(只报条目 id,抓哪条、抓成什么样由宿主定),
128
+ // 不是 agent 工具——判断那一轮的只读面里没有它。
129
+ fulltext: (itemId) => post('/fulltext', { item_id: itemId }),
130
+ // 小红书(M6):登录态探活与扫码登录。都是面板→宿主路由,不进 agent 工具面。
131
+ // 状态探活会按需拉起 MCP 子进程,所以是手动按钮触发而非挂载即打。
132
+ xhsStatus: () => plainGet('/xhs-status'),
133
+ xhsLoginQr: () => plainGet('/xhs-login-qr'),
124
134
  /* 设置页(settings.section)的取数与动作:GET 走 plainGet(不碰共享快照),写走 post/put */
125
135
  preferenceGet: () => plainGet('/preference'),
126
136
  preferencePut: (section, value) => put('/preference', { section, value }),
@@ -14,6 +14,8 @@ export const STATE_HINT = {
14
14
  missing: '读不到内容(没有语音,也没抓到正文)',
15
15
  burned_in: '硬字幕读不到(字烧在画面上,不是插件偷懒)'
16
16
  }
17
+ /** 已经有正文的那几种:再摆一颗「抓取正文」就是假动作——点了也不会变,反倒让人怀疑是没生效 */
18
+ const HAS_BODY = new Set(['full', 'subtitle', 'transcribed'])
17
19
  const STATE_OPTIONS = [
18
20
  ['full', '全文'], ['summary_only', '仅摘要'], ['subtitle', '字幕'],
19
21
  ['transcribed', '转写'], ['missing', '读不到'], ['burned_in', '硬字幕']
@@ -34,10 +36,30 @@ export function readableBody(item) {
34
36
  return typeof t === 'string' ? t.trim() : ''
35
37
  }
36
38
 
37
- export function ArticleView({ items = [], filters = {}, selectedId, onFilter, onSelect, onOpen }) {
39
+ /**
40
+ * 正文分段:空行(\n{2,})是段界,段内单换行原样保留。一条规则服务两种内容——
41
+ * 文章有真实段距,字幕逐行紧排不被拆散(行间的单换行交给 CSS pre-wrap 折行)。
42
+ * 先把 \r\n / \r 归一成 \n:Windows 作者的 feed 里段界是 \r\n\r\n,两个 \n 被
43
+ * \r 隔开就永远凑不成"连续两个换行",分段会静默失效。空白/非字符串返回空数组,
44
+ * 调用方给占位句。
45
+ */
46
+ export function readerParagraphs(text) {
47
+ const s = typeof text === 'string' ? text.replace(/\r\n?/g, '\n') : ''
48
+ return s.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean)
49
+ }
50
+
51
+ /** 右栏当前是哪一条:选中项被筛掉时回落第一条。视图与装配层共用这一条规则,
52
+ * 否则「正在抓的是哪条」会跟右栏画的那条各算一遍,忙态就贴到别人身上。 */
53
+ export function currentArticle(items, filters = {}, selectedId) {
54
+ const list = visibleArticles(items, filters)
55
+ return list.find((i) => i.id === selectedId) ?? list[0] ?? null
56
+ }
57
+
58
+ export function ArticleView({ items = [], filters = {}, selectedId, onFilter, onSelect, onOpen, onFulltext, fetching = false }) {
38
59
  const list = visibleArticles(items, filters)
39
- const cur = list.find((i) => i.id === selectedId) ?? list[0] ?? null
60
+ const cur = currentArticle(items, filters, selectedId)
40
61
  const body = cur ? readableBody(cur) : ''
62
+ const paras = readerParagraphs(body)
41
63
  const sources = [...new Set(items.filter((i) => i.kind !== 'video').map((i) => i.source_id))]
42
64
 
43
65
  return vnode('div', { className: 'bl-article' }, [
@@ -73,8 +95,20 @@ export function ArticleView({ items = [], filters = {}, selectedId, onFilter, on
73
95
  Number(cur.time_estimated) > 0 ? `约 ${Math.round(Number(cur.time_estimated))} 分钟` : ''
74
96
  ]),
75
97
  STATE_HINT[cur.body_state] ? vnode('p', { className: 'bl-reader-hint' }, [STATE_HINT[cur.body_state]]) : null,
76
- vnode('div', { className: 'bl-reader-body' }, [body || '(没有正文,只能看原文)']),
98
+ vnode('div', { className: 'bl-reader-body' },
99
+ paras.length ? paras.map((p, i) => vnode('p', { key: i }, [p])) : ['(没有正文,只能看原文)']),
77
100
  vnode('p', { className: 'bl-reader-link' }, ['读原文:' + (cur.url || '(这条没带链接)')]),
101
+ // 已经有正文的(full/subtitle/transcribed)不画这颗按钮:点了也不会有新东西,
102
+ // 摆在那儿只会让人把手里已经有的正文再怀疑一遍。
103
+ HAS_BODY.has(cur.body_state) ? null : vnode('button', {
104
+ type: 'button', className: 'bl-fetch', disabled: Boolean(fetching),
105
+ onClick: () => onFulltext?.(cur.id)
106
+ }, [fetching ? '正在抓取…' : '抓取正文']),
107
+ // 这句不是内容说明(那句在 STATE_HINT 里),是这颗按钮的预期管理:抓不到是常态,
108
+ // 别让人点完一次就以为插件坏了。
109
+ cur.body_state === 'summary_only'
110
+ ? vnode('p', { className: 'bl-reader-hint' }, ['只拿到摘要。点上面的按钮去原文抓一次正文;抓不到就还是看原文。'])
111
+ : null,
78
112
  vnode('button', { type: 'button', className: 'bl-detail', onClick: () => onOpen?.(cur.id) }, ['详情与反馈'])
79
113
  ].filter(Boolean) : [
80
114
  vnode('p', { className: 'bl-empty' }, ['左边没有条目。多半是这个筛选太窄,或者这一轮还没抓到东西。'])