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.
@@ -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
  /* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
@@ -564,6 +565,22 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
564
565
  .wisp-tab img{width:100%;height:100%;object-fit:contain;object-position:bottom center;pointer-events:none}
565
566
  @keyframes wisp-menu{0%{opacity:0;transform:scale(.94)}100%{opacity:1;transform:scale(1)}}
566
567
  @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)}}
568
+ .wisp-menu-item[data-group]{display:flex;align-items:center;justify-content:space-between;gap:10px}
569
+ .wisp-menu-caret{opacity:.55;font-size:10px}
570
+ .wisp-menu-sub{padding-left:10px}
571
+ .wisp-menu-sub .wisp-menu-item{padding-left:12px;opacity:.94}
572
+ .wisp-dialog-backdrop{position:absolute;inset:0;background:rgba(6,12,26,.34);pointer-events:auto;animation:wisp-menu .16s ease}
573
+ .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}
574
+ .wisp-dialog-title{font-size:13.5px;font-weight:600;margin:0 22px 8px 0}
575
+ .wisp-dialog p,.wisp-dialog .wisp-dialog-p{margin:0 0 8px}
576
+ .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}
577
+ .wisp-dialog-x:hover{opacity:1;background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.22))}
578
+ .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}
579
+ .wisp-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin:2px 0 6px}
580
+ .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}
581
+ .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))}
582
+ .wisp-cell img{width:100%;height:52px;object-fit:contain;object-position:bottom center;pointer-events:none}
583
+ .wisp-credits{font-size:11px;opacity:.8;margin:6px 0 0}
567
584
  @media (prefers-reduced-motion:reduce){.wisp-body{animation:none!important}}`
568
585
 
569
586
  const installStyles = (styles) => {
@@ -618,6 +635,17 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
618
635
  return out
619
636
  }
620
637
 
638
+ /* 经 window 取 Image,而不是裸的全局名:这个半包在壳里由 new Function 求值,
639
+ 裸名解析到的是**外层的全局作用域**,那里未必有 Image —— 实测在预检环境里就是
640
+ `TypeError: Image is not a constructor`(点动作预览换到新精灵图键时才踩出来)。
641
+ 这个文件读时钟、读性能也是同样的规矩:一切经 window。
642
+ 两处用它:命中蒙版、以及换皮肤后的预热。 */
643
+ const imageCtor = () => {
644
+ if (typeof window !== 'undefined' && window && typeof window.Image === 'function') return window.Image
645
+ if (typeof Image === 'function') return Image
646
+ return null
647
+ }
648
+
621
649
  const buildMask = (key, url) => {
622
650
  if (masks[key]) return
623
651
  try {
@@ -627,7 +655,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
627
655
  if (!ctx || typeof ctx.drawImage !== 'function' || typeof ctx.getImageData !== 'function') return
628
656
  canvas.width = MASK_W
629
657
  canvas.height = MASK_H
630
- const probe = new Image()
658
+ const ImageCtor = imageCtor()
659
+ if (ImageCtor === null) return
660
+ const probe = new ImageCtor()
631
661
  probe.onload = () => {
632
662
  try {
633
663
  ctx.drawImage(probe, 0, 0, MASK_W, MASK_H)
@@ -1468,6 +1498,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1468
1498
  // 否则刚摆出的表情在同一个 poll 周期里就被 setMood('idle') 抹掉了。
1469
1499
  if (mood === 'sleep' || mood === 'happy' || mood === 'poked' || mood === 'proud' || mood === 'care'
1470
1500
  || mood === 'eat' || mood === 'worried') return
1501
+ /* 动作预览期间也要挡住稳态赋值,否则刚摆的表情在同一轮里被算回去 */
1502
+ if (nowMs() < previewUntil) return
1471
1503
  setMood((busy || typing) ? 'alert' : 'idle')
1472
1504
  }
1473
1505
 
@@ -1723,6 +1755,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1723
1755
  const MENU_PAD = 10
1724
1756
  let menuEl = null
1725
1757
  let menuOff = null
1758
+ /* 折叠分组要"在原地重开",所以记住这次的落点 */
1759
+ let menuAtX = 0
1760
+ let menuAtY = 0
1726
1761
 
1727
1762
  const closeMenu = () => {
1728
1763
  if (menuEl && menuEl.parentNode) menuEl.parentNode.removeChild(menuEl)
@@ -1765,43 +1800,201 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1765
1800
  { key: 'hungry', label: '饿了会说话' },
1766
1801
  ]
1767
1802
 
1768
- const menuItems = () => {
1769
- const items = [
1770
- { label: '说句话', run: () => { wake(true); say(pick(LINES.click)) } },
1771
- { label: '放大一点', run: () => applyConfig({ size: cfg.size + 0.5 }) },
1772
- { label: '缩小一点', run: () => applyConfig({ size: cfg.size - 0.5 }) },
1773
- /* 一个开关,不是一个单向动作:从迷你按钮右键打开菜单时,它必须能把她叫回来。 */
1774
- { label: hidden ? '回来吧' : '先躲起来', run: () => setHidden(!hidden) },
1775
- { label: '睡一会儿', run: () => setMood('sleep') },
1776
- { label: '版本与更新', run: () => showUpdate() },
1777
- { header: '行为' },
1778
- ]
1779
- for (const toggle of TOGGLES) {
1780
- const on = cfg[toggle.key] === true
1781
- items.push({
1782
- label: (on ? '✓ ' : ' ') + toggle.label,
1783
- run: () => applyConfig({ [toggle.key]: !on }),
1784
- })
1785
- }
1786
- items.push({ header: '位置' })
1787
- for (const corner of CORNERS) {
1788
- items.push({ label: corner.label, run: () => goCorner(corner.label) })
1789
- }
1790
- items.push({ header: '皮肤' })
1791
- for (const id of SKIN_IDS) {
1792
- items.push({
1793
- label: (id === activeSkin ? '✓ ' : ' ') + skinLabel(id),
1794
- skin: id,
1795
- run: () => {
1796
- if (id === activeSkin) return
1797
- applySkin(id)
1798
- say('换成' + skinLabel(id) + '了。')
1799
- },
1800
- })
1803
+ /* ---- 弹窗 ----------------------------------------------------------------
1804
+ 菜单是"做事"的地方,不是"看东西"的地方:19 个同级条目,谁也找不到谁。
1805
+ 所以顶层只留动作,"她是谁"和"她有哪些动作"放进弹窗 —— 关掉即从 DOM 移除,
1806
+ 不常驻、不参与轮询。 */
1807
+ let dialogEl = null
1808
+ let dialogKind = null
1809
+ let dialogKeyOff = null
1810
+ let activeGroup = ''
1811
+ /* 预览锁:动作预览要摆出某个表情给你看,而稳态轮询每一轮都会把它算回去。
1812
+ 用一个到期时间挡住轮询,比给每个情绪再加一个瞬时标记干净。 */
1813
+ let previewUntil = 0
1814
+ let previewHandle = null
1815
+
1816
+ const closeDialog = () => {
1817
+ if (dialogEl && dialogEl.parentNode) dialogEl.parentNode.removeChild(dialogEl)
1818
+ dialogEl = null
1819
+ dialogKind = null
1820
+ }
1821
+
1822
+ /* 弹窗不抢焦点,所以 Esc 只能靠一个常驻监听 —— 而且必须是**一个**:
1823
+ 每次开弹窗都挂一个新的会越积越多,引用还拿不回来,销毁时摘不掉。 */
1824
+ const onDialogKey = (ev) => {
1825
+ if (ev.key !== 'Escape' || dialogEl === null) return
1826
+ ev.preventDefault()
1827
+ closeDialog()
1828
+ focusHer()
1829
+ }
1830
+ /* 文档级监听:真 document 一定有,但这里仍然判断一下 ——
1831
+ "环境少一个方法"不该让整个插件起不来(预检的替身就少了它)。 */
1832
+ if (typeof document.addEventListener === 'function') {
1833
+ document.addEventListener('keydown', onDialogKey, true)
1834
+ }
1835
+
1836
+ const para = (text) => {
1837
+ const p = document.createElement('p')
1838
+ p.className = 'wisp-dialog-p'
1839
+ p.textContent = text
1840
+ return p
1841
+ }
1842
+
1843
+ const dialogFoot = (left, right) => {
1844
+ const row = document.createElement('div')
1845
+ row.className = 'wisp-dialog-foot'
1846
+ const a = document.createElement('span')
1847
+ /* 两个 span 各带类名:真 DOM 从父节点就能读到汇总文本,
1848
+ 简化 DOM 不能 —— 带类名意味着两边都能定位到这段文字。 */
1849
+ a.className = 'wisp-dialog-foot-l'
1850
+ a.textContent = left
1851
+ const b = document.createElement('span')
1852
+ b.className = 'wisp-dialog-foot-r'
1853
+ b.textContent = right
1854
+ row.appendChild(a)
1855
+ row.appendChild(b)
1856
+ return row
1857
+ }
1858
+
1859
+ const openDialog = (kind, title, rows) => {
1860
+ closeMenu()
1861
+ closeDialog()
1862
+ const back = document.createElement('div')
1863
+ back.className = 'wisp-dialog-backdrop'
1864
+ back.addEventListener('pointerdown', () => { closeDialog(); focusHer() })
1865
+ const card = document.createElement('div')
1866
+ card.className = 'wisp-dialog'
1867
+ card.setAttribute('role', 'dialog')
1868
+ card.setAttribute('aria-modal', 'true')
1869
+ card.setAttribute('aria-label', title)
1870
+ card.addEventListener('pointerdown', (ev) => ev.stopPropagation())
1871
+ const head = document.createElement('div')
1872
+ head.className = 'wisp-dialog-title'
1873
+ head.textContent = title
1874
+ card.appendChild(head)
1875
+ const x = document.createElement('button')
1876
+ x.className = 'wisp-dialog-x'
1877
+ x.setAttribute('type', 'button')
1878
+ x.setAttribute('aria-label', '关闭')
1879
+ x.textContent = '×'
1880
+ x.addEventListener('click', (ev) => { ev.preventDefault(); closeDialog(); focusHer() })
1881
+ card.appendChild(x)
1882
+ for (let i = 0; i < rows.length; i++) card.appendChild(rows[i])
1883
+ back.appendChild(card)
1884
+ layer.appendChild(back)
1885
+ dialogEl = back
1886
+ dialogKind = kind
1887
+ return card
1888
+ }
1889
+
1890
+ /* 八个动作。key 是精灵图名,mood 是运行时该摆的状态 —— 两者不一样:
1891
+ "干活中"的图叫 work,但状态是 alert;"待机"两者都是 idle。 */
1892
+ const ACTION_ROWS = [
1893
+ { key: 'idle', mood: 'idle', label: '待机', say: '平时就是这样。' },
1894
+ { key: 'happy', mood: 'happy', label: '开心', say: '嗯,开心。' },
1895
+ { key: 'sleepy', mood: 'sleep', label: '打盹', say: '困了……' },
1896
+ { key: 'work', mood: 'alert', label: '干活中', say: '在跑呢,别催。' },
1897
+ { key: 'attn', mood: 'attn', label: '有动静', say: '我注意到了。' },
1898
+ { key: 'poked', mood: 'poked', label: '被戳', say: '干嘛。' },
1899
+ { key: 'proud', mood: 'proud', label: '得意', say: '跑完了,不错吧。' },
1900
+ { key: 'eat', mood: 'eat', label: '干饭', say: '先吃一碗。' },
1901
+ ]
1902
+
1903
+ const previewMood = (key) => {
1904
+ const row = ACTION_ROWS.find((r) => r.key === key)
1905
+ if (!row) return false
1906
+ closeDialog()
1907
+ /* 被点掉的那个格子随弹窗一起消失了,焦点必须有人接 —— 否则掉进 <body>。 */
1908
+ focusHer()
1909
+ previewUntil = nowMs() + 6000
1910
+ setMood(row.mood)
1911
+ /* 预览必须**自己有到期时间**:预览的姿势可能在"稳态轮询不许覆盖"的名单里
1912
+ (干饭就在),光靠 previewUntil 挡住轮询的话,锁一过期她就被永久卡在那个表情上。
1913
+ 到点主动交还稳态,之后该是什么由轮询说了算。 */
1914
+ if (previewHandle) clock.cancel(previewHandle)
1915
+ previewHandle = clock.after(6200, () => { setMood('idle') })
1916
+ say(row.say)
1917
+ return true
1918
+ }
1919
+
1920
+ const actionsDialog = () => {
1921
+ const grid = document.createElement('div')
1922
+ grid.className = 'wisp-grid'
1923
+ for (const row of ACTION_ROWS) {
1924
+ const cell = document.createElement('button')
1925
+ cell.className = 'wisp-cell'
1926
+ cell.setAttribute('type', 'button')
1927
+ cell.setAttribute('aria-label', '预览' + row.label)
1928
+ cell.dataset.mood = row.key
1929
+ const im = document.createElement('img')
1930
+ im.setAttribute('alt', '')
1931
+ im.src = resolveSprite(spriteOf(row.key))
1932
+ const cap = document.createElement('span')
1933
+ cap.textContent = row.label
1934
+ cell.appendChild(im)
1935
+ cell.appendChild(cap)
1936
+ cell.addEventListener('click', (ev) => { ev.preventDefault(); previewMood(row.key) })
1937
+ grid.appendChild(cell)
1801
1938
  }
1802
- return items
1939
+ return openDialog('actions', '动作一览', [
1940
+ para('点一个动作,她就当场做给你看。(图都是当前皮肤 ' + skinLabel(activeSkin) + ')'),
1941
+ grid,
1942
+ dialogFoot('共 ' + ACTION_ROWS.length + ' 个动作', '按 Esc 关闭'),
1943
+ ])
1803
1944
  }
1804
1945
 
1946
+ const aboutDialog = () => {
1947
+ const note = WHATS_NEW[VERSION]
1948
+ return openDialog('about', '关于她', [
1949
+ para('DeepSeek娘 —— DSH 界面上的浮动桌宠。她会跟着你的状态换表情:你打字她在忙,你闲着她在晃,你跑完一轮她替你高兴。'),
1950
+ para('她的样子照社区规范来:深蓝色渐变长直发、头顶一个大圆环呆毛、两片鲸鳍耳、女仆装。'),
1951
+ para('白米饭是算力的硬通货 —— 所以她每隔一阵会捧碗出来补点碳水。'),
1952
+ para('这一版:' + (note || VERSION)),
1953
+ para('形象与设定参考社区项目 Neko3000/deepseek-whalechan(设定文档 CC BY-NC-SA 4.0,原作者 B站 ZipZipPipe、上善无形)。'),
1954
+ dialogFoot('非官方同人作品,与 DeepSeek 官方无关', 'v' + VERSION),
1955
+ ])
1956
+ }
1957
+
1958
+ /* 顶层只放"做什么",外观/行为/位置收进可折叠的分组 —— 19 个同级条目等于没有结构。 */
1959
+ const menuItems = () => [
1960
+ { label: '关于她…', run: () => aboutDialog() },
1961
+ { label: '动作一览…', run: () => actionsDialog() },
1962
+ {
1963
+ group: '外观',
1964
+ children: [
1965
+ ...SKIN_IDS.map((id) => ({
1966
+ label: (id === activeSkin ? '✓ ' : ' ') + skinLabel(id),
1967
+ skin: id,
1968
+ run: () => {
1969
+ if (id === activeSkin) return
1970
+ applySkin(id)
1971
+ say('换成' + skinLabel(id) + '了。')
1972
+ },
1973
+ })),
1974
+ { label: '放大一点', run: () => applyConfig({ size: cfg.size + 0.5 }) },
1975
+ { label: '缩小一点', run: () => applyConfig({ size: cfg.size - 0.5 }) },
1976
+ ],
1977
+ },
1978
+ {
1979
+ group: '行为',
1980
+ children: [
1981
+ { label: '说句话', run: () => { wake(true); say(pick(LINES.click)) } },
1982
+ { label: '睡一会儿', run: () => setMood('sleep') },
1983
+ ...TOGGLES.map((t) => ({
1984
+ label: (cfg[t.key] === true ? '✓ ' : ' ') + t.label,
1985
+ run: () => applyConfig({ [t.key]: cfg[t.key] !== true }),
1986
+ })),
1987
+ ],
1988
+ },
1989
+ {
1990
+ group: '位置',
1991
+ children: CORNERS.map((c) => ({ label: c.label, run: () => goCorner(c.label) })),
1992
+ },
1993
+ { label: '版本与更新', run: () => showUpdate() },
1994
+ /* 一个开关,不是一个单向动作:从迷你按钮右键打开菜单时,它必须能把她叫回来。 */
1995
+ { label: hidden ? '回来吧' : '先躲起来', run: () => setHidden(!hidden) },
1996
+ ]
1997
+
1805
1998
  /* ---- 菜单的键盘驱动 ----
1806
1999
  菜单已经声明了 role="menu",没有键盘支持的话,对屏幕阅读器来说比不声明更糟 ——
1807
2000
  它宣布了一个你无法操作的菜单。采用 roving tabindex:只有当前项 tabindex=0,
@@ -1832,7 +2025,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1832
2025
  }
1833
2026
  }
1834
2027
 
1835
- const openMenu = (x, y) => {
2028
+ /* keepGroup:点分组是"在原地重开",得把刚设好的分组带过去。
2029
+ 其他入口都是新手势,一律回到收起状态 —— 所以这个重置放在这里,
2030
+ 而不是 closeMenu 里(放那里会让展开动作把自己清掉)。 */
2031
+ const openMenu = (x, y, keepGroup) => {
2032
+ menuAtX = x
2033
+ menuAtY = y
2034
+ if (keepGroup !== true) activeGroup = ''
1836
2035
  closeMenu()
1837
2036
  stopGlide()
1838
2037
  const items = menuItems()
@@ -1841,16 +2040,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1841
2040
  menu.className = 'wisp-menu'
1842
2041
  menu.setAttribute('role', 'menu')
1843
2042
  menu.style.position = 'absolute'
1844
- for (let i = 0; i < items.length; i++) {
1845
- const spec = items[i]
1846
- if (spec.header !== undefined) {
1847
- // 小标题是分组标签,不是菜单项 —— 不能被聚焦,否则键盘用户会停在一个按不动的东西上
1848
- const head = document.createElement('div')
1849
- head.className = 'wisp-menu-head'
1850
- head.textContent = spec.header
1851
- menu.appendChild(head)
1852
- continue
1853
- }
2043
+ let heads = 0
2044
+ const addItem = (spec, sub) => {
1854
2045
  const btn = document.createElement('button')
1855
2046
  btn.className = 'wisp-menu-item'
1856
2047
  btn.setAttribute('type', 'button')
@@ -1866,14 +2057,71 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1866
2057
  })
1867
2058
  menu.appendChild(btn)
1868
2059
  menuButtons.push(btn)
2060
+ return btn
2061
+ }
2062
+ for (let i = 0; i < items.length; i++) {
2063
+ const spec = items[i]
2064
+ if (spec.header !== undefined) {
2065
+ // 小标题是分组标签,不是菜单项 —— 不能被聚焦,否则键盘用户会停在一个按不动的东西上
2066
+ const head = document.createElement('div')
2067
+ head.className = 'wisp-menu-head'
2068
+ head.textContent = spec.header
2069
+ menu.appendChild(head)
2070
+ heads++
2071
+ continue
2072
+ }
2073
+ if (spec.group !== undefined) {
2074
+ /* 折叠分组:一行标题,点一下在原地展开/收起(重开同一个坐标,不是新开一个菜单)。 */
2075
+ const open = activeGroup === spec.group
2076
+ const btn = document.createElement('button')
2077
+ btn.className = 'wisp-menu-item'
2078
+ btn.setAttribute('type', 'button')
2079
+ btn.setAttribute('role', 'menuitem')
2080
+ btn.setAttribute('tabindex', '-1')
2081
+ btn.setAttribute('aria-expanded', open ? 'true' : 'false')
2082
+ btn.dataset.group = spec.group
2083
+ btn.dataset.open = open ? 'true' : 'false'
2084
+ /* 文字直接写在按钮上,不拆成子 span:真 DOM 会汇总子节点的 textContent,
2085
+ 但简化 DOM(预检的替身)不会 —— 依赖那种汇总会让分组名在替身里变成空串。 */
2086
+ btn.textContent = (open ? '▾ ' : '▸ ') + spec.group
2087
+ btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
2088
+ btn.addEventListener('click', (ev) => {
2089
+ ev.preventDefault(); ev.stopPropagation()
2090
+ activeGroup = open ? '' : spec.group
2091
+ openMenu(menuAtX, menuAtY, true)
2092
+ })
2093
+ menu.appendChild(btn)
2094
+ menuButtons.push(btn)
2095
+ if (!open) continue
2096
+ const wrap = document.createElement('div')
2097
+ wrap.className = 'wisp-menu-sub'
2098
+ for (const child of spec.children) {
2099
+ const cb = document.createElement('button')
2100
+ cb.className = 'wisp-menu-item'
2101
+ cb.setAttribute('type', 'button')
2102
+ cb.setAttribute('role', 'menuitem')
2103
+ cb.setAttribute('tabindex', '-1')
2104
+ cb.textContent = child.label
2105
+ cb.addEventListener('pointerdown', (ev) => ev.stopPropagation())
2106
+ cb.addEventListener('click', (ev) => {
2107
+ ev.preventDefault(); ev.stopPropagation()
2108
+ closeMenu()
2109
+ try { child.run() } catch (err) { /* 同上 */ }
2110
+ })
2111
+ wrap.appendChild(cb)
2112
+ menuButtons.push(cb)
2113
+ }
2114
+ menu.appendChild(wrap)
2115
+ continue
2116
+ }
2117
+ addItem(spec, false)
1869
2118
  }
1870
2119
  // 先挂上去再定位:定位要用最终尺寸,而尺寸只有渲染后才知道
1871
2120
  layer.appendChild(menu)
1872
2121
  menuEl = menu
1873
2122
  const w = MENU_W
1874
- // 高度只用于夹取落点,所以按条目类型估算即可(假 DOM 里没有布局可测)
1875
- let hs = MENU_PAD
1876
- for (const it of items) hs += it.header !== undefined ? 22 : MENU_ITEM_H
2123
+ // 高度只用于夹取落点,所以按**实际渲染出来的行数**估算即可(假 DOM 里没有布局可测)
2124
+ const hs = MENU_PAD + heads * 22 + menuButtons.length * MENU_ITEM_H
1877
2125
  menu.style.left = Math.round(clamp(x, 8, Math.max(8, vw() - w - 8))) + 'px'
1878
2126
  menu.style.top = Math.round(clamp(y, 8, Math.max(8, vh() - hs - 8))) + 'px'
1879
2127
 
@@ -1969,9 +2217,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1969
2217
  /* warm the other sprites after the first frame so mood changes never blink */
1970
2218
  clock.after(1500, () => {
1971
2219
  if (destroyed) return
2220
+ /* 预热其余的精灵图。没有 Image 构造函数的地方(简化 DOM)就跳过,
2221
+ 预热本来就是锦上添花,不该把整只宠物带崩 —— 这里原来硬用裸的 new Image()。 */
2222
+ const ImageCtor = imageCtor()
2223
+ if (ImageCtor === null) return
1972
2224
  for (const key in SPRITES[activeSkin]) {
1973
2225
  if (key === currentSprite) continue
1974
- const pre = new Image()
2226
+ const pre = new ImageCtor()
1975
2227
  pre.src = resolveSprite(spriteOf(key))
1976
2228
  }
1977
2229
  })
@@ -2130,6 +2382,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2130
2382
  setSkin: applySkin,
2131
2383
  showUpdate,
2132
2384
  checkForUpdate,
2385
+ /* 弹窗与折叠分组:菜单结构变了之后,测试要能问"现在开着哪个弹窗 / 哪个分组"。 */
2386
+ openAbout: () => aboutDialog(),
2387
+ openActions: () => actionsDialog(),
2388
+ closeDialog,
2389
+ previewMood,
2390
+ get dialog() { return dialogKind },
2391
+ get menuGroup() { return activeGroup },
2133
2392
  get updateCheck() { return { ...updateCheck } },
2134
2393
  get hidden() { return hidden },
2135
2394
  hide: () => setHidden(true),
@@ -2288,6 +2547,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2288
2547
  bodyEl.removeEventListener('keydown', onBodyKey)
2289
2548
  bodyEl.removeEventListener('contextmenu', onContext)
2290
2549
  closeMenu()
2550
+ closeDialog()
2551
+ if (dialogKeyOff) { dialogKeyOff(); dialogKeyOff = null }
2291
2552
  hideTab()
2292
2553
  if (typeof offTheme === 'function') {
2293
2554
  try { offTheme() } catch (e) { /* ignore */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wisp",
3
- "version": "1.27.0",
3
+ "version": "1.28.0",
4
4
  "description": "DeepSeek娘 — an unofficial floating desktop companion for the DeepSeek Harness UI. Zero dependencies; sprites inlined as data URIs; schedules on the Client timer service instead of the trapped browser timer globals.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/tools/smoke.mjs CHANGED
@@ -224,9 +224,28 @@ const cornerMenu = await page.evaluate(() => {
224
224
  })
225
225
  check(cornerMenu !== null, 'a menu can be opened near the bottom of her body',
226
226
  cornerMenu ? `pointer at y=${cornerMenu.pointerY}` : 'no candidate point hit her silhouette')
227
- check(cornerMenu !== null && cornerMenu.naiveBottom > 900,
228
- 'the case actually needs clamping (otherwise the next check is vacuous)',
229
- cornerMenu ? `unclamped bottom would be ${cornerMenu.naiveBottom} > 900` : 'n/a')
227
+ /* 顶层只有 7 项,"靠近底部"未必真的需要夹取 —— 原来那条 naiveBottom > 900 的守卫
228
+ 在菜单变矮之后自己就失效了。改成:展开一个分组把菜单撑高,再验证同一个性质。
229
+ 顺带这也是真浏览器里第一次验证折叠分组。 */
230
+ const tallMenu = await page.evaluate(() => {
231
+ const row = [...document.querySelectorAll('.wisp-menu-item')].find((i) => i.dataset && i.dataset.group)
232
+ if (!row) return null
233
+ row.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
234
+ const menu = document.querySelector('.wisp-menu')
235
+ if (!menu) return null
236
+ const r = menu.getBoundingClientRect()
237
+ return {
238
+ items: menu.querySelectorAll('.wisp-menu-item').length,
239
+ left: r.left, top: r.top, right: r.right, bottom: r.bottom,
240
+ vw: window.innerWidth, vh: window.innerHeight,
241
+ }
242
+ })
243
+ check(tallMenu !== null && tallMenu.items > 7, 'a group expands inside the real menu',
244
+ tallMenu ? `${tallMenu.items} 项` : '没找到分组行')
245
+ check(tallMenu !== null && tallMenu.left >= 0 && tallMenu.top >= 0
246
+ && tallMenu.right <= tallMenu.vw && tallMenu.bottom <= tallMenu.vh,
247
+ 'and the expanded menu still stays inside the viewport',
248
+ tallMenu ? JSON.stringify(tallMenu) : 'n/a')
230
249
  check(cornerMenu !== null && cornerMenu.inside,
231
250
  'and the menu is clamped back inside the viewport',
232
251
  cornerMenu ? `bottom ${cornerMenu.bottom} (was ${cornerMenu.naiveBottom}), right ${cornerMenu.right}` : 'n/a')
@@ -239,8 +258,8 @@ const skinCount = await page.evaluate(() => {
239
258
  const s = window.__wisp.skins
240
259
  return Array.isArray(s) ? s.length : Object.keys(s ?? {}).length
241
260
  })
242
- check(menu !== null && menu.items.length === 14 + skinCount, 'the menu lists every entry',
243
- `${menu?.items.length} items = 14 + ${skinCount} skin(s)`)
261
+ check(menu !== null && menu.items.length === 7, 'the menu lists every entry',
262
+ `${menu?.items.length} items at the top level`)
244
263
 
245
264
  // 键盘:打开即聚焦第一项,方向键移动,Esc 关闭并把焦点还给她
246
265
  const kbd = await page.evaluate(() => {
@@ -266,6 +285,9 @@ check(kbd.focusAfter === 'wisp-body', 'and focus lands back on her, not on <body
266
285
  const openedAgain = await openMenu()
267
286
  check(openedAgain !== null, 'the menu reopens for the activation check')
268
287
  const zoomed = await page.evaluate(() => {
288
+ /* 大小两项收在「外观」组里:先展开,再点。 */
289
+ const group = [...document.querySelectorAll('.wisp-menu-item')].find((i) => i.dataset && i.dataset.group === '外观')
290
+ if (group) group.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
269
291
  const item = [...document.querySelectorAll('.wisp-menu-item')].find((i) => i.textContent === '放大一点')
270
292
  if (!item) return { missing: true }
271
293
  item.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
@@ -274,6 +296,58 @@ const zoomed = await page.evaluate(() => {
274
296
  check(zoomed.width === '630px' && zoomed.menuGone, 'a menu item runs and closes the menu',
275
297
  zoomed.missing ? 'the item was not found' : `width=${zoomed.width}`)
276
298
 
299
+ /* 两个弹窗在真浏览器里的样子 —— 可见性、图片真的解码出来,这些假 DOM 测不到。
300
+ 注意位置:必须放在键盘那一段**之后**。放前面会扰动它依赖的焦点状态
301
+ (实测:键盘段会退化成"焦点在哪都行"然后连挂三条)。 */
302
+ const aboutView = await page.evaluate(() => {
303
+ window.__wisp.openAbout()
304
+ const card = document.querySelector('.wisp-dialog')
305
+ const back = document.querySelector('.wisp-dialog-backdrop')
306
+ if (!card) return { missing: true }
307
+ const r = card.getBoundingClientRect()
308
+ const text = [...card.querySelectorAll('.wisp-dialog-p, .wisp-dialog-foot-l, .wisp-dialog-foot-r')]
309
+ .map((el) => el.textContent).join(' ')
310
+ return {
311
+ visible: r.width > 200 && r.height > 80,
312
+ inView: r.left >= 0 && r.top >= 0 && r.right <= window.innerWidth && r.bottom <= window.innerHeight,
313
+ hasBackdrop: back !== null,
314
+ text,
315
+ }
316
+ })
317
+ check(aboutView.visible === true && aboutView.inView === true, 'the about dialog is really on screen',
318
+ JSON.stringify(aboutView))
319
+ check(aboutView.hasBackdrop === true && (aboutView.text || '').includes('DeepSeek娘'),
320
+ 'with a backdrop and her name in it', String((aboutView.text || '').slice(0, 48)))
321
+
322
+ const actionsView = await page.evaluate(async () => {
323
+ window.__wisp.openActions()
324
+ const cells = [...document.querySelectorAll('.wisp-cell')]
325
+ await new Promise((resolve) => setTimeout(resolve, 400))
326
+ const loaded = cells.filter((c) => {
327
+ const im = c.querySelector('img')
328
+ return im && im.complete && im.naturalWidth > 0
329
+ }).length
330
+ const r = document.querySelector('.wisp-dialog').getBoundingClientRect()
331
+ return {
332
+ cells: cells.length, loaded,
333
+ inView: r.left >= 0 && r.top >= 0 && r.right <= window.innerWidth && r.bottom <= window.innerHeight,
334
+ }
335
+ })
336
+ check(actionsView.cells === 8, 'the action preview lists eight cells', String(actionsView.cells))
337
+ check(actionsView.loaded === 8, 'and every sprite in it actually decoded',
338
+ actionsView.loaded + '/' + actionsView.cells)
339
+ check(actionsView.inView === true, 'and it fits on screen', String(actionsView.inView))
340
+
341
+ const previewed = await page.evaluate(() => {
342
+ const cell = [...document.querySelectorAll('.wisp-cell')].find((c) => c.dataset.mood === 'proud')
343
+ cell.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
344
+ return { dialog: window.__wisp.dialog, mood: window.__wisp.currentMood }
345
+ })
346
+ check(previewed.dialog === null && previewed.mood === 'proud',
347
+ 'clicking a cell closes the dialog and poses her', JSON.stringify(previewed))
348
+
349
+
350
+
277
351
  /* 键盘入口:假 DOM 证明不了原生按键处理与事件冒泡(窗口上的捕获监听会先看到 keydown),
278
352
  所以这里用真实按键事件走一遍。 */
279
353
  const focusHer = await page.evaluate(() => {