dsh-wisp 1.30.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 +60 -0
- package/lib/client.js +292 -42
- package/lib/client.template.js +292 -42
- package/package.json +1 -1
- package/tools/smoke.mjs +1 -1
- package/verify-wisp.mjs +178 -40
package/README.md
CHANGED
|
@@ -448,6 +448,66 @@ node tools/audit-log.mjs --confirm <manifest> # 生成后:并入代理登
|
|
|
448
448
|
|
|
449
449
|
## 变更
|
|
450
450
|
|
|
451
|
+
### 1.31.0
|
|
452
|
+
|
|
453
|
+
**菜单重做。** 上一版只是"把 19 个条目收进三个分组" —— 那是分类,不是交互设计。
|
|
454
|
+
这一版按交互本身重做。
|
|
455
|
+
|
|
456
|
+
**问题(先认下来)**:
|
|
457
|
+
|
|
458
|
+
- 信息和动作混在一起:「关于她…」「动作一览…」「版本与更新」和「专注 25 分钟」并列,
|
|
459
|
+
而版本号在「关于她」里还有一份 —— 同一件事出现在两个地方;
|
|
460
|
+
- 三个分组**只看得到名字**:收起时不知道里面是什么、当前是什么状态;
|
|
461
|
+
- 开关是「✓ 自己溜达」这种**带勾的文字**,而且点完菜单就关 —— 拨开关时菜单消失是最烦人的交互之一;
|
|
462
|
+
- 「放大一点/缩小一点」把**连续量**做成了两个离散按钮;
|
|
463
|
+
- 四个角落是四个**文字条目** —— 空间的事没有用空间的形式表达;
|
|
464
|
+
- 最常用的「专注」埋在二级;专注中菜单里**看不出还剩多久**;
|
|
465
|
+
- 7 行长得一模一样,没有分隔、没有层次、没有键盘提示。
|
|
466
|
+
|
|
467
|
+
**现在**:
|
|
468
|
+
|
|
469
|
+
```
|
|
470
|
+
DeepSeek娘 ← 头部:名字 + 版本 + 当前皮肤 +(专注中)
|
|
471
|
+
v1.31.0 · 深海女仆
|
|
472
|
+
─────────────────────────────
|
|
473
|
+
专注 25 分钟 ← 常用动作置顶;进行中时这一行就是状态 + 结束
|
|
474
|
+
专注 45 分钟
|
|
475
|
+
─────────────────────────────
|
|
476
|
+
外观 深海女仆 ▸ ← 摘要行:收起时也知道里面是什么、现在是什么
|
|
477
|
+
行为 4/5 开 ▸
|
|
478
|
+
位置 右下角 ▸
|
|
479
|
+
大小 ──────●────── 100% ← 滑块:拖动即时改;键盘 ←→ 各 0.25 档
|
|
480
|
+
─────────────────────────────
|
|
481
|
+
动作一览…
|
|
482
|
+
关于她… ← 检查更新移到这里(版本号已在头部)
|
|
483
|
+
─────────────────────────────
|
|
484
|
+
先躲起来
|
|
485
|
+
↑↓ 选择 · ←→ 调大小 · Esc 关闭
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
- **开关是真开关**:`role="menuitemcheckbox"` + `aria-checked` + 可见的滑轨,
|
|
489
|
+
点完**不关菜单**,原地更新(摘要里的「N/M 开」也跟着变);
|
|
490
|
+
- **大小是滑块**:`role="slider"` + `aria-valuenow`,指针拖、键盘调都可以,同样不关菜单;
|
|
491
|
+
- **位置是 2×2 罗盘**:四个方位按钮,当前所在的那个有标记;
|
|
492
|
+
- **版本与更新收进「关于她」**:菜单头部直接显示版本号,检查更新是弹窗里的一个按钮
|
|
493
|
+
(上下文才对得上);
|
|
494
|
+
- **三段式**:动作在上、设置在中间、信息在下,段间有分隔线;底部一行键盘提示。
|
|
495
|
+
|
|
496
|
+
预检 473 → **502 项**,冒烟 53 项(真浏览器里新增:分组展开后菜单仍在视口内、
|
|
497
|
+
关于弹窗真的在屏幕上、动作一览八张图真的解码 8/8)。
|
|
498
|
+
|
|
499
|
+
**替身(简化 DOM)这次修了三处"不忠实"** —— 它们让真代码在测试里表现不对:
|
|
500
|
+
|
|
501
|
+
1. 元素级 `querySelector`/`querySelectorAll` 与替身的 `all()`/`find()` 原来是**类名精确相等**,
|
|
502
|
+
而真实 DOM 的 `.a` 是**按类名列表**匹配 —— 于是 `wisp-menu-item wisp-menu-row` 这类行
|
|
503
|
+
在替身里"不存在"。这个坑前后踩过两次,这次统一成类名列表;
|
|
504
|
+
2. 只支持**单个类名或标签**选择器(属性选择器、复合选择器都不行)→ 于是给滑块数值与角落按钮
|
|
505
|
+
各配了一个类名,测试不再依赖替身不支持的选择器;
|
|
506
|
+
3. 测试里判断"焦点在滑块上"要**重新取当前元素**:菜单每次重画都换新元素,
|
|
507
|
+
拿之前抓到的做同一性比较会假失败。
|
|
508
|
+
|
|
509
|
+
### 1.30.0
|
|
510
|
+
|
|
451
511
|
### 1.30.0
|
|
452
512
|
|
|
453
513
|
这一版是**功能**,不是台词。
|
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
|
忘了更新就会红,不会静默过期。 */
|
|
@@ -71,6 +71,7 @@ const WHATS_NEW = {
|
|
|
71
71
|
'1.28.0': '菜单分成折叠分组,另外多了「关于她」和「动作一览」两个弹窗。',
|
|
72
72
|
'1.29.0': '夜深了她会劝你去睡;你走开再回来她记得;连续来了几天她也记着。',
|
|
73
73
|
'1.30.0': '会陪你专注计时了;页面藏起来时她会安静下来,不再空转。',
|
|
74
|
+
'1.31.0': '菜单重做:状态一目了然,开关是开关,大小能直接拖。',
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
/* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
|
|
@@ -689,10 +690,33 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
689
690
|
.wisp-root[data-mood="attn"] .wisp-body{animation-name:wisp-nudge;animation-duration:1.5s;animation-iteration-count:4}
|
|
690
691
|
@keyframes wisp-nudge{0%,100%{transform:translate3d(0,0,0)}25%{transform:translate3d(-7px,0,0)}75%{transform:translate3d(7px,0,0)}}
|
|
691
692
|
@keyframes wisp-hop{0%,100%{transform:translate3d(0,0,0)}45%{transform:translate3d(0,-16px,0)}}
|
|
692
|
-
.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}
|
|
693
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}
|
|
694
695
|
.wisp-menu-item:hover{background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.22))}
|
|
695
|
-
.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}
|
|
696
720
|
.wisp-root[data-hidden="true"]{display:none}
|
|
697
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}
|
|
698
722
|
.wisp-tab:hover{transform:translateY(-2px)}
|
|
@@ -715,6 +739,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
715
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))}
|
|
716
740
|
.wisp-cell img{width:100%;height:52px;object-fit:contain;object-position:bottom center;pointer-events:none}
|
|
717
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))}
|
|
718
744
|
@media (prefers-reduced-motion:reduce){.wisp-body{animation:none!important}}`
|
|
719
745
|
|
|
720
746
|
const installStyles = (styles) => {
|
|
@@ -2175,6 +2201,17 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2175
2201
|
|
|
2176
2202
|
const aboutDialog = () => {
|
|
2177
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
|
+
})
|
|
2178
2215
|
return openDialog('about', '关于她', [
|
|
2179
2216
|
para('DeepSeek娘 —— DSH 界面上的浮动桌宠。她会跟着你的状态换表情:你打字她在忙,你闲着她在晃,你跑完一轮她替你高兴。'),
|
|
2180
2217
|
para('她的样子照社区规范来:深蓝色渐变长直发、头顶一个大圆环呆毛、两片鲸鳍耳、女仆装。'),
|
|
@@ -2182,16 +2219,39 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2182
2219
|
para('她还能陪你专注计时:菜单里开一段 25 或 45 分钟,期间她保持"干活中",戳她一下是问还剩多久,到点她替你收工。'),
|
|
2183
2220
|
para('这一版:' + (note || VERSION)),
|
|
2184
2221
|
para('形象与设定参考社区项目 Neko3000/deepseek-whalechan(设定文档 CC BY-NC-SA 4.0,原作者 B站 ZipZipPipe、上善无形)。'),
|
|
2222
|
+
checkBtn,
|
|
2185
2223
|
dialogFoot('非官方同人作品,与 DeepSeek 官方无关', 'v' + VERSION),
|
|
2186
2224
|
])
|
|
2187
2225
|
}
|
|
2188
2226
|
|
|
2189
|
-
/*
|
|
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
|
+
}
|
|
2190
2242
|
const menuItems = () => [
|
|
2191
|
-
{
|
|
2192
|
-
{
|
|
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' },
|
|
2193
2253
|
{
|
|
2194
|
-
group: '外观',
|
|
2254
|
+
group: '外观', summary: skinLabel(activeSkin),
|
|
2195
2255
|
children: [
|
|
2196
2256
|
...SKIN_IDS.map((id) => ({
|
|
2197
2257
|
label: (id === activeSkin ? '✓ ' : ' ') + skinLabel(id),
|
|
@@ -2208,26 +2268,27 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2208
2268
|
},
|
|
2209
2269
|
{
|
|
2210
2270
|
group: '行为',
|
|
2271
|
+
summary: onToggleCount() + '/' + TOGGLES.length + ' 开',
|
|
2211
2272
|
children: [
|
|
2212
2273
|
{ label: '说句话', run: () => { wake(true); say(pick(LINES.click)) } },
|
|
2213
2274
|
{ label: '睡一会儿', run: () => setMood('sleep') },
|
|
2214
|
-
|
|
2215
|
-
{
|
|
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
|
-
})),
|
|
2275
|
+
/* 开关是**真正的开关行**:带 aria-checked,点完菜单不关(就在原地更新) */
|
|
2276
|
+
...TOGGLES.map((t) => ({ kind: 'switch', key: t.key, label: t.label })),
|
|
2222
2277
|
],
|
|
2223
2278
|
},
|
|
2224
2279
|
{
|
|
2225
2280
|
group: '位置',
|
|
2226
|
-
|
|
2281
|
+
summary: currentCorner(),
|
|
2282
|
+
children: [{ kind: 'corners' }],
|
|
2227
2283
|
},
|
|
2228
|
-
{
|
|
2284
|
+
{ kind: 'slider' },
|
|
2285
|
+
{ kind: 'sep' },
|
|
2286
|
+
{ label: '动作一览…', run: () => actionsDialog() },
|
|
2287
|
+
{ label: '关于她…', run: () => aboutDialog() },
|
|
2288
|
+
{ kind: 'sep' },
|
|
2229
2289
|
/* 一个开关,不是一个单向动作:从迷你按钮右键打开菜单时,它必须能把她叫回来。 */
|
|
2230
|
-
{ label: hidden ? '回来吧' : '先躲起来', run: () => setHidden(!hidden) },
|
|
2290
|
+
{ label: hidden ? '回来吧' : '先躲起来', run: () => setHidden(!hidden), muted: true },
|
|
2291
|
+
{ kind: 'hint' },
|
|
2231
2292
|
]
|
|
2232
2293
|
|
|
2233
2294
|
/* ---- 菜单的键盘驱动 ----
|
|
@@ -2275,13 +2336,16 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2275
2336
|
menu.className = 'wisp-menu'
|
|
2276
2337
|
menu.setAttribute('role', 'menu')
|
|
2277
2338
|
menu.style.position = 'absolute'
|
|
2278
|
-
let
|
|
2279
|
-
|
|
2339
|
+
let extras = 0
|
|
2340
|
+
/* 动作行:点了就执行、然后关菜单。 */
|
|
2341
|
+
const actionRow = (spec, into) => {
|
|
2280
2342
|
const btn = document.createElement('button')
|
|
2281
2343
|
btn.className = 'wisp-menu-item'
|
|
2282
2344
|
btn.setAttribute('type', 'button')
|
|
2283
2345
|
btn.setAttribute('role', 'menuitem')
|
|
2284
2346
|
btn.setAttribute('tabindex', '-1')
|
|
2347
|
+
if (spec.accent === true) btn.dataset.accent = 'true'
|
|
2348
|
+
if (spec.muted === true) btn.dataset.muted = 'true'
|
|
2285
2349
|
btn.textContent = spec.label
|
|
2286
2350
|
// 菜单自己吞掉 pointerdown,否则会被下面那个"点外面就关"的监听判成外部点击
|
|
2287
2351
|
btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
|
|
@@ -2290,26 +2354,197 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2290
2354
|
closeMenu()
|
|
2291
2355
|
try { spec.run() } catch (err) { /* 单个动作失败不该带塌整个菜单 */ }
|
|
2292
2356
|
})
|
|
2293
|
-
|
|
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)
|
|
2294
2389
|
menuButtons.push(btn)
|
|
2295
2390
|
return btn
|
|
2296
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
|
+
|
|
2297
2498
|
for (let i = 0; i < items.length; i++) {
|
|
2298
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 }
|
|
2299
2534
|
if (spec.header !== undefined) {
|
|
2300
2535
|
// 小标题是分组标签,不是菜单项 —— 不能被聚焦,否则键盘用户会停在一个按不动的东西上
|
|
2301
2536
|
const head = document.createElement('div')
|
|
2302
2537
|
head.className = 'wisp-menu-head'
|
|
2303
2538
|
head.textContent = spec.header
|
|
2304
2539
|
menu.appendChild(head)
|
|
2305
|
-
|
|
2540
|
+
extras++
|
|
2306
2541
|
continue
|
|
2307
2542
|
}
|
|
2308
2543
|
if (spec.group !== undefined) {
|
|
2309
|
-
/*
|
|
2544
|
+
/* 折叠分组:标题行带上当前状态摘要,点一下在原地展开/收起。 */
|
|
2310
2545
|
const open = activeGroup === spec.group
|
|
2311
2546
|
const btn = document.createElement('button')
|
|
2312
|
-
btn.className = 'wisp-menu-item'
|
|
2547
|
+
btn.className = 'wisp-menu-item wisp-menu-row'
|
|
2313
2548
|
btn.setAttribute('type', 'button')
|
|
2314
2549
|
btn.setAttribute('role', 'menuitem')
|
|
2315
2550
|
btn.setAttribute('tabindex', '-1')
|
|
@@ -2319,6 +2554,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2319
2554
|
/* 文字直接写在按钮上,不拆成子 span:真 DOM 会汇总子节点的 textContent,
|
|
2320
2555
|
但简化 DOM(预检的替身)不会 —— 依赖那种汇总会让分组名在替身里变成空串。 */
|
|
2321
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
|
+
}
|
|
2322
2564
|
btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
|
|
2323
2565
|
btn.addEventListener('click', (ev) => {
|
|
2324
2566
|
ev.preventDefault(); ev.stopPropagation()
|
|
@@ -2330,33 +2572,19 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2330
2572
|
if (!open) continue
|
|
2331
2573
|
const wrap = document.createElement('div')
|
|
2332
2574
|
wrap.className = 'wisp-menu-sub'
|
|
2333
|
-
|
|
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
|
-
}
|
|
2575
|
+
renderChildren(spec.children, wrap)
|
|
2349
2576
|
menu.appendChild(wrap)
|
|
2350
2577
|
continue
|
|
2351
2578
|
}
|
|
2352
|
-
|
|
2579
|
+
actionRow(spec, menu)
|
|
2353
2580
|
}
|
|
2581
|
+
|
|
2354
2582
|
// 先挂上去再定位:定位要用最终尺寸,而尺寸只有渲染后才知道
|
|
2355
2583
|
layer.appendChild(menu)
|
|
2356
2584
|
menuEl = menu
|
|
2357
2585
|
const w = MENU_W
|
|
2358
2586
|
// 高度只用于夹取落点,所以按**实际渲染出来的行数**估算即可(假 DOM 里没有布局可测)
|
|
2359
|
-
const hs = MENU_PAD +
|
|
2587
|
+
const hs = MENU_PAD + extras * 24 + menuButtons.length * MENU_ITEM_H
|
|
2360
2588
|
menu.style.left = Math.round(clamp(x, 8, Math.max(8, vw() - w - 8))) + 'px'
|
|
2361
2589
|
menu.style.top = Math.round(clamp(y, 8, Math.max(8, vh() - hs - 8))) + 'px'
|
|
2362
2590
|
|
|
@@ -2368,6 +2596,28 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2368
2596
|
return
|
|
2369
2597
|
}
|
|
2370
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
|
+
}
|
|
2371
2621
|
let at = menuFocusAt
|
|
2372
2622
|
if (ev.key === 'ArrowDown') at = at < 0 ? 0 : at + 1
|
|
2373
2623
|
else if (ev.key === 'ArrowUp') at = at < 0 ? menuButtons.length - 1 : at - 1
|