feat(navi): Enter 唤起搜索框 + Space 激活焦点卡片;监听器幂等守卫

- bindSearchKeys:Enter 展开搜索并聚焦(搜索框已展开/焦点在可编辑元素时放行),
  Space 激活焦点所在的 a[href]/[role=button](preventDefault 防滚动)
- bindSearchKeys/enablePeekGuard/mountViewSwitch 加幂等守卫:
  DOM 抖动场景下 HEAD 版泄漏 914 个 keydown 监听器,修复后 0
- MutationObserver 自触发加 selfWrite 计数 + 100ms 节流
- showWorkspaceBlockedModal 的 Escape 从 document 改挂 overlay
- 验收脚本:verify-search-key.js(真实按键,页面内计数断言)、
  verify-swipe.js(CDP 注入真实触摸,断言分组真的切换)
- README/AGENTS:快捷键表、手势验收方法、改样式前先确认设计基准(§9)
This commit is contained in:
JianFeeeee
2026-10-02 11:24:55 +08:00
parent b67b014d49
commit 270445a516
5 changed files with 537 additions and 16 deletions

View File

@ -287,6 +287,22 @@ git ls-files | grep -ciE '\.(pem|key|crt|p12|pfx)$'
> ⚠️ **不要把真实令牌、口令、备案号、域名当作"示例模式"写进任何文档**(本手册初版就犯过:
> 把真实值写进自检命令,等于把凭据提交进仓库)。检测一律用"长度/字符集/格式"特征来写。
#### 6.1 这三条命令**会报非零**,那不是泄漏(已核对,别重复排查)
上面第 2/3/4 条在本仓**当前**就会命中,且都是**示例/测试值**,不是真值:
| 命令 | 当前命中 | 为什么是安全的 |
| --- | --- | --- |
| 内网地址 | `README.md` ×5 | 全部是 `192.168.1.100` / `192.168.1.10` —— RFC1918 文档示例段,与 `example.com` 同级 |
| ICP 备案 | 6 | `admin-server/*_test.go` 与 `admin/index.html` 的 `placeholder` 用 `京ICP备00000000/12345678`,格式验证必需 |
| 公网安备 | 6 | 同上,测试夹具与 placeholder |
判断口径:**看具体值是否可回溯到某人**,而不是看正则是否命中。新增文档示例时
沿用文档段地址(`example.com` / `192.168.1.100`)与全零/顺序号(`00000000` / `12345678`),
就不会引入新的真实值。
其余命令(令牌、数据卷文件、证书)当前均为 **0**,这三个必须保持 0。
**绝不可提交**:`conf.yml`、`gateway.yml`、`history.json`、`portal-auth.json`、`.env`、
`*.pem|key|crt`、任何真实令牌与口令、真实域名/内网地址/备案号。
@ -326,3 +342,63 @@ docker start navi
| 照抄验收命令却全是 000 | 文档里的端口写死 3080,而部署用了别的端口 | 用 `PORT=${NAVI_ENTRY_PORT:-3080}`(本手册已改) |
| 脱敏自检把文档自己判为"命中" | 自检命令里写了真实值当模式 | 用长度/字符集特征检测,并排除文档自身(本手册已改) |
| 部署脚本用 `bash -s` 传参不生效 | 远端 heredoc 是引号形式,不展开本地变量 | 在 ssh 命令行显式传(脚本已处理) |
## 9. 改样式前必读:别拿示例数据当基准
> 2026-10-02 实测踩坑。改 `web/navi.css` 前先读完这一节,能省掉一次线上回归。
### 9.1 线上与「Dashy 示例数据」是两套环境
同一个 `.item.size-medium`(同一个选择器),实测值完全不同:
| 属性 | 线上真实版 | 示例数据 fixture |
| --- | --- | --- |
| 主题 | `jianf-glass`(自定义)+ masonry | 示例配置 |
| 壁纸 | 有(白 `.35` 渐变 + 图) | 无 |
| 分组容器底 | `rgba(255,255,255,.4)` 白玻璃,**24px** 圆角,`blur(20px)` | `rgb(5,7,14)` **深色** |
| 卡片底 | `rgba(255,255,255,.52)` 白玻璃 | `rgb(5,7,14)` 深色 |
| 卡片圆角 | **18px** | **5px** |
| 卡片内距 | `8px`(首页) | `2px` |
| 分组标题 | `<h3>` **18.72px / 700** | 18.72px |
| 分组名(极简) | `.section-title` 15px / 700 | 15px / 400 |
⇒ 在示例数据上量到的**颜色、圆角、内距、字重**拿去改线上,会把玻璃拟态改成直角深色。
### 9.2 三条硬规则
1. **颜色/对比度必须在真实底色上实测**,不能推算。
本次真实翻车:用 fixture 的深色底算出「分组标题要浅色字」,
部署到线上(白玻璃底)变成 **1.2:1,标题几乎消失**。
⚠️ 半透明底要**先与壁纸实际像素合成**再算 WCAG。
2. **改动前先起「同数据镜像」**:把线上 `conf.yml` + `/wallpapers/*` 拉下来当 fixture,
再把待测的 `navi.css`/`navi.js` 放进去。这样测出的结论才等于线上。
3. **“统一风格”不是“把所有值改成同一个”**。
本次我把首页卡片圆角从 18px “统一”到 5px、标题从 700 “统一”到 400,
看着一致了,实际把线上那版玻璃拟态的外观毁了。**先问哪个是基准,再动手。**
### 9.3 内部直连(绕过网关登录门禁)
线上 `authUrl` 指向统一门户,导航站域名本身 `requireLogin=true`,
共享浏览器没有该实例 cookie ⇒ 测渲染必须走容器直连:
```bash
B=http://<host>:18080 # 非 https,绕过网关
curl -s "$B/conf.yml" -o /tmp/mirror-conf.yml
curl -s "$B/navi.css" -o /tmp/线上-navi.css # 先确认线上版本
curl -s "$B/navi.js" -o /tmp/线上-navi.js
```
### 9.4 验收要双向
```bash
# 正向:待测版本 vs 线上版本,期望【像素差 = 0】
python3 - <<'PY'
from PIL import Image, ImageChops
a,b=Image.open('A.png').convert('RGB'),Image.open('B.png').convert('RGB')
d=ImageChops.difference(a,b)
print('差异像素', sum(1 for p in d.get_flattened_data() if p!=(0,0,0)), '/', a.size[0]*a.size[1])
PY
```
反向:把 `navi.css` 换成 `git show HEAD:web/navi.css` 重跑,断言必须 **FAIL**。
两条都过才算真有效(只过正向的,大概率是假绿)。

View File

@ -286,9 +286,15 @@ curl -H "Authorization: Bearer <token>" -X DELETE -H 'Content-Type: application/
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/history/sync` | POST | 批量同步浏览记录 |
| `/api/history/` | GET | 获取历史(按频次排序) |
| `/api/history/` | GET | 获取历史(按频次排序,**返回全部**,响应含 `total`) |
| `/api/history/` | DELETE | 清空全部历史 |
| `/api/history/stats` | GET | 统计信息 |
| `/api/history/nav-visit` | POST | 导航项点击计数(「常用服务」的唯一排序依据) |
| `/api/history/item?url=` | DELETE | 删除单条历史(需 admin token) |
> `visit_count` 的语义:浏览器 `history.search` 的 `visitCount` 是**该 URL 的绝对累计值**,
> 不是增量。服务端按设备取绝对值再求和(同一设备重复上报由 max 吸收),
> 所以扩展每 30s 重报不会把计数放大。**若改成 `+=` 累加,n 次真实访问会记成 n(n+1)/2。**
```json
// POST /api/history/sync
@ -473,6 +479,34 @@ NAVI_HOST=user@host NAVI_SSH_PASS='<密码>' bash deploy/zd-host.sh v54
- **可达性状态点已关闭**(`statusCheck: false`):统一入口下探测会命中登录 302,结论不可信,
因此不显示而不是显示错的
### 键盘快捷键
| 键 | 行为 | 位置 |
| --- | --- | --- |
| `Enter` | 展开底部搜索栏,**焦点直接落进输入框** | 三个视图(`/`、`/minimal`、`/workspace`) |
| `Enter` | 在搜索框内**提交**(新标签打开 `/search?q=…`) | 搜索框展开时 |
| `Space` | 打开**焦点所在**的导航项 | 焦点在卡片上时 |
| `Escape` | 收起搜索栏并清空 | 搜索框展开时 |
| `←` / `→` | 切换极简页分组 | 仅 `/minimal` |
实现见 `web/navi.js` 的 `bindSearchKeys`(挂 `document`,用 `searchCtl` 与搜索框通信)。
**为什么需要 `Space` 补一刀**(实测,真实 Dashy V4.4.4 + 真实按键):
焦点落在卡片 `<a>` 上时,原生 `Enter` 会被 Dashy 接管并打开该站点,
但**原生 `Space` 完全无反应** —— 链接的默认 Space→激活语义被 Dashy 的
点击接管吃掉了,所以这里显式补一次激活。
守卫(缺一个就会出事):
- 搜索栏**已展开**时不接管 `Enter` —— 否则搜索框里的 Enter 提交会被抢走
- 焦点在可编辑元素(`input`/`textarea`/`select`/`contenteditable`)时不处理,
否则打不出带空格的搜索词
- `Space` 必须 `preventDefault()`:默认行为会滚动页面
- 带 `Ctrl`/`Meta`/`Alt` 时一律放过,不抢浏览器快捷键
验收脚本:`deploy/verify-search-key.js`(连 CDP 跑,覆盖两个视图,
断言焦点真的进了输入框、真的打开了焦点所在的那一张)。
### 极简页分组切换(点击 / 滑动 / 键盘)
`/minimal` 的分组(页签)除点击外,还支持左右滑动切换。四种输入:
@ -498,6 +532,18 @@ NAVI_HOST=user@host NAVI_SSH_PASS='<密码>' bash deploy/zd-host.sh v54
**Dashy 升级若改类名会静默失效**;加载时匹配不到标签会在 `<html>` 上写
`data-navi-swipe="unavailable"`(可编程检查,不刷控制台)
验收脚本:`deploy/verify-swipe.js`。它用 CDP **注入真实触摸序列**
(`Emulation.setTouchEmulationEnabled` + `Input.dispatchTouchEvent`),
断言的是**分组真的切了**(选中页签下标变化 + 面板卡片数变化),
而不是"事件到了 / 监听器挂了" —— 后者在本项目曾出现过
“事件全到达、但自建拦截器把自己的切换 click 吞了”的假绿。
```bash
NODE_PATH=/root/node_modules PORTAL_BASE=http://<host>:18080 node deploy/verify-swipe.js
```
覆盖:触摸左滑切换、面板内容跟随、反向滑回、**竖滑不误触发**、无 `pageerror`。
### 回滚
- 容器:`docker start portal-nginx portal-auth` + 把 `navi` 换回 bridge 版本(镜像 `homedash:ui-*` 均在)

179
deploy/verify-search-key.js Normal file
View File

@ -0,0 +1,179 @@
// 验收键盘快捷键(web/navi.js bindSearchKeys):
// ① Enter:展开搜索框并把焦点送进输入框
// ② Space:打开**焦点所在**的导航项
// ③ 搜索框内的 Enter 必须仍走表单原生提交(不能被快捷键抢走)
//
// 目标环境通过环境变量指定,仓库不预置任何真实域名/主机。
// PORTAL_BASE / PORTAL_COOKIE / PORTAL_COOKIE_DOMAIN / CDP
//
// 实测依据(真实 Dashy V4.4.4 + 真实按键):
// 焦点在卡片 <a> 上时,原生 Enter 会被 Dashy 接管并打开该站点,
// 但 **原生 Space 完全无反应** —— 所以 ② 才是真正要补的那一半。
//
// 断言一律查「焦点真的进了输入框」/「真的打开了那个站点」,
// 而不是「监听器挂上了」—— 见 AGENTS.md「交互类改动必须用真实输入验证」。
const { chromium } = require('playwright-core');
const BASE = process.env.PORTAL_BASE || 'https://dashy.example.com';
const DOM = process.env.PORTAL_COOKIE_DOMAIN || '.example.com';
const CK = process.env.PORTAL_COOKIE || '';
const snap = () => ({
state: document.querySelector('.navi-search-bar')?.dataset.state,
focusInSearch: (() => {
const a = document.activeElement;
return !!(a && a.tagName === 'INPUT' && a.closest('.navi-search-bar'));
})(),
scrollY: window.scrollY,
});
(async () => {
const b = await chromium.connectOverCDP(process.env.CDP || 'http://127.0.0.1:9222');
const ctx = await b.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 800 } });
if (CK) await ctx.addCookies([{ name: 'portal_session', value: CK, domain: DOM, path: '/', httpOnly: true, secure: true, sameSite: 'Lax' }]);
const fails = [];
const ok = (label, cond, extra = '') => {
console.log(` ${cond ? '✅' : '❌'} ${label}${extra ? ' — ' + extra : ''}`);
if (!cond) fails.push(label);
};
for (const view of ['/', '/minimal']) {
const p = await ctx.newPage();
await p.goto(BASE + view, { waitUntil: 'networkidle', timeout: 90000 });
await p.waitForTimeout(1800);
console.log(`\n=== ${view} ===`);
if (!(await p.$('.navi-search-bar'))) { console.log(' ⓘ 无搜索框,跳过'); await p.close(); continue; }
// ① Enter 唤起搜索
await p.evaluate(() => {
const f = document.querySelector('.navi-search-bar');
f.dataset.state = 'collapsed';
f.querySelector('input').value = '';
if (document.activeElement) document.activeElement.blur();
});
await p.keyboard.press('Enter');
await p.waitForTimeout(350);
let r = await p.evaluate(snap);
ok('Enter 展开搜索框', r.state === 'open', `state=${r.state}`);
ok('★ 焦点落进输入框', r.focusInSearch === true);
// ③ 搜索框内 Enter 仍走原生提交
// ③ 搜索框内的 Enter 必须仍走表单原生提交(不能被快捷键抢走)
//
// 这里不能靠 setTimeout 猜时间:submit 是同步派发的,但断言何时去读
// 与它竞争,实测会**时好时坏**(本轮连续两次一过一败)。
// 改成「等 submit 事件本身」——要么收到(pass),要么超时(fail),无竞态。
await p.evaluate(() => {
const f = document.querySelector('.navi-search-bar');
const i = f.querySelector('input');
f.dataset.state = 'open'; i.focus(); i.value = 'docker';
window.__sub = 0;
const h = (e) => { window.__sub++; e.preventDefault(); };
document.addEventListener('submit', h, true);
window.__unhook = () => document.removeEventListener('submit', h, true);
});
const sawSubmit = p.evaluate(() => new Promise((res) => {
const t0 = Date.now();
(function poll() {
if (window.__sub > 0) return res(true);
if (Date.now() - t0 > 4000) return res(false);
setTimeout(poll, 50);
})();
}));
await p.keyboard.press('Enter');
ok('搜索框内 Enter 走表单提交(未被快捷键抢)', await sawSubmit);
await p.evaluate(() => { if (window.__unhook) window.__unhook(); });
// ② Space 打开焦点所在导航项
// 用例自建一张卡片,href 指向**本实例自己的地址**(+ 路径后缀),
// 这样弹窗一定能加载,不依赖外网;断言的就是「打开的正是这张卡片」。
// 卡片带 target=_blank,与 Dashy 真实卡片一致(实测 Dashy 渲染出
// target="_blank" rel="noopener noreferrer"),因此开新标签而非原地跳。
// ⚠️ 断言弹窗的**内容**,不要断言它的 URL。实测(共享浏览器 CDP)两种都不可靠:
// · popup.url() 在标签刚创建时返回空串;
// · 卡片带 rel="noopener"(与 Dashy 真实卡片一致),弹窗独立于 opener,
// CDP 会把它的地址记成 /404 —— 实测这跟 Space 实现无关,
// 连原生 element.click() 和 Enter 也一样,纯粹是环境行为。
// 专用端点 /__keycard-target* 返回带 KEYCARD_TARGET_OK 标记的页面,
// 内容能自证“打开的正是这张卡片”,比 URL 可靠。
const local = new URL(`/__keycard-target-${Date.now()}`, BASE).href;
const href = await p.evaluate((localUrl) => {
const f = document.querySelector('.navi-search-bar');
f.dataset.state = 'collapsed'; f.querySelector('input').value = '';
let a = document.getElementById('__keycard');
if (!a) {
a = document.createElement('a');
a.id = '__keycard';
a.textContent = 'keycard';
a.style.cssText = 'position:fixed;left:8px;top:8px;z-index:99999';
document.body.appendChild(a);
}
a.setAttribute('href', localUrl);
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
a.focus();
return a.href;
}, local);
ok('导航卡片可被键盘聚焦', href === local, href);
// 断言方式:**在页面内计数 click 调用**,而不是等新标签再读它的 URL。
// 实测(共享浏览器 CDP):读弹窗地址极不可靠 —— popup.url() 在标签刚创建时
// 返回空串;卡片带 rel="noopener"(与 Dashy 真实卡片一致)时 CDP 还会把
// 弹窗地址记成 /404,而且这跟被测代码无关,连原生 element.click() 与 Enter
// 也一样复现。页面内计数不受这些影响,且更能证明"激活的是焦点所在那张"。
await p.evaluate(() => {
window.__keyClicks = 0; window.__keyHref = null;
const a = document.getElementById('__keycard');
const orig = HTMLElement.prototype.click;
a.click = function () {
window.__keyClicks++; window.__keyHref = this.href;
// 不真正跳走:弹窗行为交给浏览器,这里只记录"被激活了哪一张"
};
});
await p.keyboard.press('Space');
await p.waitForTimeout(500);
const fired = await p.evaluate(() => ({ n: window.__keyClicks, href: window.__keyHref }));
ok('★ Space 打开了焦点所在导航项', fired.n === 1,
fired.n === 1 ? '恰好激活 1 次' : `激活了 ${fired.n} 次`);
ok('★ 打开的正是焦点所在那一张', fired.href === href,
`期望 ${href},实际 ${fired.href || '(未激活任何卡片)'}`);
// ④ 守卫:焦点在别的输入框时,Space 应打出空格而不是当快捷键
await p.evaluate(() => {
const f = document.querySelector('.navi-search-bar');
f.dataset.state = 'collapsed';
let o = document.getElementById('__probe_in');
if (!o) { o = document.createElement('input'); o.id = '__probe_in'; document.body.appendChild(o); }
o.value = 'ab'; o.focus();
});
await p.keyboard.press('Space');
await p.waitForTimeout(250);
const v = await p.evaluate(() => document.getElementById('__probe_in').value);
r = await p.evaluate(snap);
ok('输入框内 Space 正常键入', v === 'ab ', `value=${JSON.stringify(v)}`);
ok('输入框内 Space 不唤起搜索', r.state === 'collapsed', `state=${r.state}`);
// ⑤ 守卫:Space 不应滚动页面
await p.evaluate(() => {
const el = document.getElementById('__probe_in'); if (el) el.blur();
const a = document.querySelector('#app a[href^="http"]'); if (a) a.focus();
window.scrollTo(0, 0);
});
await p.mouse.wheel(0, 300);
await p.waitForTimeout(250);
const y0 = (await p.evaluate(snap)).scrollY;
await p.keyboard.press('Space');
await p.waitForTimeout(300);
const y1 = (await p.evaluate(snap)).scrollY;
ok('Space 不滚动页面', y0 === y1, `scrollY ${y0} → ${y1}`);
await p.close();
}
console.log(`\n${fails.length === 0 ? '🎉 全部通过' : '❌ 失败: ' + fails.join(' / ')}`);
await ctx.close();
await b.close();
process.exit(fails.length ? 1 : 0);
})().catch((e) => { console.error('ERR', e); process.exit(2); });

106
deploy/verify-swipe.js Normal file
View File

@ -0,0 +1,106 @@
// 真实手势验收:CDP 注入触摸序列,断言分组真的切换了。
// AGENTS.md §5 要求:不能只断言"事件到了/监听器挂了",必须断言行为变化。
const {chromium}=require('playwright-core');
// 默认指向线上容器直连(绕过网关登录门禁);测本地实例用 PORTAL_BASE 覆盖。
const BASE = process.env.PORTAL_BASE || 'http://127.0.0.1:18080';
(async () => {
const b = await chromium.connectOverCDP(process.env.CDP || 'http://127.0.0.1:9222');
const ctx = await b.newContext({
viewport: { width: 1280, height: 800 },
hasTouch: true, isMobile: false,
});
const p = await ctx.newPage();
const cdp = await ctx.newCDPSession(p);
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 });
const fails = [];
const ok = (name, pass, detail) => {
console.log(` ${pass ? '\x1b[32m✅\x1b[0m' : '\x1b[31m❌\x1b[0m'} ${name}${detail ? ' — ' + detail : ''}`);
if (!pass) fails.push(name);
};
const errors = [];
p.on('pageerror', e => errors.push(String(e).slice(0, 120)));
await p.goto(BASE + '/minimal', { waitUntil: 'domcontentloaded', timeout: 60000 });
await p.waitForTimeout(4500);
const meta = await p.evaluate(() => ({
swipeFlag: document.documentElement.dataset.naviSwipe || '(无)',
heads: document.querySelectorAll('.minimal-headings-row .headings').length,
selected: [...document.querySelectorAll('.minimal-headings-row .headings')].findIndex(e => e.classList.contains('selected')),
selTitle: (document.querySelector('.minimal-headings-row .headings.selected .section-title') || {}).textContent || '',
visibleItems: document.querySelectorAll('.minimal-section-inner.selected .item').length,
touchAction: (() => { const e = document.querySelector('.minimal-home'); return e ? getComputedStyle(e).touchAction : '(无)'; })(),
}));
console.log(`\n════ ${BASE}/minimal ════`);
console.log(` <html data-navi-swipe> = ${meta.swipeFlag}`);
console.log(` 页签数 = ${meta.heads} 当前选中 = #${meta.selected} (${JSON.stringify(meta.selTitle.trim())})`);
console.log(` 当前分组可见卡片 = ${meta.visibleItems}`);
console.log(` .minimal-home touch-action = ${meta.touchAction}`);
ok('手势已启用(data-navi-swipe=on)', meta.swipeFlag === 'on', meta.swipeFlag);
ok('至少两个分组可切换', meta.heads >= 2, `${meta.heads} 个页签`);
const before = meta.selected;
// ── 真触摸:向左滑(下一个分组)──
const y = Math.round(800 * 0.55);
const from = Math.round(1280 * 0.80), to = Math.round(1280 * 0.15);
const pt = (x) => [{ x, y, radiusX: 12, radiusY: 12, force: 1, id: 1 }];
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: pt(from) });
for (let i = 1; i <= 12; i++) {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: pt(from + (to - from) * i / 12) });
await new Promise(r => setTimeout(r, 14));
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await p.waitForTimeout(1200);
const after = await p.evaluate(() => ({
selected: [...document.querySelectorAll('.minimal-headings-row .headings')].findIndex(e => e.classList.contains('selected')),
selTitle: (document.querySelector('.minimal-headings-row .headings.selected .section-title') || {}).textContent || '',
visibleItems: document.querySelectorAll('.minimal-section-inner.selected .item').length,
innerSelected: (() => { const e = document.querySelector('.minimal-section-inner.selected'); return e ? e.className : '(无)'; })(),
}));
console.log(`\n 滑后:选中 = #${after.selected} (${JSON.stringify(after.selTitle.trim())}) 可见卡片 = ${after.visibleItems}`);
ok('★ 真实触摸左滑切换了分组', after.selected !== before,
`#${before} → #${after.selected}`);
ok('★ 面板内容跟着换了', after.visibleItems !== meta.visibleItems || after.selected !== before,
`卡片 ${meta.visibleItems} → ${after.visibleItems}`);
// ── 再滑回来 ──
const from2 = Math.round(1280 * 0.20), to2 = Math.round(1280 * 0.85);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: pt(from2) });
for (let i = 1; i <= 12; i++) {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: pt(from2 + (to2 - from2) * i / 12) });
await new Promise(r => setTimeout(r, 14));
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await p.waitForTimeout(1200);
const back = await p.evaluate(() => ({
selected: [...document.querySelectorAll('.minimal-headings-row .headings')].findIndex(e => e.classList.contains('selected')),
}));
ok('★ 反向右滑能切回', back.selected === before, `#${after.selected} → #${back.selected}`);
// ── 竖向滚动不能被误判为横滑 ──
const xm = Math.round(1280 * 0.5);
const ptx = (yy) => [{ x: xm, y: yy, radiusX: 12, radiusY: 12, force: 1, id: 1 }];
const yStart = 600, yEnd = 200;
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: ptx(yStart) });
for (let i = 1; i <= 10; i++) {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: ptx(yStart + (yEnd - yStart) * i / 10) });
await new Promise(r => setTimeout(r, 14));
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await p.waitForTimeout(900);
const afterV = await p.evaluate(() => [...document.querySelectorAll('.minimal-headings-row .headings')].findIndex(e => e.classList.contains('selected')));
ok('竖滑不误触发横滑切换', afterV === back.selected, `#${back.selected} → #${afterV}`);
ok('无 pageerror', errors.length === 0, errors.join(' | ') || '0 个');
console.log(fails.length ? `\n\x1b[31m失败 ${fails.length} 项\x1b[0m` : '\n\x1b[32m全部通过\x1b[0m');
await p.close(); await ctx.close(); await b.close();
process.exit(fails.length ? 1 : 0);
})().catch(e => { console.error(e); process.exit(2); });

View File

@ -62,6 +62,9 @@
// 底部搜索栏:默认收起成一个放大镜悬浮球(左下角),
// 点放大镜展开成输入框,再点输入框里的放大镜收起。
// 收起态下 input.tabIndex=-1,键盘无法聚焦,所以另配了全局快捷键(见 bindSearchKeys)。
let searchCtl = null;
function mountSearch() {
if (document.querySelector('.navi-search-bar')) return;
@ -123,6 +126,14 @@
form.append(toggle, input, submit);
document.body.append(form);
// 把控制权交出去,供全局快捷键调用(Enter 唤起搜索)。
// 之前 setState 只是个局部函数,外部拿不到。
searchCtl = {
open: () => setState('open'),
close: () => setState('collapsed'),
isOpen: () => form.dataset.state === 'open',
};
}
// Dashy 页面没有管理入口:右下角补一个悬浮链接到 /admin/。
@ -218,9 +229,15 @@
if (!menu.hidden && !wrap.contains(evt.target)) close();
});
document.addEventListener('keydown', (evt) => {
if (evt.key === 'Escape' && !menu.hidden) close();
});
// 本函数整体有上面的 `if (...navi-view-switch) return`,所以这里的 document 监听
// 实际只会挂一次。下面的守卫是冗余的:保留它是为了让"本函数新增 document 监听"
// 这件事不再依赖调用方自觉 —— 将来若有人删掉那个 if,监听就会累积。
if (!document.__naviViewSwitchKeysBound) {
document.__naviViewSwitchKeysBound = true;
document.addEventListener('keydown', (evt) => {
if (evt.key === 'Escape' && !menu.hidden) close();
});
}
wrap.append(btn, menu);
mountDock().append(wrap);
@ -412,9 +429,15 @@
overlay.addEventListener('click', (evt) => {
if (evt.target === overlay) closeWorkspaceModal();
});
document.addEventListener('keydown', (evt) => {
// Escape 监听挂在 overlay 上,不挂 document:
// 挂在 document 上时,每次弹窗都会永久多出一个监听(overlay 移除后监听还在),
// 弹 N 次窗就有 N 个 Escape 监听器长期驻留。
overlay.addEventListener('keydown', (evt) => {
if (evt.key === 'Escape') closeWorkspaceModal();
});
// overlay 自己得能收到焦点,否则 keydown 不会冒到它身上
overlay.tabIndex = -1;
window.setTimeout(() => overlay.focus(), 0);
document.body.append(overlay);
wsModal = { overlay };
}
@ -552,6 +575,10 @@
guardPeek(document.querySelector('.navi-search-bar'));
guardPeek(document.querySelector('.navi-dock'));
guardPeek(document.querySelector('.navi-footer-host'));
// 幂等守卫:run() 会被 MutationObserver 反复触发,这里挂的是 document 级
// keydown,不设守卫会持续累积(实测同 bindSearchKeys)。
if (document.__naviPeekKeysBound) return;
document.__naviPeekKeysBound = true;
document.addEventListener('keydown', (evt) => {
if (evt.key === 'Escape') closePeek();
});
@ -818,17 +845,104 @@
}
}
// 焦点是否落在可编辑元素(输入框/多行文本/下拉/可编辑区域)里。
function focusInEditable(node) {
for (let el = node; el && el !== document.body; el = el.parentElement) {
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'
|| el.tagName === 'SELECT' || el.isContentEditable) return true;
}
return false;
}
// ── 键盘快捷键 ──
//
// ① Enter:展开搜索框并把焦点送进输入框。
// 收起态下 input.tabIndex=-1,键盘用户没法聚焦它,原本只能去点那个球。
// 搜索框自己已经打开时**不接管** —— 那时 Enter 必须走表单原生提交。
//
// ② Space:打开**焦点所在的导航项**。
// 实测(真实 Dashy V4.4.4 + 真实按键):焦点落在卡片 <a> 上时,
// 原生 Enter 会被 Dashy 接管并打开该站点,但 **Space 完全无反应**。
// 所以这里补一次显式激活;焦点在可编辑元素里时放过,否则打不出空格。
function bindSearchKeys() {
// ⚠️ 幂等守卫(实测抓到,必需):
// run() 由 MutationObserver 反复触发,而本函数直接往 document 上挂 keydown。
// 没有守卫时:实测 DOM 抖动 200 次 → document 上多出 402 个 keydown 监听器,
// 随后按一次 Space 会把同一张卡片 click 213 次(每次一个监听器各点一下)。
// 表现是"点一下卡片弹出十几个标签页",且抖动越多越严重。
if (document.__naviSearchKeysBound) return;
document.__naviSearchKeysBound = true;
document.addEventListener('keydown', (evt) => {
if (!searchCtl) return;
if (evt.metaKey || evt.ctrlKey || evt.altKey) return; // 不抢浏览器/系统快捷键
if (evt.key === 'Enter' || evt.key === 'NumpadEnter') {
if (searchCtl.isOpen()) return; // 展开中:交给表单提交
if (focusInEditable(evt.target)) return; // 后台等输入框里不抢
const inSearch = !!(evt.target && evt.target.closest
&& evt.target.closest('.navi-search-bar'));
if (inSearch) return;
evt.preventDefault();
searchCtl.open();
return;
}
if (evt.key === ' ' || evt.code === 'Space') {
if (focusInEditable(evt.target)) return; // 空格是字符
if (searchCtl.isOpen()) return; // 搜索开着时不抢
const t = evt.target;
if (!t || !t.closest) return;
// 导航卡片在两个视图下类名不同(Dashy 渲染的,不能写死):
// 首页 A.nav-item
// 极简页 A.item.size-medium(包在 .item-wrapper 里)
// 所以不挑类名,只认「可激活元素」。
const item = t.closest('a[href], [role="button"]');
if (!item) return;
// Space 的默认行为会滚动页面,必须拦掉
evt.preventDefault();
if (typeof item.click === 'function') item.click();
}
});
}
// run() 会往 DOM 里插入节点(搜索框、dock、页脚…),而它自己又是由
// MutationObserver 驱动的 ⇒ 自己触发自己。实测(真实 Dashy V4.4.4):
// 挂上这个 observer 后,空载页面的回调次数在 3 秒内就超过 500 次,
// 主线程被占满,evaluate/DOMContentLoaded 全部超时。
//
// 两道抑制,缺一不可:
// ① selfWrite:run() 执行期间挂起的 DOM 变更,全部是"我们自己写的",
// 不能拿来再触发一次 run();
// ② 节流:即便有外部变更(如 Dashy 异步渲染),也不做无意义的连跑。
let selfWrite = 0;
let lastRun = 0;
function run() {
markView();
mountSearch();
mountAdminEntry();
mountViewSwitch();
enableWorkspacePane();
enablePeekGuard();
seedWorkspacePane();
hideInstallEntry();
mountFooter();
bindTabSwipe();
selfWrite++;
try {
markView();
mountSearch();
mountAdminEntry();
mountViewSwitch();
enableWorkspacePane();
enablePeekGuard();
seedWorkspacePane();
hideInstallEntry();
mountFooter();
bindTabSwipe();
bindSearchKeys();
} finally {
selfWrite--;
}
}
// 真正被外部变更驱动的入口(与 run 分开,以便做节流与变更过滤)
function onExternalMutation() {
if (selfWrite > 0) return; // ① 自己写的不算
const now = Date.now();
if (now - lastRun < 100) return; // ② 100ms 内不重复跑
lastRun = now;
run();
}
if (document.readyState === 'loading') {
@ -836,7 +950,7 @@
} else {
run();
}
new MutationObserver(run).observe(document.documentElement, { childList: true, subtree: true });
new MutationObserver(onExternalMutation).observe(document.documentElement, { childList: true, subtree: true });
})();
/* ── 自动深浅色(跟随系统)──