dsh-wisp 1.27.0 → 1.28.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
@@ -448,6 +448,61 @@ node tools/audit-log.mjs --confirm <manifest> # 生成后:并入代理登
448
448
 
449
449
  ## 变更
450
450
 
451
+ ### 1.28.0
452
+
453
+ - **菜单不再是 19 个同级条目。** 原来 6 个动作 + 4 个开关 + 4 个角落 + 5 套皮肤全平铺,
454
+ 只有三行**不可点**的小标题当分区 —— 那不是结构,是排版。
455
+
456
+ 现在顶层 **7 项**,其余收进**可折叠分组**(点一下在原地展开/收起,带
457
+ `aria-expanded`):
458
+
459
+ ```
460
+ 关于她…
461
+ 动作一览…
462
+ ▸ 外观 5 套皮肤 + 放大 / 缩小
463
+ ▸ 行为 说句话 / 睡一会儿 / 自己溜达 / 跟随状态 / 跑完撒花 / 饿了会说话
464
+ ▸ 位置 四个角落
465
+ 版本与更新
466
+ 先躲起来
467
+ ```
468
+
469
+ 新手势打开菜单时分组一律收起(**展开动作不能被自己的重开清掉** —— 这条写错过一次:
470
+ 清状态原来放在 `closeMenu()` 里,而"在原地重开"正是先 close 再 open,于是刚展开的分组
471
+ 立刻被自己收回去)。
472
+
473
+ - **两个真弹窗**(不再是塞进菜单的长条目):
474
+
475
+ - **「关于她」** —— 她是谁、为什么长这样(社区规范:深蓝渐变长直发 / 大圆环呆毛 /
476
+ 鲸鳍耳 / 女仆装)、白米饭的来由、这一版做了什么、版本号,以及"非官方同人、与官方无关"
477
+ 和社区项目的出处。**介绍写进插件里**,别人装完不用翻仓库;
478
+ - **「动作一览」** —— 当前皮肤八个动作的缩略图 + 中文名,**点一下就让她当场做给你看**
479
+ (先关弹窗再摆姿势,否则弹窗正好盖着她)。预览用一个到期时间挡住稳态轮询
480
+ (`previewUntil`),所以刚摆的表情不会在同一轮里被算回去。
481
+
482
+ 三种关法:**×** / **Esc** / **点背景**。关掉即从 DOM 移除,不常驻、不参与轮询。
483
+
484
+ - **修掉两个真 bug**:
485
+
486
+ - 文档级 Esc 监听原本每次开弹窗都挂一个、而且**摘不掉**(清理函数是个空壳)——
487
+ 改成全程只有一个,随实例销毁;
488
+ - 点预览格或点背景关掉弹窗后**焦点掉进 `<body>`** ✗(下一按 Enter 打不开菜单了)。
489
+ 现在关弹窗一律把焦点交还给她 —— 这条是**真浏览器冒烟**抓到的,假 DOM 看不出来。
490
+
491
+ - 预检 400 → **410 项**,冒烟 46 → **53 项**(真浏览器里新增:分组能展开、展开后菜单仍在
492
+ 视口内、关于弹窗真的在屏幕上、动作一览八张图**真的解码完成 8/8**、点格子会关弹窗并摆姿势)。
493
+
494
+ - 这一轮踩的坑(都记在这里,免得下次重演):
495
+ 1. 简化 DOM **不会汇总子节点的 `textContent`** —— 分组名、弹窗段落、页脚都因此读成空串,
496
+ 改成"文本写在带类名的元素上";
497
+ 2. 替身的 `find()` / `all()` **会把已移除的节点也返回**,判断"弹窗关没关"必须显式认
498
+ `removed`;
499
+ 3. 替身的 `all('wisp-menu-item')` 是**类名精确匹配**,多挂一个类就选不中了 ——
500
+ 分组标记因此改放 `dataset`;
501
+ 4. 测试里用了文件后半部分才定义的 helper(`ctxEvent`)→ TDZ,把整段套件截断过一次;
502
+ 5. 冒烟里新插入的检查放在键盘段**之前**会扰动它依赖的焦点状态 → 挪到键盘段之后。
503
+
504
+ ### 1.27.0
505
+
451
506
  ### 1.27.0
452
507
 
453
508
  - **第八个情绪 `eat`:干饭。** 五套皮肤各一张,双手捧着一碗白米饭,眯眼笑。
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.27.0'
54
+ const VERSION = '1.28.0'
55
55
 
56
56
  /* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
57
57
  忘了更新就会红,不会静默过期。 */
@@ -68,6 +68,7 @@ const WHATS_NEW = {
68
68
  '1.25.1': '内容与 1.25.0 相同,重新发布以修好安装源。',
69
69
  '1.26.0': '检查更新同时读 npm 和 GitHub,取更高的那个版本。',
70
70
  '1.27.0': '她饿了会捧出一碗白米饭,还有第八个表情。',
71
+ '1.28.0': '菜单分成折叠分组,另外多了「关于她」和「动作一览」两个弹窗。',
71
72
  }
72
73
 
73
74
  /* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
@@ -615,6 +616,22 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
615
616
  .wisp-tab img{width:100%;height:100%;object-fit:contain;object-position:bottom center;pointer-events:none}
616
617
  @keyframes wisp-menu{0%{opacity:0;transform:scale(.94)}100%{opacity:1;transform:scale(1)}}
617
618
  @keyframes wisp-say{0%{opacity:0;transform:translate(-50%,-100%) translateY(6px) scale(.94)}12%{opacity:1;transform:translate(-50%,-100%) translateY(0) scale(1)}82%{opacity:1;transform:translate(-50%,-100%) translateY(0) scale(1)}100%{opacity:0;transform:translate(-50%,-100%) translateY(-6px) scale(.98)}}
619
+ .wisp-menu-item[data-group]{display:flex;align-items:center;justify-content:space-between;gap:10px}
620
+ .wisp-menu-caret{opacity:.55;font-size:10px}
621
+ .wisp-menu-sub{padding-left:10px}
622
+ .wisp-menu-sub .wisp-menu-item{padding-left:12px;opacity:.94}
623
+ .wisp-dialog-backdrop{position:absolute;inset:0;background:rgba(6,12,26,.34);pointer-events:auto;animation:wisp-menu .16s ease}
624
+ .wisp-dialog{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:min(340px,calc(100vw - 28px));max-height:min(440px,calc(100vh - 36px));overflow:auto;padding:14px 14px 10px;border-radius:14px;font-size:12.5px;line-height:1.6;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 18px 50px rgba(0,0,0,.34);-webkit-backdrop-filter:blur(16px) saturate(160%);backdrop-filter:blur(16px) saturate(160%);pointer-events:auto;animation:wisp-menu .16s ease}
625
+ .wisp-dialog-title{font-size:13.5px;font-weight:600;margin:0 22px 8px 0}
626
+ .wisp-dialog p,.wisp-dialog .wisp-dialog-p{margin:0 0 8px}
627
+ .wisp-dialog-x{position:absolute;right:8px;top:7px;width:22px;height:22px;border:0;border-radius:6px;background:none;color:inherit;font:inherit;font-size:15px;line-height:1;cursor:pointer;opacity:.7}
628
+ .wisp-dialog-x:hover{opacity:1;background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.22))}
629
+ .wisp-dialog-foot{display:flex;justify-content:space-between;gap:10px;margin-top:8px;padding-top:8px;border-top:1px solid var(--dsw-alias-border-l1,var(--wisp-say-line));font-size:11px;opacity:.72}
630
+ .wisp-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin:2px 0 6px}
631
+ .wisp-cell{display:flex;flex-direction:column;align-items:center;gap:2px;padding:4px 2px 5px;border:1px solid transparent;border-radius:9px;background:none;color:inherit;font:inherit;font-size:10.5px;cursor:pointer}
632
+ .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))}
633
+ .wisp-cell img{width:100%;height:52px;object-fit:contain;object-position:bottom center;pointer-events:none}
634
+ .wisp-credits{font-size:11px;opacity:.8;margin:6px 0 0}
618
635
  @media (prefers-reduced-motion:reduce){.wisp-body{animation:none!important}}`
619
636
 
620
637
  const installStyles = (styles) => {
@@ -669,6 +686,17 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
669
686
  return out
670
687
  }
671
688
 
689
+ /* 经 window 取 Image,而不是裸的全局名:这个半包在壳里由 new Function 求值,
690
+ 裸名解析到的是**外层的全局作用域**,那里未必有 Image —— 实测在预检环境里就是
691
+ `TypeError: Image is not a constructor`(点动作预览换到新精灵图键时才踩出来)。
692
+ 这个文件读时钟、读性能也是同样的规矩:一切经 window。
693
+ 两处用它:命中蒙版、以及换皮肤后的预热。 */
694
+ const imageCtor = () => {
695
+ if (typeof window !== 'undefined' && window && typeof window.Image === 'function') return window.Image
696
+ if (typeof Image === 'function') return Image
697
+ return null
698
+ }
699
+
672
700
  const buildMask = (key, url) => {
673
701
  if (masks[key]) return
674
702
  try {
@@ -678,7 +706,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
678
706
  if (!ctx || typeof ctx.drawImage !== 'function' || typeof ctx.getImageData !== 'function') return
679
707
  canvas.width = MASK_W
680
708
  canvas.height = MASK_H
681
- const probe = new Image()
709
+ const ImageCtor = imageCtor()
710
+ if (ImageCtor === null) return
711
+ const probe = new ImageCtor()
682
712
  probe.onload = () => {
683
713
  try {
684
714
  ctx.drawImage(probe, 0, 0, MASK_W, MASK_H)
@@ -1519,6 +1549,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1519
1549
  // 否则刚摆出的表情在同一个 poll 周期里就被 setMood('idle') 抹掉了。
1520
1550
  if (mood === 'sleep' || mood === 'happy' || mood === 'poked' || mood === 'proud' || mood === 'care'
1521
1551
  || mood === 'eat' || mood === 'worried') return
1552
+ /* 动作预览期间也要挡住稳态赋值,否则刚摆的表情在同一轮里被算回去 */
1553
+ if (nowMs() < previewUntil) return
1522
1554
  setMood((busy || typing) ? 'alert' : 'idle')
1523
1555
  }
1524
1556
 
@@ -1774,6 +1806,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1774
1806
  const MENU_PAD = 10
1775
1807
  let menuEl = null
1776
1808
  let menuOff = null
1809
+ /* 折叠分组要"在原地重开",所以记住这次的落点 */
1810
+ let menuAtX = 0
1811
+ let menuAtY = 0
1777
1812
 
1778
1813
  const closeMenu = () => {
1779
1814
  if (menuEl && menuEl.parentNode) menuEl.parentNode.removeChild(menuEl)
@@ -1816,43 +1851,201 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1816
1851
  { key: 'hungry', label: '饿了会说话' },
1817
1852
  ]
1818
1853
 
1819
- const menuItems = () => {
1820
- const items = [
1821
- { label: '说句话', run: () => { wake(true); say(pick(LINES.click)) } },
1822
- { label: '放大一点', run: () => applyConfig({ size: cfg.size + 0.5 }) },
1823
- { label: '缩小一点', run: () => applyConfig({ size: cfg.size - 0.5 }) },
1824
- /* 一个开关,不是一个单向动作:从迷你按钮右键打开菜单时,它必须能把她叫回来。 */
1825
- { label: hidden ? '回来吧' : '先躲起来', run: () => setHidden(!hidden) },
1826
- { label: '睡一会儿', run: () => setMood('sleep') },
1827
- { label: '版本与更新', run: () => showUpdate() },
1828
- { header: '行为' },
1829
- ]
1830
- for (const toggle of TOGGLES) {
1831
- const on = cfg[toggle.key] === true
1832
- items.push({
1833
- label: (on ? '✓ ' : ' ') + toggle.label,
1834
- run: () => applyConfig({ [toggle.key]: !on }),
1835
- })
1836
- }
1837
- items.push({ header: '位置' })
1838
- for (const corner of CORNERS) {
1839
- items.push({ label: corner.label, run: () => goCorner(corner.label) })
1840
- }
1841
- items.push({ header: '皮肤' })
1842
- for (const id of SKIN_IDS) {
1843
- items.push({
1844
- label: (id === activeSkin ? '✓ ' : ' ') + skinLabel(id),
1845
- skin: id,
1846
- run: () => {
1847
- if (id === activeSkin) return
1848
- applySkin(id)
1849
- say('换成' + skinLabel(id) + '了。')
1850
- },
1851
- })
1854
+ /* ---- 弹窗 ----------------------------------------------------------------
1855
+ 菜单是"做事"的地方,不是"看东西"的地方:19 个同级条目,谁也找不到谁。
1856
+ 所以顶层只留动作,"她是谁"和"她有哪些动作"放进弹窗 —— 关掉即从 DOM 移除,
1857
+ 不常驻、不参与轮询。 */
1858
+ let dialogEl = null
1859
+ let dialogKind = null
1860
+ let dialogKeyOff = null
1861
+ let activeGroup = ''
1862
+ /* 预览锁:动作预览要摆出某个表情给你看,而稳态轮询每一轮都会把它算回去。
1863
+ 用一个到期时间挡住轮询,比给每个情绪再加一个瞬时标记干净。 */
1864
+ let previewUntil = 0
1865
+ let previewHandle = null
1866
+
1867
+ const closeDialog = () => {
1868
+ if (dialogEl && dialogEl.parentNode) dialogEl.parentNode.removeChild(dialogEl)
1869
+ dialogEl = null
1870
+ dialogKind = null
1871
+ }
1872
+
1873
+ /* 弹窗不抢焦点,所以 Esc 只能靠一个常驻监听 —— 而且必须是**一个**:
1874
+ 每次开弹窗都挂一个新的会越积越多,引用还拿不回来,销毁时摘不掉。 */
1875
+ const onDialogKey = (ev) => {
1876
+ if (ev.key !== 'Escape' || dialogEl === null) return
1877
+ ev.preventDefault()
1878
+ closeDialog()
1879
+ focusHer()
1880
+ }
1881
+ /* 文档级监听:真 document 一定有,但这里仍然判断一下 ——
1882
+ "环境少一个方法"不该让整个插件起不来(预检的替身就少了它)。 */
1883
+ if (typeof document.addEventListener === 'function') {
1884
+ document.addEventListener('keydown', onDialogKey, true)
1885
+ }
1886
+
1887
+ const para = (text) => {
1888
+ const p = document.createElement('p')
1889
+ p.className = 'wisp-dialog-p'
1890
+ p.textContent = text
1891
+ return p
1892
+ }
1893
+
1894
+ const dialogFoot = (left, right) => {
1895
+ const row = document.createElement('div')
1896
+ row.className = 'wisp-dialog-foot'
1897
+ const a = document.createElement('span')
1898
+ /* 两个 span 各带类名:真 DOM 从父节点就能读到汇总文本,
1899
+ 简化 DOM 不能 —— 带类名意味着两边都能定位到这段文字。 */
1900
+ a.className = 'wisp-dialog-foot-l'
1901
+ a.textContent = left
1902
+ const b = document.createElement('span')
1903
+ b.className = 'wisp-dialog-foot-r'
1904
+ b.textContent = right
1905
+ row.appendChild(a)
1906
+ row.appendChild(b)
1907
+ return row
1908
+ }
1909
+
1910
+ const openDialog = (kind, title, rows) => {
1911
+ closeMenu()
1912
+ closeDialog()
1913
+ const back = document.createElement('div')
1914
+ back.className = 'wisp-dialog-backdrop'
1915
+ back.addEventListener('pointerdown', () => { closeDialog(); focusHer() })
1916
+ const card = document.createElement('div')
1917
+ card.className = 'wisp-dialog'
1918
+ card.setAttribute('role', 'dialog')
1919
+ card.setAttribute('aria-modal', 'true')
1920
+ card.setAttribute('aria-label', title)
1921
+ card.addEventListener('pointerdown', (ev) => ev.stopPropagation())
1922
+ const head = document.createElement('div')
1923
+ head.className = 'wisp-dialog-title'
1924
+ head.textContent = title
1925
+ card.appendChild(head)
1926
+ const x = document.createElement('button')
1927
+ x.className = 'wisp-dialog-x'
1928
+ x.setAttribute('type', 'button')
1929
+ x.setAttribute('aria-label', '关闭')
1930
+ x.textContent = '×'
1931
+ x.addEventListener('click', (ev) => { ev.preventDefault(); closeDialog(); focusHer() })
1932
+ card.appendChild(x)
1933
+ for (let i = 0; i < rows.length; i++) card.appendChild(rows[i])
1934
+ back.appendChild(card)
1935
+ layer.appendChild(back)
1936
+ dialogEl = back
1937
+ dialogKind = kind
1938
+ return card
1939
+ }
1940
+
1941
+ /* 八个动作。key 是精灵图名,mood 是运行时该摆的状态 —— 两者不一样:
1942
+ "干活中"的图叫 work,但状态是 alert;"待机"两者都是 idle。 */
1943
+ const ACTION_ROWS = [
1944
+ { key: 'idle', mood: 'idle', label: '待机', say: '平时就是这样。' },
1945
+ { key: 'happy', mood: 'happy', label: '开心', say: '嗯,开心。' },
1946
+ { key: 'sleepy', mood: 'sleep', label: '打盹', say: '困了……' },
1947
+ { key: 'work', mood: 'alert', label: '干活中', say: '在跑呢,别催。' },
1948
+ { key: 'attn', mood: 'attn', label: '有动静', say: '我注意到了。' },
1949
+ { key: 'poked', mood: 'poked', label: '被戳', say: '干嘛。' },
1950
+ { key: 'proud', mood: 'proud', label: '得意', say: '跑完了,不错吧。' },
1951
+ { key: 'eat', mood: 'eat', label: '干饭', say: '先吃一碗。' },
1952
+ ]
1953
+
1954
+ const previewMood = (key) => {
1955
+ const row = ACTION_ROWS.find((r) => r.key === key)
1956
+ if (!row) return false
1957
+ closeDialog()
1958
+ /* 被点掉的那个格子随弹窗一起消失了,焦点必须有人接 —— 否则掉进 <body>。 */
1959
+ focusHer()
1960
+ previewUntil = nowMs() + 6000
1961
+ setMood(row.mood)
1962
+ /* 预览必须**自己有到期时间**:预览的姿势可能在"稳态轮询不许覆盖"的名单里
1963
+ (干饭就在),光靠 previewUntil 挡住轮询的话,锁一过期她就被永久卡在那个表情上。
1964
+ 到点主动交还稳态,之后该是什么由轮询说了算。 */
1965
+ if (previewHandle) clock.cancel(previewHandle)
1966
+ previewHandle = clock.after(6200, () => { setMood('idle') })
1967
+ say(row.say)
1968
+ return true
1969
+ }
1970
+
1971
+ const actionsDialog = () => {
1972
+ const grid = document.createElement('div')
1973
+ grid.className = 'wisp-grid'
1974
+ for (const row of ACTION_ROWS) {
1975
+ const cell = document.createElement('button')
1976
+ cell.className = 'wisp-cell'
1977
+ cell.setAttribute('type', 'button')
1978
+ cell.setAttribute('aria-label', '预览' + row.label)
1979
+ cell.dataset.mood = row.key
1980
+ const im = document.createElement('img')
1981
+ im.setAttribute('alt', '')
1982
+ im.src = resolveSprite(spriteOf(row.key))
1983
+ const cap = document.createElement('span')
1984
+ cap.textContent = row.label
1985
+ cell.appendChild(im)
1986
+ cell.appendChild(cap)
1987
+ cell.addEventListener('click', (ev) => { ev.preventDefault(); previewMood(row.key) })
1988
+ grid.appendChild(cell)
1852
1989
  }
1853
- return items
1990
+ return openDialog('actions', '动作一览', [
1991
+ para('点一个动作,她就当场做给你看。(图都是当前皮肤 ' + skinLabel(activeSkin) + ')'),
1992
+ grid,
1993
+ dialogFoot('共 ' + ACTION_ROWS.length + ' 个动作', '按 Esc 关闭'),
1994
+ ])
1854
1995
  }
1855
1996
 
1997
+ const aboutDialog = () => {
1998
+ const note = WHATS_NEW[VERSION]
1999
+ return openDialog('about', '关于她', [
2000
+ para('DeepSeek娘 —— DSH 界面上的浮动桌宠。她会跟着你的状态换表情:你打字她在忙,你闲着她在晃,你跑完一轮她替你高兴。'),
2001
+ para('她的样子照社区规范来:深蓝色渐变长直发、头顶一个大圆环呆毛、两片鲸鳍耳、女仆装。'),
2002
+ para('白米饭是算力的硬通货 —— 所以她每隔一阵会捧碗出来补点碳水。'),
2003
+ para('这一版:' + (note || VERSION)),
2004
+ para('形象与设定参考社区项目 Neko3000/deepseek-whalechan(设定文档 CC BY-NC-SA 4.0,原作者 B站 ZipZipPipe、上善无形)。'),
2005
+ dialogFoot('非官方同人作品,与 DeepSeek 官方无关', 'v' + VERSION),
2006
+ ])
2007
+ }
2008
+
2009
+ /* 顶层只放"做什么",外观/行为/位置收进可折叠的分组 —— 19 个同级条目等于没有结构。 */
2010
+ const menuItems = () => [
2011
+ { label: '关于她…', run: () => aboutDialog() },
2012
+ { label: '动作一览…', run: () => actionsDialog() },
2013
+ {
2014
+ group: '外观',
2015
+ children: [
2016
+ ...SKIN_IDS.map((id) => ({
2017
+ label: (id === activeSkin ? '✓ ' : ' ') + skinLabel(id),
2018
+ skin: id,
2019
+ run: () => {
2020
+ if (id === activeSkin) return
2021
+ applySkin(id)
2022
+ say('换成' + skinLabel(id) + '了。')
2023
+ },
2024
+ })),
2025
+ { label: '放大一点', run: () => applyConfig({ size: cfg.size + 0.5 }) },
2026
+ { label: '缩小一点', run: () => applyConfig({ size: cfg.size - 0.5 }) },
2027
+ ],
2028
+ },
2029
+ {
2030
+ group: '行为',
2031
+ children: [
2032
+ { label: '说句话', run: () => { wake(true); say(pick(LINES.click)) } },
2033
+ { label: '睡一会儿', run: () => setMood('sleep') },
2034
+ ...TOGGLES.map((t) => ({
2035
+ label: (cfg[t.key] === true ? '✓ ' : ' ') + t.label,
2036
+ run: () => applyConfig({ [t.key]: cfg[t.key] !== true }),
2037
+ })),
2038
+ ],
2039
+ },
2040
+ {
2041
+ group: '位置',
2042
+ children: CORNERS.map((c) => ({ label: c.label, run: () => goCorner(c.label) })),
2043
+ },
2044
+ { label: '版本与更新', run: () => showUpdate() },
2045
+ /* 一个开关,不是一个单向动作:从迷你按钮右键打开菜单时,它必须能把她叫回来。 */
2046
+ { label: hidden ? '回来吧' : '先躲起来', run: () => setHidden(!hidden) },
2047
+ ]
2048
+
1856
2049
  /* ---- 菜单的键盘驱动 ----
1857
2050
  菜单已经声明了 role="menu",没有键盘支持的话,对屏幕阅读器来说比不声明更糟 ——
1858
2051
  它宣布了一个你无法操作的菜单。采用 roving tabindex:只有当前项 tabindex=0,
@@ -1883,7 +2076,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1883
2076
  }
1884
2077
  }
1885
2078
 
1886
- const openMenu = (x, y) => {
2079
+ /* keepGroup:点分组是"在原地重开",得把刚设好的分组带过去。
2080
+ 其他入口都是新手势,一律回到收起状态 —— 所以这个重置放在这里,
2081
+ 而不是 closeMenu 里(放那里会让展开动作把自己清掉)。 */
2082
+ const openMenu = (x, y, keepGroup) => {
2083
+ menuAtX = x
2084
+ menuAtY = y
2085
+ if (keepGroup !== true) activeGroup = ''
1887
2086
  closeMenu()
1888
2087
  stopGlide()
1889
2088
  const items = menuItems()
@@ -1892,16 +2091,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1892
2091
  menu.className = 'wisp-menu'
1893
2092
  menu.setAttribute('role', 'menu')
1894
2093
  menu.style.position = 'absolute'
1895
- for (let i = 0; i < items.length; i++) {
1896
- const spec = items[i]
1897
- if (spec.header !== undefined) {
1898
- // 小标题是分组标签,不是菜单项 —— 不能被聚焦,否则键盘用户会停在一个按不动的东西上
1899
- const head = document.createElement('div')
1900
- head.className = 'wisp-menu-head'
1901
- head.textContent = spec.header
1902
- menu.appendChild(head)
1903
- continue
1904
- }
2094
+ let heads = 0
2095
+ const addItem = (spec, sub) => {
1905
2096
  const btn = document.createElement('button')
1906
2097
  btn.className = 'wisp-menu-item'
1907
2098
  btn.setAttribute('type', 'button')
@@ -1917,14 +2108,71 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1917
2108
  })
1918
2109
  menu.appendChild(btn)
1919
2110
  menuButtons.push(btn)
2111
+ return btn
2112
+ }
2113
+ for (let i = 0; i < items.length; i++) {
2114
+ const spec = items[i]
2115
+ if (spec.header !== undefined) {
2116
+ // 小标题是分组标签,不是菜单项 —— 不能被聚焦,否则键盘用户会停在一个按不动的东西上
2117
+ const head = document.createElement('div')
2118
+ head.className = 'wisp-menu-head'
2119
+ head.textContent = spec.header
2120
+ menu.appendChild(head)
2121
+ heads++
2122
+ continue
2123
+ }
2124
+ if (spec.group !== undefined) {
2125
+ /* 折叠分组:一行标题,点一下在原地展开/收起(重开同一个坐标,不是新开一个菜单)。 */
2126
+ const open = activeGroup === spec.group
2127
+ const btn = document.createElement('button')
2128
+ btn.className = 'wisp-menu-item'
2129
+ btn.setAttribute('type', 'button')
2130
+ btn.setAttribute('role', 'menuitem')
2131
+ btn.setAttribute('tabindex', '-1')
2132
+ btn.setAttribute('aria-expanded', open ? 'true' : 'false')
2133
+ btn.dataset.group = spec.group
2134
+ btn.dataset.open = open ? 'true' : 'false'
2135
+ /* 文字直接写在按钮上,不拆成子 span:真 DOM 会汇总子节点的 textContent,
2136
+ 但简化 DOM(预检的替身)不会 —— 依赖那种汇总会让分组名在替身里变成空串。 */
2137
+ btn.textContent = (open ? '▾ ' : '▸ ') + spec.group
2138
+ btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
2139
+ btn.addEventListener('click', (ev) => {
2140
+ ev.preventDefault(); ev.stopPropagation()
2141
+ activeGroup = open ? '' : spec.group
2142
+ openMenu(menuAtX, menuAtY, true)
2143
+ })
2144
+ menu.appendChild(btn)
2145
+ menuButtons.push(btn)
2146
+ if (!open) continue
2147
+ const wrap = document.createElement('div')
2148
+ wrap.className = 'wisp-menu-sub'
2149
+ for (const child of spec.children) {
2150
+ const cb = document.createElement('button')
2151
+ cb.className = 'wisp-menu-item'
2152
+ cb.setAttribute('type', 'button')
2153
+ cb.setAttribute('role', 'menuitem')
2154
+ cb.setAttribute('tabindex', '-1')
2155
+ cb.textContent = child.label
2156
+ cb.addEventListener('pointerdown', (ev) => ev.stopPropagation())
2157
+ cb.addEventListener('click', (ev) => {
2158
+ ev.preventDefault(); ev.stopPropagation()
2159
+ closeMenu()
2160
+ try { child.run() } catch (err) { /* 同上 */ }
2161
+ })
2162
+ wrap.appendChild(cb)
2163
+ menuButtons.push(cb)
2164
+ }
2165
+ menu.appendChild(wrap)
2166
+ continue
2167
+ }
2168
+ addItem(spec, false)
1920
2169
  }
1921
2170
  // 先挂上去再定位:定位要用最终尺寸,而尺寸只有渲染后才知道
1922
2171
  layer.appendChild(menu)
1923
2172
  menuEl = menu
1924
2173
  const w = MENU_W
1925
- // 高度只用于夹取落点,所以按条目类型估算即可(假 DOM 里没有布局可测)
1926
- let hs = MENU_PAD
1927
- for (const it of items) hs += it.header !== undefined ? 22 : MENU_ITEM_H
2174
+ // 高度只用于夹取落点,所以按**实际渲染出来的行数**估算即可(假 DOM 里没有布局可测)
2175
+ const hs = MENU_PAD + heads * 22 + menuButtons.length * MENU_ITEM_H
1928
2176
  menu.style.left = Math.round(clamp(x, 8, Math.max(8, vw() - w - 8))) + 'px'
1929
2177
  menu.style.top = Math.round(clamp(y, 8, Math.max(8, vh() - hs - 8))) + 'px'
1930
2178
 
@@ -2020,9 +2268,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2020
2268
  /* warm the other sprites after the first frame so mood changes never blink */
2021
2269
  clock.after(1500, () => {
2022
2270
  if (destroyed) return
2271
+ /* 预热其余的精灵图。没有 Image 构造函数的地方(简化 DOM)就跳过,
2272
+ 预热本来就是锦上添花,不该把整只宠物带崩 —— 这里原来硬用裸的 new Image()。 */
2273
+ const ImageCtor = imageCtor()
2274
+ if (ImageCtor === null) return
2023
2275
  for (const key in SPRITES[activeSkin]) {
2024
2276
  if (key === currentSprite) continue
2025
- const pre = new Image()
2277
+ const pre = new ImageCtor()
2026
2278
  pre.src = resolveSprite(spriteOf(key))
2027
2279
  }
2028
2280
  })
@@ -2181,6 +2433,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2181
2433
  setSkin: applySkin,
2182
2434
  showUpdate,
2183
2435
  checkForUpdate,
2436
+ /* 弹窗与折叠分组:菜单结构变了之后,测试要能问"现在开着哪个弹窗 / 哪个分组"。 */
2437
+ openAbout: () => aboutDialog(),
2438
+ openActions: () => actionsDialog(),
2439
+ closeDialog,
2440
+ previewMood,
2441
+ get dialog() { return dialogKind },
2442
+ get menuGroup() { return activeGroup },
2184
2443
  get updateCheck() { return { ...updateCheck } },
2185
2444
  get hidden() { return hidden },
2186
2445
  hide: () => setHidden(true),
@@ -2339,6 +2598,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2339
2598
  bodyEl.removeEventListener('keydown', onBodyKey)
2340
2599
  bodyEl.removeEventListener('contextmenu', onContext)
2341
2600
  closeMenu()
2601
+ closeDialog()
2602
+ if (dialogKeyOff) { dialogKeyOff(); dialogKeyOff = null }
2342
2603
  hideTab()
2343
2604
  if (typeof offTheme === 'function') {
2344
2605
  try { offTheme() } catch (e) { /* ignore */ }