dsh-wisp 1.29.0 → 1.31.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 +91 -0
- package/lib/client.js +404 -42
- package/lib/client.template.js +404 -42
- package/package.json +1 -1
- package/tools/smoke.mjs +1 -1
- package/verify-wisp.mjs +307 -40
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.
|
|
54
|
+
const VERSION = '1.31.0'
|
|
55
55
|
|
|
56
56
|
/* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
|
|
57
57
|
忘了更新就会红,不会静默过期。 */
|
|
@@ -70,6 +70,8 @@ const WHATS_NEW = {
|
|
|
70
70
|
'1.27.0': '她饿了会捧出一碗白米饭,还有第八个表情。',
|
|
71
71
|
'1.28.0': '菜单分成折叠分组,另外多了「关于她」和「动作一览」两个弹窗。',
|
|
72
72
|
'1.29.0': '夜深了她会劝你去睡;你走开再回来她记得;连续来了几天她也记着。',
|
|
73
|
+
'1.30.0': '会陪你专注计时了;页面藏起来时她会安静下来,不再空转。',
|
|
74
|
+
'1.31.0': '菜单重做:状态一目了然,开关是开关,大小能直接拖。',
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
/* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
|
|
@@ -216,6 +218,7 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
216
218
|
careAfterMs: 5400000, // 连续活动这么久之后提醒你起来动动(90 分钟;0 = 关)
|
|
217
219
|
hungry: true, // 干饭:她饿了会说
|
|
218
220
|
hungerMs: 2700000, // 连续活动这么久就该吃一碗了(45 分钟;0 = 关)
|
|
221
|
+
focusMinutes: 25, // 菜单里「专注 N 分钟」用的默认时长
|
|
219
222
|
night: true, // 深夜劝睡
|
|
220
223
|
bedtimeHour: 23, // 几点算深夜(0-23)
|
|
221
224
|
wakeHour: 5, // 几点算天亮(0-23);bedtime > wake 时窗口跨零点
|
|
@@ -324,6 +327,19 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
324
327
|
'算力不够了,得补点碳水。', '电量告急,切待机。', '你要不要也来一口?',
|
|
325
328
|
'这碗不算加餐,算基础设施。', '吃饱了才有力气摸鱼。', '饭碗见底了,该续命了。', '我数着米粒呢。',
|
|
326
329
|
],
|
|
330
|
+
/* 专注计时器。她是陪着你干活的,所以"替你画出一段时间的边界"比再多说几句更实在。 */
|
|
331
|
+
focusStart: [
|
|
332
|
+
'{n} 分钟。开始吧。', '好,{n} 分钟,我盯着你。', '计时开始,{n} 分钟。', '{n} 分钟不看别的。',
|
|
333
|
+
'这段时间归你了。{n} 分钟。',
|
|
334
|
+
],
|
|
335
|
+
focusDone: [
|
|
336
|
+
'{n} 分钟到了。收工。', '{n} 分钟,干完了?', '时间到。{n} 分钟,去喝口水。', '{n} 分钟到了 —— 站起来伸个懒腰。',
|
|
337
|
+
'这一轮 {n} 分钟,结束了。',
|
|
338
|
+
],
|
|
339
|
+
focusStop: ['行,那就先到这儿。', '不专注了?那我歇着。', '计时取消了。', '好吧,下次再说。'],
|
|
340
|
+
focusLeft: [
|
|
341
|
+
'还有 {n} 分钟。', '{n} 分钟,快了。', '还剩 {n} 分钟,别分心。', '还有 {n} 分钟呢,盯着屏幕。',
|
|
342
|
+
],
|
|
327
343
|
/* 深夜劝睡:23 点后一种语气,凌晨 1 点后另一种 —— 后者是社区设定里"嘴硬心软"那一档。
|
|
328
344
|
分档按 bedtimeHour + 2 推导,所以改了作息这套分档也跟着走。 */
|
|
329
345
|
nightLate: [
|
|
@@ -450,6 +466,7 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
450
466
|
careAfterMs: { kind: 'num', lo: 0, hi: 86400000 },
|
|
451
467
|
hungry: { kind: 'bool' },
|
|
452
468
|
hungerMs: { kind: 'num', lo: 0, hi: 86400000 },
|
|
469
|
+
focusMinutes: { kind: 'num', lo: 1, hi: 600 },
|
|
453
470
|
night: { kind: 'bool' },
|
|
454
471
|
bedtimeHour: { kind: 'num', lo: 0, hi: 23 },
|
|
455
472
|
wakeHour: { kind: 'num', lo: 0, hi: 23 },
|
|
@@ -673,10 +690,33 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
673
690
|
.wisp-root[data-mood="attn"] .wisp-body{animation-name:wisp-nudge;animation-duration:1.5s;animation-iteration-count:4}
|
|
674
691
|
@keyframes wisp-nudge{0%,100%{transform:translate3d(0,0,0)}25%{transform:translate3d(-7px,0,0)}75%{transform:translate3d(7px,0,0)}}
|
|
675
692
|
@keyframes wisp-hop{0%,100%{transform:translate3d(0,0,0)}45%{transform:translate3d(0,-16px,0)}}
|
|
676
|
-
.wisp-menu{position:absolute;min-width:
|
|
693
|
+
.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}
|
|
677
694
|
.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}
|
|
678
695
|
.wisp-menu-item:hover{background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.22))}
|
|
679
|
-
.wisp-menu-head{padding:
|
|
696
|
+
.wisp-menu-head{padding:5px 9px 7px}
|
|
697
|
+
.wisp-menu-title{font-size:12.5px;font-weight:600}
|
|
698
|
+
.wisp-menu-sub{font-size:10.5px;opacity:.6;margin-top:1px}
|
|
699
|
+
.wisp-menu-sep{height:1px;margin:4px 6px;background:var(--dsw-alias-border-l1,var(--wisp-say-line));opacity:.45}
|
|
700
|
+
.wisp-menu-hint{padding:5px 9px 3px;font-size:10.5px;opacity:.45}
|
|
701
|
+
.wisp-menu-row{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
|
702
|
+
.wisp-menu-sum{flex:0 1 auto;min-width:0;font-size:11px;opacity:.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
703
|
+
.wisp-menu-item[data-accent="true"]{font-weight:600;color:var(--dsw-alias-brand-primary,inherit)}
|
|
704
|
+
.wisp-menu-item[data-muted="true"]{opacity:.78}
|
|
705
|
+
.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}
|
|
706
|
+
.wisp-switch[data-on="true"]{background:var(--dsw-alias-brand-primary,#4a7cf7)}
|
|
707
|
+
.wisp-switch i{position:absolute;top:2px;left:2px;width:10px;height:10px;border-radius:50%;background:#fff;transition:transform .15s ease}
|
|
708
|
+
.wisp-switch[data-on="true"] i{transform:translateX(12px)}
|
|
709
|
+
.wisp-slider-row{padding:6px 10px 8px}
|
|
710
|
+
.wisp-slider-top{display:flex;justify-content:space-between;font-size:11px;opacity:.8;margin-bottom:3px}
|
|
711
|
+
.wisp-slider{position:relative;height:16px;cursor:pointer;outline:none;touch-action:none}
|
|
712
|
+
.wisp-slider-track{position:absolute;left:0;right:0;top:7px;height:3px;border-radius:2px;background:rgba(140,160,190,.35)}
|
|
713
|
+
.wisp-slider-fill{position:absolute;left:0;top:7px;height:3px;border-radius:2px;background:var(--dsw-alias-brand-primary,#4a7cf7)}
|
|
714
|
+
.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)}
|
|
715
|
+
.wisp-slider:focus-visible .wisp-slider-knob{outline:2px solid var(--dsw-alias-brand-primary,#4a7cf7);outline-offset:1px}
|
|
716
|
+
.wisp-grid2{display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:2px 8px 4px}
|
|
717
|
+
.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}
|
|
718
|
+
.wisp-grid2 button:hover,.wisp-corner:hover{background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.22))}
|
|
719
|
+
.wisp-grid2 button[data-current="true"],.wisp-corner[data-current="true"]{border-color:var(--dsw-alias-brand-primary,#4a7cf7);font-weight:600}
|
|
680
720
|
.wisp-root[data-hidden="true"]{display:none}
|
|
681
721
|
.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}
|
|
682
722
|
.wisp-tab:hover{transform:translateY(-2px)}
|
|
@@ -699,6 +739,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
699
739
|
.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))}
|
|
700
740
|
.wisp-cell img{width:100%;height:52px;object-fit:contain;object-position:bottom center;pointer-events:none}
|
|
701
741
|
.wisp-credits{font-size:11px;opacity:.8;margin:6px 0 0}
|
|
742
|
+
.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}
|
|
743
|
+
.wisp-dialog-action:hover{background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.2))}
|
|
702
744
|
@media (prefers-reduced-motion:reduce){.wisp-body{animation:none!important}}`
|
|
703
745
|
|
|
704
746
|
const installStyles = (styles) => {
|
|
@@ -1642,6 +1684,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1642
1684
|
return
|
|
1643
1685
|
}
|
|
1644
1686
|
|
|
1687
|
+
/* 专注期间不让稳态把她从"干活中"挪走 —— 她在盯着你,这是这个功能的全部意义。
|
|
1688
|
+
位置在这里而不是函数开头:久坐、干饭、劝睡该响还得响。 */
|
|
1689
|
+
if (focusUntil > 0) {
|
|
1690
|
+
if (mood !== 'alert') setMood('alert')
|
|
1691
|
+
return
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1645
1694
|
// 临时状态(happy / worried)在自己的计时器到期前不能被稳态赋值覆盖 ——
|
|
1646
1695
|
// 否则刚摆出的表情在同一个 poll 周期里就被 setMood('idle') 抹掉了。
|
|
1647
1696
|
if (mood === 'sleep' || mood === 'happy' || mood === 'poked' || mood === 'proud' || mood === 'care'
|
|
@@ -1651,10 +1700,62 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1651
1700
|
setMood((busy || typing) ? 'alert' : 'idle')
|
|
1652
1701
|
}
|
|
1653
1702
|
|
|
1703
|
+
/* ---- 专注计时器 ---------------------------------------------------------
|
|
1704
|
+
桌面陪伴插件最该有的实用功能:替你画出一段时间的边界。
|
|
1705
|
+
专注期间她保持"干活中"的姿势(poll 里给她一个 hold —— 否则稳态会把她挪走),
|
|
1706
|
+
到点收工庆祝;戳她一下就是问"还剩多久"。
|
|
1707
|
+
她**不碰应用**:不改输入框、不发消息,只是陪着 + 计时。 */
|
|
1708
|
+
let focusUntil = 0
|
|
1709
|
+
let focusHandle = null
|
|
1710
|
+
let focusMinutes = 0
|
|
1711
|
+
let focusDone = 0
|
|
1712
|
+
|
|
1713
|
+
const focusLeftMs = () => Math.max(0, focusUntil - nowMs())
|
|
1714
|
+
const focusState = () => (focusUntil > 0
|
|
1715
|
+
? { active: true, minutes: focusMinutes, leftMs: Math.round(focusLeftMs()), leftMin: Math.max(1, Math.ceil(focusLeftMs() / 60000)), done: focusDone }
|
|
1716
|
+
: { active: false, minutes: 0, leftMs: 0, leftMin: 0, done: focusDone })
|
|
1717
|
+
|
|
1718
|
+
const stopFocus = (silent) => {
|
|
1719
|
+
if (focusUntil === 0 && focusHandle === null) return false
|
|
1720
|
+
focusUntil = 0
|
|
1721
|
+
focusMinutes = 0
|
|
1722
|
+
if (focusHandle) { clock.cancel(focusHandle); focusHandle = null }
|
|
1723
|
+
if (silent !== true) {
|
|
1724
|
+
wake(true)
|
|
1725
|
+
flashHappy(pick(LINES.focusStop))
|
|
1726
|
+
}
|
|
1727
|
+
return true
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
const finishFocus = () => {
|
|
1731
|
+
const minutes = focusMinutes
|
|
1732
|
+
focusUntil = 0
|
|
1733
|
+
focusHandle = null
|
|
1734
|
+
focusMinutes = 0
|
|
1735
|
+
focusDone++
|
|
1736
|
+
wake(true)
|
|
1737
|
+
flashProud(pick(LINES.focusDone).split('{n}').join(String(minutes)))
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
const startFocus = (minutes) => {
|
|
1741
|
+
const mins = clamp(Math.round(Number(minutes) || cfg.focusMinutes), 1, 600)
|
|
1742
|
+
stopFocus(true)
|
|
1743
|
+
focusMinutes = mins
|
|
1744
|
+
focusUntil = nowMs() + mins * 60000
|
|
1745
|
+
wake(true)
|
|
1746
|
+
say(pick(LINES.focusStart).split('{n}').join(String(mins)))
|
|
1747
|
+
focusHandle = clock.after(mins * 60000, () => { if (focusUntil > 0) finishFocus() })
|
|
1748
|
+
return focusState()
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
/* 没人在看的时候别空转:页面藏起来就停掉轮询、絮叨、踱步三个定时器。
|
|
1752
|
+
它们本来就是"给看得见的人看的",停下来是纯赚(CPU 与电量)。 */
|
|
1753
|
+
let paused = false
|
|
1754
|
+
|
|
1654
1755
|
let chatterHandle = null
|
|
1655
1756
|
const syncChatter = () => {
|
|
1656
1757
|
if (chatterHandle) { clock.cancel(chatterHandle); chatterHandle = null }
|
|
1657
|
-
if (cfg.chatterMs <= 0) return
|
|
1758
|
+
if (cfg.chatterMs <= 0 || paused) return
|
|
1658
1759
|
chatterHandle = clock.every(cfg.chatterMs, () => {
|
|
1659
1760
|
if (destroyed || mood !== 'idle') return
|
|
1660
1761
|
if (agentIsBusy()) return
|
|
@@ -1665,7 +1766,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1665
1766
|
let pollHandle = null
|
|
1666
1767
|
const syncPoll = () => {
|
|
1667
1768
|
if (pollHandle) { clock.cancel(pollHandle); pollHandle = null }
|
|
1668
|
-
if (cfg.reactions) pollHandle = clock.every(POLL_MS, poll)
|
|
1769
|
+
if (cfg.reactions && !paused) pollHandle = clock.every(POLL_MS, poll)
|
|
1669
1770
|
}
|
|
1670
1771
|
syncPoll()
|
|
1671
1772
|
syncChatter()
|
|
@@ -1692,7 +1793,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1692
1793
|
let wanderHandle = null
|
|
1693
1794
|
const syncWander = () => {
|
|
1694
1795
|
if (wanderHandle) { clock.cancel(wanderHandle); wanderHandle = null }
|
|
1695
|
-
if (!cfg.wander || cfg.wanderRange <= 0) return
|
|
1796
|
+
if (!cfg.wander || cfg.wanderRange <= 0 || paused) return
|
|
1696
1797
|
wanderHandle = clock.every(cfg.wanderMs, () => {
|
|
1697
1798
|
if (destroyed || hidden || mood !== 'idle' || start !== null) return
|
|
1698
1799
|
if (agentIsBusy()) return
|
|
@@ -1822,6 +1923,12 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1822
1923
|
if (now - pokeWindowStart > POKE_WINDOW_MS) { pokeWindowStart = now; pokeStreak = 0 }
|
|
1823
1924
|
pokeStreak++
|
|
1824
1925
|
wake(true)
|
|
1926
|
+
/* 专注中戳她是在问进度,不算戳着玩(也就不会升级成"抗议")。 */
|
|
1927
|
+
if (focusUntil > 0) {
|
|
1928
|
+
const left = Math.max(1, Math.ceil(focusLeftMs() / 60000))
|
|
1929
|
+
say(pick(LINES.focusLeft).split('{n}').join(String(left)))
|
|
1930
|
+
return
|
|
1931
|
+
}
|
|
1825
1932
|
if (pokeStreak >= POKE_ANNOYED_AT) {
|
|
1826
1933
|
pokeStreak = 0
|
|
1827
1934
|
flashPoked(pick(LINES.poked))
|
|
@@ -2094,22 +2201,57 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2094
2201
|
|
|
2095
2202
|
const aboutDialog = () => {
|
|
2096
2203
|
const note = WHATS_NEW[VERSION]
|
|
2204
|
+
/* 菜单里那条「版本与更新」已经收掉了(版本号现在直接显示在菜单头部),
|
|
2205
|
+
所以检查更新必须有新的入口 —— 放在这里,上下文正合适。 */
|
|
2206
|
+
const checkBtn = document.createElement('button')
|
|
2207
|
+
checkBtn.className = 'wisp-dialog-action'
|
|
2208
|
+
checkBtn.setAttribute('type', 'button')
|
|
2209
|
+
checkBtn.textContent = '检查更新'
|
|
2210
|
+
checkBtn.addEventListener('click', (ev) => {
|
|
2211
|
+
ev.preventDefault()
|
|
2212
|
+
closeDialog()
|
|
2213
|
+
showUpdate()
|
|
2214
|
+
})
|
|
2097
2215
|
return openDialog('about', '关于她', [
|
|
2098
2216
|
para('DeepSeek娘 —— DSH 界面上的浮动桌宠。她会跟着你的状态换表情:你打字她在忙,你闲着她在晃,你跑完一轮她替你高兴。'),
|
|
2099
2217
|
para('她的样子照社区规范来:深蓝色渐变长直发、头顶一个大圆环呆毛、两片鲸鳍耳、女仆装。'),
|
|
2100
2218
|
para('白米饭是算力的硬通货 —— 所以她每隔一阵会捧碗出来补点碳水。'),
|
|
2219
|
+
para('她还能陪你专注计时:菜单里开一段 25 或 45 分钟,期间她保持"干活中",戳她一下是问还剩多久,到点她替你收工。'),
|
|
2101
2220
|
para('这一版:' + (note || VERSION)),
|
|
2102
2221
|
para('形象与设定参考社区项目 Neko3000/deepseek-whalechan(设定文档 CC BY-NC-SA 4.0,原作者 B站 ZipZipPipe、上善无形)。'),
|
|
2222
|
+
checkBtn,
|
|
2103
2223
|
dialogFoot('非官方同人作品,与 DeepSeek 官方无关', 'v' + VERSION),
|
|
2104
2224
|
])
|
|
2105
2225
|
}
|
|
2106
2226
|
|
|
2107
|
-
/*
|
|
2227
|
+
/* 菜单的结构 ------------------------------------------------------------
|
|
2228
|
+
三段:**动作在上**(专注是最常用的,置顶)、**设置在中间**(三个可折叠分组 +
|
|
2229
|
+
大小滑块)、**信息在下**(动作一览 / 关于她)。每段之间有空行。
|
|
2230
|
+
分组的标题行直接显示当前状态(皮肤名 / 几个开关开着 / 哪个角落),
|
|
2231
|
+
所以收起时也知道里面是什么 —— 这是它和"一个写着外观的按钮"的区别。 */
|
|
2232
|
+
const onToggleCount = () => TOGGLES.filter((t) => cfg[t.key] === true).length
|
|
2233
|
+
const currentCorner = () => {
|
|
2234
|
+
const bw = boxW()
|
|
2235
|
+
const bh = boxH()
|
|
2236
|
+
const hit = CORNERS.find((c) => {
|
|
2237
|
+
const want = c.at(vw(), vh(), bw, bh, cfg)
|
|
2238
|
+
return Math.abs(want.x - coords.x) < 2 && Math.abs(want.y - coords.y) < 2
|
|
2239
|
+
})
|
|
2240
|
+
return hit ? hit.label : '自由'
|
|
2241
|
+
}
|
|
2108
2242
|
const menuItems = () => [
|
|
2109
|
-
{
|
|
2110
|
-
{
|
|
2243
|
+
{ kind: 'head' },
|
|
2244
|
+
{ kind: 'sep' },
|
|
2245
|
+
/* 专注:最常用的动作,放最上面;进行中时这一行本身就是状态 + 结束 */
|
|
2246
|
+
...(focusUntil > 0
|
|
2247
|
+
? [{ label: '专注中 · 还剩 ' + Math.max(1, Math.ceil(focusLeftMs() / 60000)) + ' 分钟', run: () => stopFocus(false), accent: true }]
|
|
2248
|
+
: [
|
|
2249
|
+
{ label: '专注 ' + cfg.focusMinutes + ' 分钟', run: () => startFocus(cfg.focusMinutes), accent: true },
|
|
2250
|
+
{ label: '专注 45 分钟', run: () => startFocus(45) },
|
|
2251
|
+
]),
|
|
2252
|
+
{ kind: 'sep' },
|
|
2111
2253
|
{
|
|
2112
|
-
group: '外观',
|
|
2254
|
+
group: '外观', summary: skinLabel(activeSkin),
|
|
2113
2255
|
children: [
|
|
2114
2256
|
...SKIN_IDS.map((id) => ({
|
|
2115
2257
|
label: (id === activeSkin ? '✓ ' : ' ') + skinLabel(id),
|
|
@@ -2126,22 +2268,27 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2126
2268
|
},
|
|
2127
2269
|
{
|
|
2128
2270
|
group: '行为',
|
|
2271
|
+
summary: onToggleCount() + '/' + TOGGLES.length + ' 开',
|
|
2129
2272
|
children: [
|
|
2130
2273
|
{ label: '说句话', run: () => { wake(true); say(pick(LINES.click)) } },
|
|
2131
2274
|
{ label: '睡一会儿', run: () => setMood('sleep') },
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
run: () => applyConfig({ [t.key]: cfg[t.key] !== true }),
|
|
2135
|
-
})),
|
|
2275
|
+
/* 开关是**真正的开关行**:带 aria-checked,点完菜单不关(就在原地更新) */
|
|
2276
|
+
...TOGGLES.map((t) => ({ kind: 'switch', key: t.key, label: t.label })),
|
|
2136
2277
|
],
|
|
2137
2278
|
},
|
|
2138
2279
|
{
|
|
2139
2280
|
group: '位置',
|
|
2140
|
-
|
|
2281
|
+
summary: currentCorner(),
|
|
2282
|
+
children: [{ kind: 'corners' }],
|
|
2141
2283
|
},
|
|
2142
|
-
{
|
|
2284
|
+
{ kind: 'slider' },
|
|
2285
|
+
{ kind: 'sep' },
|
|
2286
|
+
{ label: '动作一览…', run: () => actionsDialog() },
|
|
2287
|
+
{ label: '关于她…', run: () => aboutDialog() },
|
|
2288
|
+
{ kind: 'sep' },
|
|
2143
2289
|
/* 一个开关,不是一个单向动作:从迷你按钮右键打开菜单时,它必须能把她叫回来。 */
|
|
2144
|
-
{ label: hidden ? '回来吧' : '先躲起来', run: () => setHidden(!hidden) },
|
|
2290
|
+
{ label: hidden ? '回来吧' : '先躲起来', run: () => setHidden(!hidden), muted: true },
|
|
2291
|
+
{ kind: 'hint' },
|
|
2145
2292
|
]
|
|
2146
2293
|
|
|
2147
2294
|
/* ---- 菜单的键盘驱动 ----
|
|
@@ -2189,13 +2336,16 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2189
2336
|
menu.className = 'wisp-menu'
|
|
2190
2337
|
menu.setAttribute('role', 'menu')
|
|
2191
2338
|
menu.style.position = 'absolute'
|
|
2192
|
-
let
|
|
2193
|
-
|
|
2339
|
+
let extras = 0
|
|
2340
|
+
/* 动作行:点了就执行、然后关菜单。 */
|
|
2341
|
+
const actionRow = (spec, into) => {
|
|
2194
2342
|
const btn = document.createElement('button')
|
|
2195
2343
|
btn.className = 'wisp-menu-item'
|
|
2196
2344
|
btn.setAttribute('type', 'button')
|
|
2197
2345
|
btn.setAttribute('role', 'menuitem')
|
|
2198
2346
|
btn.setAttribute('tabindex', '-1')
|
|
2347
|
+
if (spec.accent === true) btn.dataset.accent = 'true'
|
|
2348
|
+
if (spec.muted === true) btn.dataset.muted = 'true'
|
|
2199
2349
|
btn.textContent = spec.label
|
|
2200
2350
|
// 菜单自己吞掉 pointerdown,否则会被下面那个"点外面就关"的监听判成外部点击
|
|
2201
2351
|
btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
|
|
@@ -2204,26 +2354,197 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2204
2354
|
closeMenu()
|
|
2205
2355
|
try { spec.run() } catch (err) { /* 单个动作失败不该带塌整个菜单 */ }
|
|
2206
2356
|
})
|
|
2207
|
-
|
|
2357
|
+
into.appendChild(btn)
|
|
2358
|
+
menuButtons.push(btn)
|
|
2359
|
+
return btn
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
/* 开关行:真开关的样子 + role=menuitemcheckbox + aria-checked。
|
|
2363
|
+
点完**不关菜单** —— 拨开关时菜单消失是最烦人的交互之一。 */
|
|
2364
|
+
const switchRow = (spec, into) => {
|
|
2365
|
+
const on = cfg[spec.key] === true
|
|
2366
|
+
const btn = document.createElement('button')
|
|
2367
|
+
btn.className = 'wisp-menu-item wisp-menu-row'
|
|
2368
|
+
btn.setAttribute('type', 'button')
|
|
2369
|
+
btn.setAttribute('role', 'menuitemcheckbox')
|
|
2370
|
+
btn.setAttribute('aria-checked', on ? 'true' : 'false')
|
|
2371
|
+
btn.setAttribute('tabindex', '-1')
|
|
2372
|
+
btn.dataset.switchKey = spec.key
|
|
2373
|
+
const label = document.createElement('span')
|
|
2374
|
+
label.textContent = spec.label
|
|
2375
|
+
const toggle = document.createElement('span')
|
|
2376
|
+
toggle.className = 'wisp-switch'
|
|
2377
|
+
toggle.dataset.on = on ? 'true' : 'false'
|
|
2378
|
+
toggle.appendChild(document.createElement('i'))
|
|
2379
|
+
btn.appendChild(label)
|
|
2380
|
+
btn.appendChild(toggle)
|
|
2381
|
+
btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
|
|
2382
|
+
btn.addEventListener('click', (ev) => {
|
|
2383
|
+
ev.preventDefault(); ev.stopPropagation()
|
|
2384
|
+
try { applyConfig({ [spec.key]: cfg[spec.key] !== true }) } catch (err) { /* ignore */ }
|
|
2385
|
+
/* 原地重画:开着的那一组保持开着,菜单不跳走 */
|
|
2386
|
+
openMenu(menuAtX, menuAtY, true)
|
|
2387
|
+
})
|
|
2388
|
+
into.appendChild(btn)
|
|
2208
2389
|
menuButtons.push(btn)
|
|
2209
2390
|
return btn
|
|
2210
2391
|
}
|
|
2392
|
+
|
|
2393
|
+
/* 大小滑块:连续量就该直接拖,而不是一次次点"放大一点"。
|
|
2394
|
+
键盘也能调(←/→ 各 0.25 档)—— 所以它自己是可聚焦的。
|
|
2395
|
+
替身里没有布局,getBoundingClientRect 全是 0,所以宽度为 0 时用固定跨度兜底;
|
|
2396
|
+
测试一律走方向键那条路,不依赖像素。 */
|
|
2397
|
+
const sliderRow = (into) => {
|
|
2398
|
+
const SPEC = CONFIG_SPEC.size
|
|
2399
|
+
const row = document.createElement('div')
|
|
2400
|
+
row.className = 'wisp-slider-row'
|
|
2401
|
+
const top = document.createElement('div')
|
|
2402
|
+
top.className = 'wisp-slider-top'
|
|
2403
|
+
const name = document.createElement('span')
|
|
2404
|
+
name.textContent = '大小'
|
|
2405
|
+
const value = document.createElement('span')
|
|
2406
|
+
value.className = 'wisp-slider-value'
|
|
2407
|
+
value.dataset.sliderValue = '1'
|
|
2408
|
+
value.textContent = Math.round(cfg.size * 100) + '%'
|
|
2409
|
+
top.appendChild(name)
|
|
2410
|
+
top.appendChild(value)
|
|
2411
|
+
row.appendChild(top)
|
|
2412
|
+
const track = document.createElement('div')
|
|
2413
|
+
track.className = 'wisp-slider'
|
|
2414
|
+
track.setAttribute('role', 'slider')
|
|
2415
|
+
track.setAttribute('tabindex', '-1')
|
|
2416
|
+
track.setAttribute('aria-label', '大小')
|
|
2417
|
+
track.dataset.slider = '1'
|
|
2418
|
+
track.setAttribute('aria-valuemin', String(SPEC.lo))
|
|
2419
|
+
track.setAttribute('aria-valuemax', String(SPEC.hi))
|
|
2420
|
+
track.setAttribute('aria-valuenow', String(cfg.size))
|
|
2421
|
+
const bg = document.createElement('div')
|
|
2422
|
+
bg.className = 'wisp-slider-track'
|
|
2423
|
+
const fill = document.createElement('div')
|
|
2424
|
+
fill.className = 'wisp-slider-fill'
|
|
2425
|
+
const knob = document.createElement('div')
|
|
2426
|
+
knob.className = 'wisp-slider-knob'
|
|
2427
|
+
track.appendChild(bg)
|
|
2428
|
+
track.appendChild(fill)
|
|
2429
|
+
track.appendChild(knob)
|
|
2430
|
+
const paintSlider = () => {
|
|
2431
|
+
const ratio = clamp((cfg.size - SPEC.lo) / (SPEC.hi - SPEC.lo), 0, 1)
|
|
2432
|
+
fill.style.width = Math.round(ratio * 100) + '%'
|
|
2433
|
+
knob.style.left = Math.round(ratio * 100) + '%'
|
|
2434
|
+
value.textContent = Math.round(cfg.size * 100) + '%'
|
|
2435
|
+
track.setAttribute('aria-valuenow', String(cfg.size))
|
|
2436
|
+
}
|
|
2437
|
+
paintSlider()
|
|
2438
|
+
const setFromClientX = (clientX) => {
|
|
2439
|
+
let rect = { left: 0, width: 0 }
|
|
2440
|
+
try { rect = track.getBoundingClientRect() } catch (e) { rect = { left: 0, width: 0 } }
|
|
2441
|
+
const span = rect.width > 0 ? rect.width : 120
|
|
2442
|
+
const ratio = clamp((clientX - rect.left) / span, 0, 1)
|
|
2443
|
+
const next = Math.round((SPEC.lo + ratio * (SPEC.hi - SPEC.lo)) * 4) / 4
|
|
2444
|
+
applyConfig({ size: next })
|
|
2445
|
+
paintSlider()
|
|
2446
|
+
}
|
|
2447
|
+
track.addEventListener('pointerdown', (ev) => {
|
|
2448
|
+
ev.preventDefault(); ev.stopPropagation()
|
|
2449
|
+
setFromClientX(ev.clientX)
|
|
2450
|
+
const move = (e) => setFromClientX(e.clientX)
|
|
2451
|
+
const up = () => {
|
|
2452
|
+
window.removeEventListener('pointermove', move)
|
|
2453
|
+
window.removeEventListener('pointerup', up)
|
|
2454
|
+
}
|
|
2455
|
+
window.addEventListener('pointermove', move)
|
|
2456
|
+
window.addEventListener('pointerup', up)
|
|
2457
|
+
})
|
|
2458
|
+
row.appendChild(track)
|
|
2459
|
+
into.appendChild(row)
|
|
2460
|
+
menuButtons.push(track)
|
|
2461
|
+
extras++
|
|
2462
|
+
return track
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
/* 四个角落用 2×2 的方位控件,而不是四个文字条目 —— 空间的事用空间的形式表达。 */
|
|
2466
|
+
const cornerGrid = (into) => {
|
|
2467
|
+
const grid = document.createElement('div')
|
|
2468
|
+
grid.className = 'wisp-grid2'
|
|
2469
|
+
for (const corner of CORNERS) {
|
|
2470
|
+
const b = document.createElement('button')
|
|
2471
|
+
b.className = 'wisp-corner'
|
|
2472
|
+
b.setAttribute('type', 'button')
|
|
2473
|
+
b.setAttribute('tabindex', '-1')
|
|
2474
|
+
b.dataset.corner = corner.label
|
|
2475
|
+
b.textContent = corner.label
|
|
2476
|
+
if (corner.label === currentCorner()) b.dataset.current = 'true'
|
|
2477
|
+
b.addEventListener('pointerdown', (ev) => ev.stopPropagation())
|
|
2478
|
+
b.addEventListener('click', (ev) => {
|
|
2479
|
+
ev.preventDefault(); ev.stopPropagation()
|
|
2480
|
+
closeMenu()
|
|
2481
|
+
try { goCorner(corner.label) } catch (err) { /* ignore */ }
|
|
2482
|
+
})
|
|
2483
|
+
grid.appendChild(b)
|
|
2484
|
+
menuButtons.push(b)
|
|
2485
|
+
}
|
|
2486
|
+
into.appendChild(grid)
|
|
2487
|
+
extras++
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
const renderChildren = (children, into) => {
|
|
2491
|
+
for (const child of children) {
|
|
2492
|
+
if (child.kind === 'switch') { switchRow(child, into); continue }
|
|
2493
|
+
if (child.kind === 'corners') { cornerGrid(into); continue }
|
|
2494
|
+
actionRow(child, into)
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2211
2498
|
for (let i = 0; i < items.length; i++) {
|
|
2212
2499
|
const spec = items[i]
|
|
2500
|
+
if (spec.kind === 'head') {
|
|
2501
|
+
/* 头部:她是谁、哪一版、现在什么状态。不可聚焦 —— 它不是动作。 */
|
|
2502
|
+
const head = document.createElement('div')
|
|
2503
|
+
head.className = 'wisp-menu-head'
|
|
2504
|
+
const title = document.createElement('div')
|
|
2505
|
+
title.className = 'wisp-menu-title'
|
|
2506
|
+
title.textContent = 'DeepSeek娘'
|
|
2507
|
+
const subline = document.createElement('div')
|
|
2508
|
+
subline.className = 'wisp-menu-sub'
|
|
2509
|
+
const bits = ['v' + VERSION, skinLabel(activeSkin)]
|
|
2510
|
+
if (focusUntil > 0) bits.push('专注中')
|
|
2511
|
+
subline.textContent = bits.join(' · ')
|
|
2512
|
+
head.appendChild(title)
|
|
2513
|
+
head.appendChild(subline)
|
|
2514
|
+
menu.appendChild(head)
|
|
2515
|
+
extras++
|
|
2516
|
+
continue
|
|
2517
|
+
}
|
|
2518
|
+
if (spec.kind === 'sep') {
|
|
2519
|
+
const sep = document.createElement('div')
|
|
2520
|
+
sep.className = 'wisp-menu-sep'
|
|
2521
|
+
menu.appendChild(sep)
|
|
2522
|
+
extras++
|
|
2523
|
+
continue
|
|
2524
|
+
}
|
|
2525
|
+
if (spec.kind === 'hint') {
|
|
2526
|
+
const hint = document.createElement('div')
|
|
2527
|
+
hint.className = 'wisp-menu-hint'
|
|
2528
|
+
hint.textContent = '↑↓ 选择 · ←→ 调大小 · Esc 关闭'
|
|
2529
|
+
menu.appendChild(hint)
|
|
2530
|
+
extras++
|
|
2531
|
+
continue
|
|
2532
|
+
}
|
|
2533
|
+
if (spec.kind === 'slider') { sliderRow(menu); continue }
|
|
2213
2534
|
if (spec.header !== undefined) {
|
|
2214
2535
|
// 小标题是分组标签,不是菜单项 —— 不能被聚焦,否则键盘用户会停在一个按不动的东西上
|
|
2215
2536
|
const head = document.createElement('div')
|
|
2216
2537
|
head.className = 'wisp-menu-head'
|
|
2217
2538
|
head.textContent = spec.header
|
|
2218
2539
|
menu.appendChild(head)
|
|
2219
|
-
|
|
2540
|
+
extras++
|
|
2220
2541
|
continue
|
|
2221
2542
|
}
|
|
2222
2543
|
if (spec.group !== undefined) {
|
|
2223
|
-
/*
|
|
2544
|
+
/* 折叠分组:标题行带上当前状态摘要,点一下在原地展开/收起。 */
|
|
2224
2545
|
const open = activeGroup === spec.group
|
|
2225
2546
|
const btn = document.createElement('button')
|
|
2226
|
-
btn.className = 'wisp-menu-item'
|
|
2547
|
+
btn.className = 'wisp-menu-item wisp-menu-row'
|
|
2227
2548
|
btn.setAttribute('type', 'button')
|
|
2228
2549
|
btn.setAttribute('role', 'menuitem')
|
|
2229
2550
|
btn.setAttribute('tabindex', '-1')
|
|
@@ -2233,6 +2554,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2233
2554
|
/* 文字直接写在按钮上,不拆成子 span:真 DOM 会汇总子节点的 textContent,
|
|
2234
2555
|
但简化 DOM(预检的替身)不会 —— 依赖那种汇总会让分组名在替身里变成空串。 */
|
|
2235
2556
|
btn.textContent = (open ? '▾ ' : '▸ ') + spec.group
|
|
2557
|
+
if (spec.summary !== undefined) {
|
|
2558
|
+
const sum = document.createElement('span')
|
|
2559
|
+
sum.className = 'wisp-menu-sum'
|
|
2560
|
+
sum.dataset.summary = '1'
|
|
2561
|
+
sum.textContent = spec.summary
|
|
2562
|
+
btn.appendChild(sum)
|
|
2563
|
+
}
|
|
2236
2564
|
btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
|
|
2237
2565
|
btn.addEventListener('click', (ev) => {
|
|
2238
2566
|
ev.preventDefault(); ev.stopPropagation()
|
|
@@ -2244,33 +2572,19 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2244
2572
|
if (!open) continue
|
|
2245
2573
|
const wrap = document.createElement('div')
|
|
2246
2574
|
wrap.className = 'wisp-menu-sub'
|
|
2247
|
-
|
|
2248
|
-
const cb = document.createElement('button')
|
|
2249
|
-
cb.className = 'wisp-menu-item'
|
|
2250
|
-
cb.setAttribute('type', 'button')
|
|
2251
|
-
cb.setAttribute('role', 'menuitem')
|
|
2252
|
-
cb.setAttribute('tabindex', '-1')
|
|
2253
|
-
cb.textContent = child.label
|
|
2254
|
-
cb.addEventListener('pointerdown', (ev) => ev.stopPropagation())
|
|
2255
|
-
cb.addEventListener('click', (ev) => {
|
|
2256
|
-
ev.preventDefault(); ev.stopPropagation()
|
|
2257
|
-
closeMenu()
|
|
2258
|
-
try { child.run() } catch (err) { /* 同上 */ }
|
|
2259
|
-
})
|
|
2260
|
-
wrap.appendChild(cb)
|
|
2261
|
-
menuButtons.push(cb)
|
|
2262
|
-
}
|
|
2575
|
+
renderChildren(spec.children, wrap)
|
|
2263
2576
|
menu.appendChild(wrap)
|
|
2264
2577
|
continue
|
|
2265
2578
|
}
|
|
2266
|
-
|
|
2579
|
+
actionRow(spec, menu)
|
|
2267
2580
|
}
|
|
2581
|
+
|
|
2268
2582
|
// 先挂上去再定位:定位要用最终尺寸,而尺寸只有渲染后才知道
|
|
2269
2583
|
layer.appendChild(menu)
|
|
2270
2584
|
menuEl = menu
|
|
2271
2585
|
const w = MENU_W
|
|
2272
2586
|
// 高度只用于夹取落点,所以按**实际渲染出来的行数**估算即可(假 DOM 里没有布局可测)
|
|
2273
|
-
const hs = MENU_PAD +
|
|
2587
|
+
const hs = MENU_PAD + extras * 24 + menuButtons.length * MENU_ITEM_H
|
|
2274
2588
|
menu.style.left = Math.round(clamp(x, 8, Math.max(8, vw() - w - 8))) + 'px'
|
|
2275
2589
|
menu.style.top = Math.round(clamp(y, 8, Math.max(8, vh() - hs - 8))) + 'px'
|
|
2276
2590
|
|
|
@@ -2282,6 +2596,28 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2282
2596
|
return
|
|
2283
2597
|
}
|
|
2284
2598
|
if (menuButtons.length === 0) return
|
|
2599
|
+
/* 焦点在滑块上:←/→ 调大小,不移动焦点(这是滑块的常规键盘交互)。 */
|
|
2600
|
+
const focused = menuFocusAt >= 0 ? menuButtons[menuFocusAt] : null
|
|
2601
|
+
if (focused && focused.dataset && focused.dataset.slider === '1'
|
|
2602
|
+
&& (ev.key === 'ArrowLeft' || ev.key === 'ArrowRight')) {
|
|
2603
|
+
if (typeof ev.preventDefault === 'function') ev.preventDefault()
|
|
2604
|
+
const step = ev.shiftKey ? 1 : 0.25
|
|
2605
|
+
applyConfig({ size: cfg.size + (ev.key === 'ArrowRight' ? step : -step) })
|
|
2606
|
+
/* 只更新滑块自身,不重画整张菜单 —— 焦点和展开状态都不动 */
|
|
2607
|
+
try {
|
|
2608
|
+
const SPEC = CONFIG_SPEC.size
|
|
2609
|
+
const ratio = clamp((cfg.size - SPEC.lo) / (SPEC.hi - SPEC.lo), 0, 1)
|
|
2610
|
+
const knob = focused.querySelector('.wisp-slider-knob')
|
|
2611
|
+
const fill = focused.querySelector('.wisp-slider-fill')
|
|
2612
|
+
if (knob) knob.style.left = Math.round(ratio * 100) + '%'
|
|
2613
|
+
if (fill) fill.style.width = Math.round(ratio * 100) + '%'
|
|
2614
|
+
focused.setAttribute('aria-valuenow', String(cfg.size))
|
|
2615
|
+
const row = focused.parentNode
|
|
2616
|
+
const label = row && row.querySelector ? row.querySelector('[data-slider-value]') : null
|
|
2617
|
+
if (label) label.textContent = Math.round(cfg.size * 100) + '%'
|
|
2618
|
+
} catch (err) { /* 画不出来也不该挡住改大小 */ }
|
|
2619
|
+
return
|
|
2620
|
+
}
|
|
2285
2621
|
let at = menuFocusAt
|
|
2286
2622
|
if (ev.key === 'ArrowDown') at = at < 0 ? 0 : at + 1
|
|
2287
2623
|
else if (ev.key === 'ArrowUp') at = at < 0 ? menuButtons.length - 1 : at - 1
|
|
@@ -2329,7 +2665,22 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2329
2665
|
const onVisibility = () => {
|
|
2330
2666
|
let state = 'visible'
|
|
2331
2667
|
try { state = document.visibilityState === 'hidden' ? 'hidden' : 'visible' } catch (e) { state = 'visible' }
|
|
2332
|
-
if (state === 'hidden') {
|
|
2668
|
+
if (state === 'hidden') {
|
|
2669
|
+
awayAt = nowMs()
|
|
2670
|
+
/* 没人在看,停掉三个"给看得见的人看"的定时器 */
|
|
2671
|
+
paused = true
|
|
2672
|
+
syncPoll()
|
|
2673
|
+
syncChatter()
|
|
2674
|
+
syncWander()
|
|
2675
|
+
return
|
|
2676
|
+
}
|
|
2677
|
+
/* 回来了:先恢复再算问候 —— 恢复是必须的,问候才是锦上添花 */
|
|
2678
|
+
if (paused) {
|
|
2679
|
+
paused = false
|
|
2680
|
+
syncPoll()
|
|
2681
|
+
syncChatter()
|
|
2682
|
+
syncWander()
|
|
2683
|
+
}
|
|
2333
2684
|
if (awayAt === 0) return
|
|
2334
2685
|
const away = nowMs() - awayAt
|
|
2335
2686
|
awayAt = 0
|
|
@@ -2563,6 +2914,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2563
2914
|
showUpdate,
|
|
2564
2915
|
checkForUpdate,
|
|
2565
2916
|
/* 弹窗与折叠分组:菜单结构变了之后,测试要能问"现在开着哪个弹窗 / 哪个分组"。 */
|
|
2917
|
+
/* 专注计时器:脚本与测试都能驱动 */
|
|
2918
|
+
startFocus,
|
|
2919
|
+
stopFocus,
|
|
2920
|
+
get focus() { return focusState() },
|
|
2566
2921
|
openAbout: () => aboutDialog(),
|
|
2567
2922
|
openActions: () => actionsDialog(),
|
|
2568
2923
|
closeDialog,
|
|
@@ -2691,6 +3046,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2691
3046
|
},
|
|
2692
3047
|
streak: streakOf(memory.days),
|
|
2693
3048
|
awaySince: Math.round(awayAt),
|
|
3049
|
+
/* 专注:不用猜"她为什么一直站在干活中" */
|
|
3050
|
+
focus: focusState(),
|
|
3051
|
+
/* 定时器是否挂着;页面藏起来时三个都该是 false */
|
|
3052
|
+
timers: {
|
|
3053
|
+
poll: pollHandle !== null, chatter: chatterHandle !== null,
|
|
3054
|
+
wander: wanderHandle !== null, paused,
|
|
3055
|
+
},
|
|
2694
3056
|
/* 今日轮次:不用猜"她为什么突然得意",数字就在这里 */
|
|
2695
3057
|
runsToday: { day: runsToday.day, count: runsToday.count, said: runsToday.said.slice() },
|
|
2696
3058
|
/* 她记着你:一起多少天、习惯放在哪、常穿哪套、已经说过哪些 */
|