@mzzsfy/dsh-settings-nav-icons 0.1.5 → 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.
package/README.md CHANGED
@@ -8,6 +8,7 @@ DeepSeek Harness 纯前端插件:设置弹窗左侧导航与 dsh-market 插件
8
8
  |---|---|
9
9
  | 分区当前是官方齿轮 | 改写官方 svg 内部内容为专属图形(声明 → 内置映射 → 关键词 → 哈希) |
10
10
  | 分区当前非齿轮(官方原生图标 / 第三方插件供给的图标) | 不干预,映射或声明命中也不例外 |
11
+ | 左侧导航分区溢出(弹窗高度不足) | 列表区域滚动,标题固定;官方无滚动机制,溢出被弹窗裁剪致底部分区不可达 |
11
12
  | 市场卡片头像槽(有名称锚) | 按插件名取图替换作者头像/字母色块 |
12
13
  | 语言切换导致 label 变化 | 已改写的分区按新 label 重新取图改写 |
13
14
  | 面板关闭后重新打开 / 会话切换 | 新渲染的齿轮自动再改写 |
@@ -64,11 +65,12 @@ window.__navicIcons.register({ '消息通知': 'bell' })
64
65
 
65
66
  ## 实现要点
66
67
 
68
+ - 导航滚动补偿:官方 navList 高度随内容撑开,弹窗(overflow hidden)裁掉溢出且无滚动途径,分区一多底部不可达。插件启动时向 head 注入一条样式规则(navList `flex:1 1 0 + min-height:0 + overflow-y:auto + scrollbar-width:thin`),样式表形态对弹窗关闭重开等任意 React 重渲染持续生效;卸载即移除,零残留。类名锚定与图标改写同策略(`VOzbGW_` 哈希前缀字面量)。
67
69
  - 实现边界:`settings.*` 子槽位的声明权与渲染权均被原版 `dsh-client-ui-settings-general` 条目占用——`renderSlot` 只授予声明了 `children` 的条目(dsh-client-ui-renderer),而 Slot 声明全局唯一、重复声明即 `already declared`,因此任何插件都无法在保留设置页内容的前提下接管 Shell 重绘导航。DOM 观察是唯一不依赖官方契约变更的路径(dream-skin 与 dsh-better-sidebar 各自内置了同原理的一次性 hack)。
68
70
  - 匹配锚点:分区显示文本而非分区 id——id 不进 DOM,文本是唯一稳定可见锚点;CSS Modules 哈希类名(`VOzbGW_*`)取字面量。label 一律 trim 后匹配,空白 label 直接跳过。
69
71
  - 内容改写制:决策只对官方齿轮(齿轮路径前缀 `M14.0861` 识别)生效,`applyDecision` 写官方 svg 的 `innerHTML` 并把记账(`data-navic` = label)落在官方 svg 自身;不新建节点、不改属性、不驱动 style。React 安全性依据:官方 nav 图标是静态子树,重渲染前后 element 引用相等即 bailout,改写内容稳定存活(0.1.x 的 NotFoundError 仅源于 remove 节点本身);升级残留的旧注入节点(`data-navic="1"`)在改写前清除。改写后的 svg 非齿轮且带记账,与"未处理的官方原生非齿轮 svg"靠记账区分——因此声明变更不删记账,由 `registerIcons` 对命中键的 nav svg 以 `resolveForLabel` 全链(声明 → 内置映射 → 关键词 → 哈希)就地重写内容,头像槽(非 svg 节点)仍走清账重贴。
70
72
  - 市场卡片头像槽记账为插件名,IMG/DIV 同构处理:原节点隐藏、注入图标跟随(不触碰 React 受管子树),换名重贴按父容器范围清理旧注入、不误删外来兄弟。
71
- - 生命周期:单个 `MutationObserver` 常驻 `document.body`(`childList` + `subtree` + `characterData`;childList 通道仅元素级变更放行,characterData 通道按目标域精确放行——label 与市场卡片名所在行内的文本改写才唤醒,流式正文等域外文本变更不唤醒),变更去抖到 `requestAnimationFrame` 扫描,单元格与头像槽合并为单次全文档遍历,扫描结束 `takeRecords` 消除自产写入回波;`ctx.effect` 持有,卸载即断开并取消已排定帧,已停止实例的注册入口短路、不驱动 DOM。观察器句柄挂 window 代际槽(HMR 重评估先拆上一代观察器与在途帧),帧句柄与槽同步(跨代取消真实可达)。常驻全文档观察是已知性能取舍:装饰性插件的目标域(设置弹窗/市场卡片)无稳定根锚点,两级观察的回归风险大于收益。
73
+ - 生命周期:单个 `MutationObserver` 常驻 `document.body`(`childList` + `subtree` + `characterData`;childList 通道仅元素级变更放行,characterData 通道按目标域精确放行——label 与市场卡片名所在行内的文本改写才唤醒,流式正文等域外文本变更不唤醒),变更去抖到 `requestAnimationFrame` 扫描,单元格与头像槽合并为单次全文档遍历,扫描结束 `takeRecords` 消除自产写入回波;`ctx.effect` 持有,卸载即断开并取消已排定帧,已停止实例的注册入口短路、不驱动 DOM。观察器句柄、在途帧与滚动样式元素挂 window 代际槽(HMR 重评估先拆上一代三者,滚动样式对旧版槽缺字段宽松判空),帧句柄与槽同步(跨代取消真实可达)。常驻全文档观察是已知性能取舍:装饰性插件的目标域(设置弹窗/市场卡片)无稳定根锚点,两级观察的回归风险大于收益。
72
74
  - 纯逻辑层(映射表 + 改写决策)在 `src/logic.mjs`,`src/client.js` 内嵌同源实现,`node --test` 以同一套场景对两份实现做 parity 验证(全表 deepEqual + 决策函数逐函数源码对比),另有编排层契约测试(注册校验/队列三态与时序/生命周期/头像槽编排/异常隔离)与四生产者样板契约测试。
73
75
 
74
76
  ## 安装
@@ -104,7 +106,7 @@ pnpm --dir packages/dsh-settings-nav-icons test
104
106
  node --test packages/dsh-settings-nav-icons/test/*.test.mjs
105
107
  ```
106
108
 
107
- 覆盖:上表全部行为场景(齿轮强补 / 非齿轮不动 / svg 记账幂等与 label 重写 / trim 与空 label / 无 svg 降级 / 内容改写与不建节点 / 0.1.x 残留清理 / 声明安全门 / 撤销回退取图链 / 关键词边界 / 头像槽 img+div)、映射表契约、双实现全量同源 parity、client.js 注册 id 守卫、编排层契约(注册校验 / 就地重写 / 队列三态 / 生命周期 / 异常隔离 / 持久层恢复)、四生产者样板契约。
109
+ 覆盖:上表全部行为场景(齿轮强补 / 非齿轮不动 / svg 记账幂等与 label 重写 / trim 与空 label / 无 svg 降级 / 内容改写与不建节点 / 0.1.x 残留清理 / 声明安全门 / 撤销回退取图链 / 关键词边界 / 头像槽 img+div / 导航滚动样式注入与卸载)、映射表契约、双实现全量同源 parity、client.js 注册 id 守卫、编排层契约(注册校验 / 就地重写 / 队列三态 / 生命周期 / 异常隔离 / 持久层恢复)、四生产者样板契约。
108
110
 
109
111
  ## License
110
112
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-settings-nav-icons",
3
3
  "description": "设置弹窗导航分区与插件市场卡片图标替换:按分区显示文本把回退齿轮换成专属图形,市场卡片头像槽换成插件图标,DOM 观察实现,卸载后重载页面恢复官方图标",
4
- "version": "0.1.5",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -12,6 +12,12 @@ window.__ModuleLoader__.load({
12
12
  const SELECTOR_LABEL = '.VOzbGW_navLabel'
13
13
  const ATTR_MARK = 'data-navic'
14
14
 
15
+ // ---- 设置导航滚动补偿:官方 navList 高度随内容撑开,溢出被 panel hidden
16
+ // 裁剪且无滚动机制,分区一多底部即不可达。样式表形态一次注入,对弹窗重开
17
+ // 等任意 React 重渲染持续生效;类名锚定与上方单元格同策略(哈希前缀字面量)。
18
+
19
+ const NAVLIST_SCROLL_CSS = '.VOzbGW_navList{flex:1 1 0;min-height:0;overflow-y:auto;scrollbar-width:thin}'
20
+
15
21
  /* LOGIC-BEGIN */
16
22
  // 纯逻辑层:图标映射 + 单元格替换决策,与 src/logic.mjs 同源,由 parity 测试保证。
17
23
 
@@ -459,6 +465,24 @@ window.__ModuleLoader__.load({
459
465
  schedule()
460
466
  }
461
467
 
468
+ // 滚动样式元素:start 注入 head,stop 移除,引用即状态源,幂等无守卫分支;
469
+ // 同挂代际槽, HMR 重评估 stop 未跑时由新实例代拆
470
+ let scrollStyle = null
471
+
472
+ function installScrollStyle() {
473
+ scrollStyle = document.createElement('style')
474
+ scrollStyle.textContent = NAVLIST_SCROLL_CSS
475
+ document.head.appendChild(scrollStyle)
476
+ if (slot !== null) slot.scrollStyle = scrollStyle
477
+ }
478
+
479
+ function uninstallScrollStyle() {
480
+ if (scrollStyle === null) return
481
+ scrollStyle.remove()
482
+ scrollStyle = null
483
+ if (slot !== null) slot.scrollStyle = null
484
+ }
485
+
462
486
  // 队列直通桩属主守卫:stop 仅复位本实例所置桩,乱序清理不覆盖新实例运行态
463
487
  let queueStub = null
464
488
 
@@ -485,10 +509,13 @@ window.__ModuleLoader__.load({
485
509
  if (previous !== undefined) {
486
510
  if (previous.observer !== null) previous.observer.disconnect()
487
511
  if (previous.rafId !== 0) cancelAnimationFrame(previous.rafId)
512
+ // 宽松判空:槽是跨代码版本通道,旧版槽无 scrollStyle 字段(undefined)
513
+ if (previous.scrollStyle != null) previous.scrollStyle.remove()
488
514
  }
489
- slot = { observer: null, rafId: 0 }
515
+ slot = { observer: null, rafId: 0, scrollStyle: null }
490
516
  window[SLOT_KEY] = slot
491
- // 可抛步骤(声明恢复/队列排水)先于观察器挂载,失败不产生孤儿扫描器
517
+ // 可抛步骤(声明恢复/队列排水/样式注入)先于观察器挂载,失败不产生孤儿扫描器
518
+ installScrollStyle()
492
519
  restoreDeclarations()
493
520
  drainQueue()
494
521
  observer = new MutationObserver(onMutations)
@@ -499,6 +526,7 @@ window.__ModuleLoader__.load({
499
526
 
500
527
  function stop() {
501
528
  active = false
529
+ uninstallScrollStyle()
502
530
  if (observer !== null) {
503
531
  observer.disconnect()
504
532
  observer = null
@@ -16,6 +16,7 @@ const SELECTOR_AV = '[class$="_av"]'
16
16
  const SELECTOR_ALL = SELECTOR_CELL + ',' + SELECTOR_AV
17
17
  const SELECTOR_MARKED = '[data-navic]'
18
18
  const SELECTOR_LABEL = '.VOzbGW_navLabel'
19
+ const NAVLIST_SCROLL_CSS = '.VOzbGW_navList{flex:1 1 0;min-height:0;overflow-y:auto;scrollbar-width:thin}'
19
20
 
20
21
  function fakeRaf() {
21
22
  const pending = new Map()
@@ -113,6 +114,25 @@ function fakeDocument() {
113
114
  marked: [],
114
115
  cells: [],
115
116
  avatars: [],
117
+ // 生命周期事件序:锁定"样式注入先于观察器挂载"的顺序契约
118
+ events: [],
119
+ head: {
120
+ children: [],
121
+ appendChild(el) { doc.head.children.push(el); doc.events.push('style') },
122
+ },
123
+ createdStyles: [],
124
+ createElement(tag) {
125
+ const el = {
126
+ tag,
127
+ textContent: '',
128
+ remove() {
129
+ doc.createdStyles.splice(doc.createdStyles.indexOf(el), 1)
130
+ doc.head.children.splice(doc.head.children.indexOf(el), 1)
131
+ },
132
+ }
133
+ if (tag === 'style') doc.createdStyles.push(el)
134
+ return el
135
+ },
116
136
  querySelectorAll(sel) {
117
137
  if (sel === SELECTOR_ALL) return [...doc.cells, ...doc.avatars]
118
138
  if (sel === SELECTOR_CELL) return doc.cells
@@ -136,7 +156,7 @@ function loadClient() {
136
156
  this.cb = cb
137
157
  observers.push(this)
138
158
  }
139
- observe(...args) { observed.push(args) }
159
+ observe(...args) { observed.push(args); if (doc.events) doc.events.push('observe') }
140
160
  disconnect() { observed.length = 0 }
141
161
  takeRecords() { return [] }
142
162
  }
@@ -174,6 +194,58 @@ test('boot 观察:body 目标与三开关齐全,卸载后断开', () => {
174
194
  assert.equal(observed.length, 0, '卸载后 disconnect')
175
195
  })
176
196
 
197
+ test('导航滚动样式:启动注入 head,卸载移除,重装恢复,先于观察器挂载', () => {
198
+ const { win, doc, observed } = loadClient()
199
+ assert.equal(doc.createdStyles.length, 0, '启动前无注入')
200
+ const unload = boot(win.__loaded, doc)
201
+ try {
202
+ assert.equal(doc.createdStyles.length, 1, '恰一次注入')
203
+ assert.equal(doc.createdStyles[0].textContent, NAVLIST_SCROLL_CSS, '滚动规则与实现同源')
204
+ assert.equal(doc.head.children.length, 1, '样式挂载在 head')
205
+ assert.deepEqual(doc.events, ['style', 'observe'], '样式注入先于观察器挂载')
206
+ unload()
207
+ assert.equal(doc.createdStyles.length, 0, '卸载后移除')
208
+ assert.equal(doc.head.children.length, 0, 'head 无残留')
209
+ const unload2 = boot(win.__loaded, doc)
210
+ try {
211
+ assert.equal(doc.createdStyles.length, 1, '重装恢复注入')
212
+ } finally {
213
+ unload2()
214
+ }
215
+ assert.equal(doc.createdStyles.length, 0, '二次卸载移除')
216
+ } finally {
217
+ unload()
218
+ }
219
+ })
220
+
221
+ test('导航滚动样式代际:HMR 不卸载重评估由新实例代拆,旧形槽跨版本容忍', () => {
222
+ // HMR 路径:旧实例未 stop 直接二次 boot(新实例读槽代拆上一代样式)
223
+ const { win, doc } = loadClient()
224
+ const unload1 = boot(win.__loaded, doc)
225
+ const oldStyle = doc.createdStyles[0]
226
+ const unload2 = boot(win.__loaded, doc)
227
+ try {
228
+ assert.equal(doc.createdStyles.length, 1, '代拆后恰一份样式')
229
+ assert.equal(doc.head.children.length, 1, 'head 无旧代残留')
230
+ assert.notEqual(doc.createdStyles[0], oldStyle, '存活者为新实例注入的新元素')
231
+ unload2()
232
+ assert.equal(doc.createdStyles.length, 0, '新实例卸载后干净')
233
+ } finally {
234
+ unload1()
235
+ unload2()
236
+ }
237
+
238
+ // 升级路径:上一代是旧版本槽(无 scrollStyle 字段),代拆不得抛错
239
+ const { win: win2, doc: doc2 } = loadClient()
240
+ win2[Symbol.for('@mzzsfy/dsh-settings-nav-icons')] = { observer: null, rafId: 0 }
241
+ const unload3 = boot(win2.__loaded, doc2)
242
+ try {
243
+ assert.equal(doc2.createdStyles.length, 1, '旧形槽不阻塞注入')
244
+ } finally {
245
+ unload3()
246
+ }
247
+ })
248
+
177
249
  test('registerIcons 输入校验:数组与空值拒收', () => {
178
250
  const { win, doc, raf } = loadClient()
179
251
  const unload = boot(win.__loaded, doc)