dsh-wisp 1.30.0 → 1.32.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/lib/client.js CHANGED
@@ -51,7 +51,7 @@ window.__ModuleLoader__.load({
51
51
  var exports = module.exports
52
52
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
53
53
 
54
- const VERSION = '1.30.0'
54
+ const VERSION = '1.32.0'
55
55
 
56
56
  /* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
57
57
  忘了更新就会红,不会静默过期。 */
@@ -71,6 +71,8 @@ const WHATS_NEW = {
71
71
  '1.28.0': '菜单分成折叠分组,另外多了「关于她」和「动作一览」两个弹窗。',
72
72
  '1.29.0': '夜深了她会劝你去睡;你走开再回来她记得;连续来了几天她也记着。',
73
73
  '1.30.0': '会陪你专注计时了;页面藏起来时她会安静下来,不再空转。',
74
+ '1.31.0': '菜单重做:状态一目了然,开关是开关,大小能直接拖。',
75
+ '1.32.0': '分组改成鼠标悬停就展开、移开就收起(点一下则是钉住)。',
74
76
  }
75
77
 
76
78
  /* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
@@ -689,10 +691,33 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
689
691
  .wisp-root[data-mood="attn"] .wisp-body{animation-name:wisp-nudge;animation-duration:1.5s;animation-iteration-count:4}
690
692
  @keyframes wisp-nudge{0%,100%{transform:translate3d(0,0,0)}25%{transform:translate3d(-7px,0,0)}75%{transform:translate3d(7px,0,0)}}
691
693
  @keyframes wisp-hop{0%,100%{transform:translate3d(0,0,0)}45%{transform:translate3d(0,-16px,0)}}
692
- .wisp-menu{position:absolute;min-width:150px;padding:5px;border-radius:11px;font-size:12px;line-height:1.2;color:var(--dsw-alias-label-primary,var(--wisp-say-fg));background:var(--dsw-alias-bg-overlay,var(--wisp-say-bg));border:1px solid var(--dsw-alias-border-l1,var(--wisp-say-line));box-shadow:0 10px 30px rgba(0,0,0,.28);-webkit-backdrop-filter:blur(14px) saturate(160%);backdrop-filter:blur(14px) saturate(160%);pointer-events:auto;animation:wisp-menu .14s ease}
694
+ .wisp-menu{position:absolute;min-width:198px;max-width:240px;padding:6px;border-radius:12px;font-size:12px;line-height:1.25;color:var(--dsw-alias-label-primary,var(--wisp-say-fg));background:var(--dsw-alias-bg-overlay,var(--wisp-say-bg));border:1px solid var(--dsw-alias-border-l1,var(--wisp-say-line));box-shadow:0 12px 34px rgba(0,0,0,.3);-webkit-backdrop-filter:blur(14px) saturate(160%);backdrop-filter:blur(14px) saturate(160%);pointer-events:auto;animation:wisp-menu .14s ease}
693
695
  .wisp-menu-item{display:block;width:100%;padding:7px 10px;border:0;border-radius:7px;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer;white-space:nowrap}
694
696
  .wisp-menu-item:hover{background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.22))}
695
- .wisp-menu-head{padding:7px 10px 3px;font-size:11px;opacity:.62;letter-spacing:.04em}
697
+ .wisp-menu-head{padding:5px 9px 7px}
698
+ .wisp-menu-title{font-size:12.5px;font-weight:600}
699
+ .wisp-menu-sub{font-size:10.5px;opacity:.6;margin-top:1px}
700
+ .wisp-menu-sep{height:1px;margin:4px 6px;background:var(--dsw-alias-border-l1,var(--wisp-say-line));opacity:.45}
701
+ .wisp-menu-hint{padding:5px 9px 3px;font-size:10.5px;opacity:.45}
702
+ .wisp-menu-row{display:flex;align-items:center;justify-content:space-between;gap:10px}
703
+ .wisp-menu-sum{flex:0 1 auto;min-width:0;font-size:11px;opacity:.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
704
+ .wisp-menu-item[data-accent="true"]{font-weight:600;color:var(--dsw-alias-brand-primary,inherit)}
705
+ .wisp-menu-item[data-muted="true"]{opacity:.78}
706
+ .wisp-switch{flex:0 0 auto;width:26px;height:14px;border-radius:8px;background:rgba(140,160,190,.35);position:relative;transition:background .15s ease}
707
+ .wisp-switch[data-on="true"]{background:var(--dsw-alias-brand-primary,#4a7cf7)}
708
+ .wisp-switch i{position:absolute;top:2px;left:2px;width:10px;height:10px;border-radius:50%;background:#fff;transition:transform .15s ease}
709
+ .wisp-switch[data-on="true"] i{transform:translateX(12px)}
710
+ .wisp-slider-row{padding:6px 10px 8px}
711
+ .wisp-slider-top{display:flex;justify-content:space-between;font-size:11px;opacity:.8;margin-bottom:3px}
712
+ .wisp-slider{position:relative;height:16px;cursor:pointer;outline:none;touch-action:none}
713
+ .wisp-slider-track{position:absolute;left:0;right:0;top:7px;height:3px;border-radius:2px;background:rgba(140,160,190,.35)}
714
+ .wisp-slider-fill{position:absolute;left:0;top:7px;height:3px;border-radius:2px;background:var(--dsw-alias-brand-primary,#4a7cf7)}
715
+ .wisp-slider-knob{position:absolute;top:3px;width:11px;height:11px;margin-left:-5px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.35)}
716
+ .wisp-slider:focus-visible .wisp-slider-knob{outline:2px solid var(--dsw-alias-brand-primary,#4a7cf7);outline-offset:1px}
717
+ .wisp-grid2{display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:2px 8px 4px}
718
+ .wisp-grid2 button,.wisp-corner{padding:6px 4px;border:1px solid transparent;border-radius:7px;background:none;color:inherit;font:inherit;font-size:11px;cursor:pointer}
719
+ .wisp-grid2 button:hover,.wisp-corner:hover{background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.22))}
720
+ .wisp-grid2 button[data-current="true"],.wisp-corner[data-current="true"]{border-color:var(--dsw-alias-brand-primary,#4a7cf7);font-weight:600}
696
721
  .wisp-root[data-hidden="true"]{display:none}
697
722
  .wisp-tab{position:absolute;width:56px;height:56px;padding:0;display:flex;align-items:center;justify-content:center;overflow:hidden;border-radius:16px;border:1px solid var(--dsw-alias-border-l1,var(--wisp-say-line));background:var(--dsw-alias-bg-overlay,var(--wisp-say-bg));box-shadow:0 6px 18px rgba(0,0,0,.22);cursor:pointer;pointer-events:auto;animation:wisp-menu .16s ease;transition:transform .16s ease}
698
723
  .wisp-tab:hover{transform:translateY(-2px)}
@@ -715,6 +740,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
715
740
  .wisp-cell:hover{border-color:var(--dsw-alias-border-l1,var(--wisp-say-line));background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.18))}
716
741
  .wisp-cell img{width:100%;height:52px;object-fit:contain;object-position:bottom center;pointer-events:none}
717
742
  .wisp-credits{font-size:11px;opacity:.8;margin:6px 0 0}
743
+ .wisp-dialog-action{display:block;width:100%;margin:4px 0 2px;padding:7px 10px;border:1px solid var(--dsw-alias-border-l1,var(--wisp-say-line));border-radius:8px;background:none;color:inherit;font:inherit;cursor:pointer}
744
+ .wisp-dialog-action:hover{background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.2))}
718
745
  @media (prefers-reduced-motion:reduce){.wisp-body{animation:none!important}}`
719
746
 
720
747
  const installStyles = (styles) => {
@@ -1987,12 +2014,67 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1987
2014
  /* 折叠分组要"在原地重开",所以记住这次的落点 */
1988
2015
  let menuAtX = 0
1989
2016
  let menuAtY = 0
2017
+ /* 悬停展开的延迟:鼠标路过一行就展开会抖(hover intent),
2018
+ 关闭的宽限更长一点 —— 斜着往子项移动时才不会中途收起。 */
2019
+ const HOVER_OPEN_MS = 140
2020
+ const HOVER_CLOSE_MS = 260
2021
+ /* 点开的组会被"钉住":鼠标移开也不关。悬停是预览,点击是决定。 */
2022
+ let pinnedGroup = ''
2023
+ let groupOpenTimer = null
2024
+ let groupCloseTimer = null
2025
+ /* 判断"焦点是否在这一组里"不能笼统地看"焦点是否在菜单里" —— 菜单一打开焦点就在
2026
+ 第一项上,那样写等于悬停收起永远不会触发。 */
2027
+
2028
+ const clearGroupTimers = () => {
2029
+ if (groupOpenTimer) { clock.cancel(groupOpenTimer); groupOpenTimer = null }
2030
+ if (groupCloseTimer) { clock.cancel(groupCloseTimer); groupCloseTimer = null }
2031
+ }
2032
+ /* 焦点是否在这**一组**里 —— 键盘用户正操作这一组时,鼠标移开不该把它收掉。
2033
+ 焦点在别的行、或压根不在菜单里,都不该阻止收起。
2034
+ 判法:从焦点向上走,遇到带 data-group-block 的祖先就算命中(不靠外部映射)。 */
2035
+ const focusInsideGroup = (name) => {
2036
+ let el = null
2037
+ try { el = document.activeElement } catch (e) { el = null }
2038
+ let hops = 0
2039
+ while (el && hops < 24) {
2040
+ if (el.dataset && el.dataset.groupBlock === name) return true
2041
+ el = el.parentNode || null
2042
+ hops++
2043
+ }
2044
+ return false
2045
+ }
2046
+ /* 悬停到一组:延迟后展开它,并解除别组的钉住(手风琴 —— 同时开两组会看不清)。 */
2047
+ const scheduleGroupOpen = (name) => {
2048
+ clearGroupTimers()
2049
+ if (activeGroup === name) return
2050
+ groupOpenTimer = clock.after(HOVER_OPEN_MS, () => {
2051
+ groupOpenTimer = null
2052
+ if (destroyed || menuEl === null) return
2053
+ pinnedGroup = ''
2054
+ activeGroup = name
2055
+ openMenu(menuAtX, menuAtY, true)
2056
+ })
2057
+ }
2058
+ const scheduleGroupClose = (name) => {
2059
+ clearGroupTimers()
2060
+ if (activeGroup !== name) return
2061
+ if (pinnedGroup === name) return // 点开的不随鼠标移开关闭
2062
+ groupCloseTimer = clock.after(HOVER_CLOSE_MS, () => {
2063
+ groupCloseTimer = null
2064
+ if (destroyed || menuEl === null) return
2065
+ if (pinnedGroup === name) return
2066
+ if (focusInsideGroup(name)) return // 键盘用户还在这一组里,别关
2067
+ activeGroup = ''
2068
+ openMenu(menuAtX, menuAtY, true)
2069
+ })
2070
+ }
1990
2071
 
1991
2072
  const closeMenu = () => {
1992
2073
  if (menuEl && menuEl.parentNode) menuEl.parentNode.removeChild(menuEl)
1993
2074
  menuEl = null
1994
2075
  menuButtons = []
1995
2076
  menuFocusAt = -1
2077
+ clearGroupTimers()
1996
2078
  if (menuOff) { menuOff(); menuOff = null }
1997
2079
  }
1998
2080
 
@@ -2175,6 +2257,17 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2175
2257
 
2176
2258
  const aboutDialog = () => {
2177
2259
  const note = WHATS_NEW[VERSION]
2260
+ /* 菜单里那条「版本与更新」已经收掉了(版本号现在直接显示在菜单头部),
2261
+ 所以检查更新必须有新的入口 —— 放在这里,上下文正合适。 */
2262
+ const checkBtn = document.createElement('button')
2263
+ checkBtn.className = 'wisp-dialog-action'
2264
+ checkBtn.setAttribute('type', 'button')
2265
+ checkBtn.textContent = '检查更新'
2266
+ checkBtn.addEventListener('click', (ev) => {
2267
+ ev.preventDefault()
2268
+ closeDialog()
2269
+ showUpdate()
2270
+ })
2178
2271
  return openDialog('about', '关于她', [
2179
2272
  para('DeepSeek娘 —— DSH 界面上的浮动桌宠。她会跟着你的状态换表情:你打字她在忙,你闲着她在晃,你跑完一轮她替你高兴。'),
2180
2273
  para('她的样子照社区规范来:深蓝色渐变长直发、头顶一个大圆环呆毛、两片鲸鳍耳、女仆装。'),
@@ -2182,16 +2275,39 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2182
2275
  para('她还能陪你专注计时:菜单里开一段 25 或 45 分钟,期间她保持"干活中",戳她一下是问还剩多久,到点她替你收工。'),
2183
2276
  para('这一版:' + (note || VERSION)),
2184
2277
  para('形象与设定参考社区项目 Neko3000/deepseek-whalechan(设定文档 CC BY-NC-SA 4.0,原作者 B站 ZipZipPipe、上善无形)。'),
2278
+ checkBtn,
2185
2279
  dialogFoot('非官方同人作品,与 DeepSeek 官方无关', 'v' + VERSION),
2186
2280
  ])
2187
2281
  }
2188
2282
 
2189
- /* 顶层只放"做什么",外观/行为/位置收进可折叠的分组 —— 19 个同级条目等于没有结构。 */
2283
+ /* 菜单的结构 ------------------------------------------------------------
2284
+ 三段:**动作在上**(专注是最常用的,置顶)、**设置在中间**(三个可折叠分组 +
2285
+ 大小滑块)、**信息在下**(动作一览 / 关于她)。每段之间有空行。
2286
+ 分组的标题行直接显示当前状态(皮肤名 / 几个开关开着 / 哪个角落),
2287
+ 所以收起时也知道里面是什么 —— 这是它和"一个写着外观的按钮"的区别。 */
2288
+ const onToggleCount = () => TOGGLES.filter((t) => cfg[t.key] === true).length
2289
+ const currentCorner = () => {
2290
+ const bw = boxW()
2291
+ const bh = boxH()
2292
+ const hit = CORNERS.find((c) => {
2293
+ const want = c.at(vw(), vh(), bw, bh, cfg)
2294
+ return Math.abs(want.x - coords.x) < 2 && Math.abs(want.y - coords.y) < 2
2295
+ })
2296
+ return hit ? hit.label : '自由'
2297
+ }
2190
2298
  const menuItems = () => [
2191
- { label: '关于她…', run: () => aboutDialog() },
2192
- { label: '动作一览…', run: () => actionsDialog() },
2299
+ { kind: 'head' },
2300
+ { kind: 'sep' },
2301
+ /* 专注:最常用的动作,放最上面;进行中时这一行本身就是状态 + 结束 */
2302
+ ...(focusUntil > 0
2303
+ ? [{ label: '专注中 · 还剩 ' + Math.max(1, Math.ceil(focusLeftMs() / 60000)) + ' 分钟', run: () => stopFocus(false), accent: true }]
2304
+ : [
2305
+ { label: '专注 ' + cfg.focusMinutes + ' 分钟', run: () => startFocus(cfg.focusMinutes), accent: true },
2306
+ { label: '专注 45 分钟', run: () => startFocus(45) },
2307
+ ]),
2308
+ { kind: 'sep' },
2193
2309
  {
2194
- group: '外观',
2310
+ group: '外观', summary: skinLabel(activeSkin),
2195
2311
  children: [
2196
2312
  ...SKIN_IDS.map((id) => ({
2197
2313
  label: (id === activeSkin ? '✓ ' : ' ') + skinLabel(id),
@@ -2208,26 +2324,27 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2208
2324
  },
2209
2325
  {
2210
2326
  group: '行为',
2327
+ summary: onToggleCount() + '/' + TOGGLES.length + ' 开',
2211
2328
  children: [
2212
2329
  { label: '说句话', run: () => { wake(true); say(pick(LINES.click)) } },
2213
2330
  { label: '睡一会儿', run: () => setMood('sleep') },
2214
- { label: '专注 ' + cfg.focusMinutes + ' 分钟', run: () => startFocus(cfg.focusMinutes) },
2215
- { label: '专注 45 分钟', run: () => startFocus(45) },
2216
- /* 只有专注中才出现"结束专注" —— 平时的菜单不该多一个按不动的条目 */
2217
- ...(focusUntil > 0 ? [{ label: '结束专注', run: () => stopFocus(false) }] : []),
2218
- ...TOGGLES.map((t) => ({
2219
- label: (cfg[t.key] === true ? '✓ ' : ' ') + t.label,
2220
- run: () => applyConfig({ [t.key]: cfg[t.key] !== true }),
2221
- })),
2331
+ /* 开关是**真正的开关行**:带 aria-checked,点完菜单不关(就在原地更新) */
2332
+ ...TOGGLES.map((t) => ({ kind: 'switch', key: t.key, label: t.label })),
2222
2333
  ],
2223
2334
  },
2224
2335
  {
2225
2336
  group: '位置',
2226
- children: CORNERS.map((c) => ({ label: c.label, run: () => goCorner(c.label) })),
2337
+ summary: currentCorner(),
2338
+ children: [{ kind: 'corners' }],
2227
2339
  },
2228
- { label: '版本与更新', run: () => showUpdate() },
2340
+ { kind: 'slider' },
2341
+ { kind: 'sep' },
2342
+ { label: '动作一览…', run: () => actionsDialog() },
2343
+ { label: '关于她…', run: () => aboutDialog() },
2344
+ { kind: 'sep' },
2229
2345
  /* 一个开关,不是一个单向动作:从迷你按钮右键打开菜单时,它必须能把她叫回来。 */
2230
- { label: hidden ? '回来吧' : '先躲起来', run: () => setHidden(!hidden) },
2346
+ { label: hidden ? '回来吧' : '先躲起来', run: () => setHidden(!hidden), muted: true },
2347
+ { kind: 'hint' },
2231
2348
  ]
2232
2349
 
2233
2350
  /* ---- 菜单的键盘驱动 ----
@@ -2266,7 +2383,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2266
2383
  const openMenu = (x, y, keepGroup) => {
2267
2384
  menuAtX = x
2268
2385
  menuAtY = y
2269
- if (keepGroup !== true) activeGroup = ''
2386
+ if (keepGroup !== true) { activeGroup = ''; pinnedGroup = '' }
2270
2387
  closeMenu()
2271
2388
  stopGlide()
2272
2389
  const items = menuItems()
@@ -2275,13 +2392,16 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2275
2392
  menu.className = 'wisp-menu'
2276
2393
  menu.setAttribute('role', 'menu')
2277
2394
  menu.style.position = 'absolute'
2278
- let heads = 0
2279
- const addItem = (spec, sub) => {
2395
+ let extras = 0
2396
+ /* 动作行:点了就执行、然后关菜单。 */
2397
+ const actionRow = (spec, into) => {
2280
2398
  const btn = document.createElement('button')
2281
2399
  btn.className = 'wisp-menu-item'
2282
2400
  btn.setAttribute('type', 'button')
2283
2401
  btn.setAttribute('role', 'menuitem')
2284
2402
  btn.setAttribute('tabindex', '-1')
2403
+ if (spec.accent === true) btn.dataset.accent = 'true'
2404
+ if (spec.muted === true) btn.dataset.muted = 'true'
2285
2405
  btn.textContent = spec.label
2286
2406
  // 菜单自己吞掉 pointerdown,否则会被下面那个"点外面就关"的监听判成外部点击
2287
2407
  btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
@@ -2290,26 +2410,202 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2290
2410
  closeMenu()
2291
2411
  try { spec.run() } catch (err) { /* 单个动作失败不该带塌整个菜单 */ }
2292
2412
  })
2293
- menu.appendChild(btn)
2413
+ into.appendChild(btn)
2294
2414
  menuButtons.push(btn)
2295
2415
  return btn
2296
2416
  }
2417
+
2418
+ /* 开关行:真开关的样子 + role=menuitemcheckbox + aria-checked。
2419
+ 点完**不关菜单** —— 拨开关时菜单消失是最烦人的交互之一。 */
2420
+ const switchRow = (spec, into) => {
2421
+ const on = cfg[spec.key] === true
2422
+ const btn = document.createElement('button')
2423
+ btn.className = 'wisp-menu-item wisp-menu-row'
2424
+ btn.setAttribute('type', 'button')
2425
+ btn.setAttribute('role', 'menuitemcheckbox')
2426
+ btn.setAttribute('aria-checked', on ? 'true' : 'false')
2427
+ btn.setAttribute('tabindex', '-1')
2428
+ btn.dataset.switchKey = spec.key
2429
+ const label = document.createElement('span')
2430
+ label.textContent = spec.label
2431
+ const toggle = document.createElement('span')
2432
+ toggle.className = 'wisp-switch'
2433
+ toggle.dataset.on = on ? 'true' : 'false'
2434
+ toggle.appendChild(document.createElement('i'))
2435
+ btn.appendChild(label)
2436
+ btn.appendChild(toggle)
2437
+ btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
2438
+ btn.addEventListener('click', (ev) => {
2439
+ ev.preventDefault(); ev.stopPropagation()
2440
+ try { applyConfig({ [spec.key]: cfg[spec.key] !== true }) } catch (err) { /* ignore */ }
2441
+ /* 原地重画:开着的那一组保持开着,菜单不跳走 */
2442
+ openMenu(menuAtX, menuAtY, true)
2443
+ })
2444
+ into.appendChild(btn)
2445
+ menuButtons.push(btn)
2446
+ return btn
2447
+ }
2448
+
2449
+ /* 大小滑块:连续量就该直接拖,而不是一次次点"放大一点"。
2450
+ 键盘也能调(←/→ 各 0.25 档)—— 所以它自己是可聚焦的。
2451
+ 替身里没有布局,getBoundingClientRect 全是 0,所以宽度为 0 时用固定跨度兜底;
2452
+ 测试一律走方向键那条路,不依赖像素。 */
2453
+ const sliderRow = (into) => {
2454
+ const SPEC = CONFIG_SPEC.size
2455
+ const row = document.createElement('div')
2456
+ row.className = 'wisp-slider-row'
2457
+ const top = document.createElement('div')
2458
+ top.className = 'wisp-slider-top'
2459
+ const name = document.createElement('span')
2460
+ name.textContent = '大小'
2461
+ const value = document.createElement('span')
2462
+ value.className = 'wisp-slider-value'
2463
+ value.dataset.sliderValue = '1'
2464
+ value.textContent = Math.round(cfg.size * 100) + '%'
2465
+ top.appendChild(name)
2466
+ top.appendChild(value)
2467
+ row.appendChild(top)
2468
+ const track = document.createElement('div')
2469
+ track.className = 'wisp-slider'
2470
+ track.setAttribute('role', 'slider')
2471
+ track.setAttribute('tabindex', '-1')
2472
+ track.setAttribute('aria-label', '大小')
2473
+ track.dataset.slider = '1'
2474
+ track.setAttribute('aria-valuemin', String(SPEC.lo))
2475
+ track.setAttribute('aria-valuemax', String(SPEC.hi))
2476
+ track.setAttribute('aria-valuenow', String(cfg.size))
2477
+ const bg = document.createElement('div')
2478
+ bg.className = 'wisp-slider-track'
2479
+ const fill = document.createElement('div')
2480
+ fill.className = 'wisp-slider-fill'
2481
+ const knob = document.createElement('div')
2482
+ knob.className = 'wisp-slider-knob'
2483
+ track.appendChild(bg)
2484
+ track.appendChild(fill)
2485
+ track.appendChild(knob)
2486
+ const paintSlider = () => {
2487
+ const ratio = clamp((cfg.size - SPEC.lo) / (SPEC.hi - SPEC.lo), 0, 1)
2488
+ fill.style.width = Math.round(ratio * 100) + '%'
2489
+ knob.style.left = Math.round(ratio * 100) + '%'
2490
+ value.textContent = Math.round(cfg.size * 100) + '%'
2491
+ track.setAttribute('aria-valuenow', String(cfg.size))
2492
+ }
2493
+ paintSlider()
2494
+ const setFromClientX = (clientX) => {
2495
+ let rect = { left: 0, width: 0 }
2496
+ try { rect = track.getBoundingClientRect() } catch (e) { rect = { left: 0, width: 0 } }
2497
+ const span = rect.width > 0 ? rect.width : 120
2498
+ const ratio = clamp((clientX - rect.left) / span, 0, 1)
2499
+ const next = Math.round((SPEC.lo + ratio * (SPEC.hi - SPEC.lo)) * 4) / 4
2500
+ applyConfig({ size: next })
2501
+ paintSlider()
2502
+ }
2503
+ track.addEventListener('pointerdown', (ev) => {
2504
+ ev.preventDefault(); ev.stopPropagation()
2505
+ setFromClientX(ev.clientX)
2506
+ const move = (e) => setFromClientX(e.clientX)
2507
+ const up = () => {
2508
+ window.removeEventListener('pointermove', move)
2509
+ window.removeEventListener('pointerup', up)
2510
+ }
2511
+ window.addEventListener('pointermove', move)
2512
+ window.addEventListener('pointerup', up)
2513
+ })
2514
+ row.appendChild(track)
2515
+ into.appendChild(row)
2516
+ menuButtons.push(track)
2517
+ extras++
2518
+ return track
2519
+ }
2520
+
2521
+ /* 四个角落用 2×2 的方位控件,而不是四个文字条目 —— 空间的事用空间的形式表达。 */
2522
+ const cornerGrid = (into) => {
2523
+ const grid = document.createElement('div')
2524
+ grid.className = 'wisp-grid2'
2525
+ for (const corner of CORNERS) {
2526
+ const b = document.createElement('button')
2527
+ b.className = 'wisp-corner'
2528
+ b.setAttribute('type', 'button')
2529
+ b.setAttribute('tabindex', '-1')
2530
+ b.dataset.corner = corner.label
2531
+ b.textContent = corner.label
2532
+ if (corner.label === currentCorner()) b.dataset.current = 'true'
2533
+ b.addEventListener('pointerdown', (ev) => ev.stopPropagation())
2534
+ b.addEventListener('click', (ev) => {
2535
+ ev.preventDefault(); ev.stopPropagation()
2536
+ closeMenu()
2537
+ try { goCorner(corner.label) } catch (err) { /* ignore */ }
2538
+ })
2539
+ grid.appendChild(b)
2540
+ menuButtons.push(b)
2541
+ }
2542
+ into.appendChild(grid)
2543
+ extras++
2544
+ }
2545
+
2546
+ const renderChildren = (children, into) => {
2547
+ for (const child of children) {
2548
+ if (child.kind === 'switch') { switchRow(child, into); continue }
2549
+ if (child.kind === 'corners') { cornerGrid(into); continue }
2550
+ actionRow(child, into)
2551
+ }
2552
+ }
2553
+
2297
2554
  for (let i = 0; i < items.length; i++) {
2298
2555
  const spec = items[i]
2556
+ if (spec.kind === 'head') {
2557
+ /* 头部:她是谁、哪一版、现在什么状态。不可聚焦 —— 它不是动作。 */
2558
+ const head = document.createElement('div')
2559
+ head.className = 'wisp-menu-head'
2560
+ const title = document.createElement('div')
2561
+ title.className = 'wisp-menu-title'
2562
+ title.textContent = 'DeepSeek娘'
2563
+ const subline = document.createElement('div')
2564
+ subline.className = 'wisp-menu-sub'
2565
+ const bits = ['v' + VERSION, skinLabel(activeSkin)]
2566
+ if (focusUntil > 0) bits.push('专注中')
2567
+ subline.textContent = bits.join(' · ')
2568
+ head.appendChild(title)
2569
+ head.appendChild(subline)
2570
+ menu.appendChild(head)
2571
+ extras++
2572
+ continue
2573
+ }
2574
+ if (spec.kind === 'sep') {
2575
+ const sep = document.createElement('div')
2576
+ sep.className = 'wisp-menu-sep'
2577
+ menu.appendChild(sep)
2578
+ extras++
2579
+ continue
2580
+ }
2581
+ if (spec.kind === 'hint') {
2582
+ const hint = document.createElement('div')
2583
+ hint.className = 'wisp-menu-hint'
2584
+ hint.textContent = '↑↓ 选择 · ←→ 调大小 · Esc 关闭'
2585
+ menu.appendChild(hint)
2586
+ extras++
2587
+ continue
2588
+ }
2589
+ if (spec.kind === 'slider') { sliderRow(menu); continue }
2299
2590
  if (spec.header !== undefined) {
2300
2591
  // 小标题是分组标签,不是菜单项 —— 不能被聚焦,否则键盘用户会停在一个按不动的东西上
2301
2592
  const head = document.createElement('div')
2302
2593
  head.className = 'wisp-menu-head'
2303
2594
  head.textContent = spec.header
2304
2595
  menu.appendChild(head)
2305
- heads++
2596
+ extras++
2306
2597
  continue
2307
2598
  }
2308
2599
  if (spec.group !== undefined) {
2309
- /* 折叠分组:一行标题,点一下在原地展开/收起(重开同一个坐标,不是新开一个菜单)。 */
2600
+ /* 折叠分组:标题行 + (展开时的)子项,**包在同一个悬停区里**。
2601
+ 这是悬停展开能做对的前提 —— 从标题移到子项不该算"移开",
2602
+ 否则鼠标一动子菜单就没了(那是这类交互最常见的翻车点)。 */
2310
2603
  const open = activeGroup === spec.group
2604
+ const block = document.createElement('div')
2605
+ block.className = 'wisp-menu-group'
2606
+ block.dataset.groupBlock = spec.group
2311
2607
  const btn = document.createElement('button')
2312
- btn.className = 'wisp-menu-item'
2608
+ btn.className = 'wisp-menu-item wisp-menu-row'
2313
2609
  btn.setAttribute('type', 'button')
2314
2610
  btn.setAttribute('role', 'menuitem')
2315
2611
  btn.setAttribute('tabindex', '-1')
@@ -2319,44 +2615,58 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2319
2615
  /* 文字直接写在按钮上,不拆成子 span:真 DOM 会汇总子节点的 textContent,
2320
2616
  但简化 DOM(预检的替身)不会 —— 依赖那种汇总会让分组名在替身里变成空串。 */
2321
2617
  btn.textContent = (open ? '▾ ' : '▸ ') + spec.group
2618
+ if (spec.summary !== undefined) {
2619
+ const sum = document.createElement('span')
2620
+ sum.className = 'wisp-menu-sum'
2621
+ sum.dataset.summary = '1'
2622
+ sum.textContent = spec.summary
2623
+ btn.appendChild(sum)
2624
+ }
2322
2625
  btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
2323
2626
  btn.addEventListener('click', (ev) => {
2324
2627
  ev.preventDefault(); ev.stopPropagation()
2325
- activeGroup = open ? '' : spec.group
2628
+ if (activeGroup === spec.group && pinnedGroup === spec.group) {
2629
+ /* 已经点开过:再点一次收起 */
2630
+ pinnedGroup = ''
2631
+ activeGroup = ''
2632
+ } else {
2633
+ /* 点开 = 钉住:之后鼠标移开也不关(键盘、触屏、以及"我就想它开着"都靠这条) */
2634
+ pinnedGroup = spec.group
2635
+ activeGroup = spec.group
2636
+ }
2326
2637
  openMenu(menuAtX, menuAtY, true)
2327
2638
  })
2328
- menu.appendChild(btn)
2639
+ /* 悬停展开 / 移开收起。pointerType 不为 mouse 的一律不管 ——
2640
+ 触屏没有悬停,不判这个的话手指一碰就会莫名其妙展开。 */
2641
+ const isMouse = (ev) => !(ev && ev.pointerType && ev.pointerType !== 'mouse')
2642
+ block.addEventListener('pointerenter', (ev) => {
2643
+ if (!isMouse(ev)) return
2644
+ scheduleGroupOpen(spec.group)
2645
+ })
2646
+ block.addEventListener('pointerleave', (ev) => {
2647
+ if (!isMouse(ev)) return
2648
+ scheduleGroupClose(spec.group)
2649
+ })
2650
+ block.appendChild(btn)
2651
+ menu.appendChild(block)
2329
2652
  menuButtons.push(btn)
2330
2653
  if (!open) continue
2331
2654
  const wrap = document.createElement('div')
2332
2655
  wrap.className = 'wisp-menu-sub'
2333
- for (const child of spec.children) {
2334
- const cb = document.createElement('button')
2335
- cb.className = 'wisp-menu-item'
2336
- cb.setAttribute('type', 'button')
2337
- cb.setAttribute('role', 'menuitem')
2338
- cb.setAttribute('tabindex', '-1')
2339
- cb.textContent = child.label
2340
- cb.addEventListener('pointerdown', (ev) => ev.stopPropagation())
2341
- cb.addEventListener('click', (ev) => {
2342
- ev.preventDefault(); ev.stopPropagation()
2343
- closeMenu()
2344
- try { child.run() } catch (err) { /* 同上 */ }
2345
- })
2346
- wrap.appendChild(cb)
2347
- menuButtons.push(cb)
2348
- }
2349
- menu.appendChild(wrap)
2656
+ renderChildren(spec.children, wrap)
2657
+ /* 子项进 block(悬停区),不是直接进 menu */
2658
+ block.appendChild(wrap)
2350
2659
  continue
2351
2660
  }
2352
- addItem(spec, false)
2661
+ actionRow(spec, menu)
2353
2662
  }
2663
+
2354
2664
  // 先挂上去再定位:定位要用最终尺寸,而尺寸只有渲染后才知道
2355
2665
  layer.appendChild(menu)
2356
2666
  menuEl = menu
2357
2667
  const w = MENU_W
2358
2668
  // 高度只用于夹取落点,所以按**实际渲染出来的行数**估算即可(假 DOM 里没有布局可测)
2359
- const hs = MENU_PAD + heads * 22 + menuButtons.length * MENU_ITEM_H
2669
+ const hs = MENU_PAD + extras * 24 + menuButtons.length * MENU_ITEM_H
2360
2670
  menu.style.left = Math.round(clamp(x, 8, Math.max(8, vw() - w - 8))) + 'px'
2361
2671
  menu.style.top = Math.round(clamp(y, 8, Math.max(8, vh() - hs - 8))) + 'px'
2362
2672
 
@@ -2368,6 +2678,28 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2368
2678
  return
2369
2679
  }
2370
2680
  if (menuButtons.length === 0) return
2681
+ /* 焦点在滑块上:←/→ 调大小,不移动焦点(这是滑块的常规键盘交互)。 */
2682
+ const focused = menuFocusAt >= 0 ? menuButtons[menuFocusAt] : null
2683
+ if (focused && focused.dataset && focused.dataset.slider === '1'
2684
+ && (ev.key === 'ArrowLeft' || ev.key === 'ArrowRight')) {
2685
+ if (typeof ev.preventDefault === 'function') ev.preventDefault()
2686
+ const step = ev.shiftKey ? 1 : 0.25
2687
+ applyConfig({ size: cfg.size + (ev.key === 'ArrowRight' ? step : -step) })
2688
+ /* 只更新滑块自身,不重画整张菜单 —— 焦点和展开状态都不动 */
2689
+ try {
2690
+ const SPEC = CONFIG_SPEC.size
2691
+ const ratio = clamp((cfg.size - SPEC.lo) / (SPEC.hi - SPEC.lo), 0, 1)
2692
+ const knob = focused.querySelector('.wisp-slider-knob')
2693
+ const fill = focused.querySelector('.wisp-slider-fill')
2694
+ if (knob) knob.style.left = Math.round(ratio * 100) + '%'
2695
+ if (fill) fill.style.width = Math.round(ratio * 100) + '%'
2696
+ focused.setAttribute('aria-valuenow', String(cfg.size))
2697
+ const row = focused.parentNode
2698
+ const label = row && row.querySelector ? row.querySelector('[data-slider-value]') : null
2699
+ if (label) label.textContent = Math.round(cfg.size * 100) + '%'
2700
+ } catch (err) { /* 画不出来也不该挡住改大小 */ }
2701
+ return
2702
+ }
2371
2703
  let at = menuFocusAt
2372
2704
  if (ev.key === 'ArrowDown') at = at < 0 ? 0 : at + 1
2373
2705
  else if (ev.key === 'ArrowUp') at = at < 0 ? menuButtons.length - 1 : at - 1