From 270445a5167b81ba419135a72188604e9b717641 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 11:24:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(navi):=20Enter=20=E5=94=A4=E8=B5=B7?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E6=A1=86=20+=20Space=20=E6=BF=80=E6=B4=BB?= =?UTF-8?q?=E7=84=A6=E7=82=B9=E5=8D=A1=E7=89=87=EF=BC=9B=E7=9B=91=E5=90=AC?= =?UTF-8?q?=E5=99=A8=E5=B9=82=E7=AD=89=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- AGENTS.md | 76 +++++++++++++++ README.md | 48 +++++++++- deploy/verify-search-key.js | 179 ++++++++++++++++++++++++++++++++++++ deploy/verify-swipe.js | 106 +++++++++++++++++++++ web/navi.js | 144 ++++++++++++++++++++++++++--- 5 files changed, 537 insertions(+), 16 deletions(-) create mode 100644 deploy/verify-search-key.js create mode 100644 deploy/verify-swipe.js diff --git a/AGENTS.md b/AGENTS.md index 459daf9..ea8f46b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` | +| 分组标题 | `

` **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://: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**。 +两条都过才算真有效(只过正向的,大概率是假绿)。 diff --git a/README.md b/README.md index 15883f8..9db2d95 100644 --- a/README.md +++ b/README.md @@ -286,9 +286,15 @@ curl -H "Authorization: Bearer " -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 + 真实按键): +焦点落在卡片 `` 上时,原生 `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 升级若改类名会静默失效**;加载时匹配不到标签会在 `` 上写 `data-navi-swipe="unavailable"`(可编程检查,不刷控制台) +验收脚本:`deploy/verify-swipe.js`。它用 CDP **注入真实触摸序列** +(`Emulation.setTouchEmulationEnabled` + `Input.dispatchTouchEvent`), +断言的是**分组真的切了**(选中页签下标变化 + 面板卡片数变化), +而不是"事件到了 / 监听器挂了" —— 后者在本项目曾出现过 +“事件全到达、但自建拦截器把自己的切换 click 吞了”的假绿。 + +```bash +NODE_PATH=/root/node_modules PORTAL_BASE=http://:18080 node deploy/verify-swipe.js +``` + +覆盖:触摸左滑切换、面板内容跟随、反向滑回、**竖滑不误触发**、无 `pageerror`。 + ### 回滚 - 容器:`docker start portal-nginx portal-auth` + 把 `navi` 换回 bridge 版本(镜像 `homedash:ui-*` 均在) diff --git a/deploy/verify-search-key.js b/deploy/verify-search-key.js new file mode 100644 index 0000000..c392378 --- /dev/null +++ b/deploy/verify-search-key.js @@ -0,0 +1,179 @@ +// 验收键盘快捷键(web/navi.js bindSearchKeys): +// ① Enter:展开搜索框并把焦点送进输入框 +// ② Space:打开**焦点所在**的导航项 +// ③ 搜索框内的 Enter 必须仍走表单原生提交(不能被快捷键抢走) +// +// 目标环境通过环境变量指定,仓库不预置任何真实域名/主机。 +// PORTAL_BASE / PORTAL_COOKIE / PORTAL_COOKIE_DOMAIN / CDP +// +// 实测依据(真实 Dashy V4.4.4 + 真实按键): +// 焦点在卡片 上时,原生 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); }); \ No newline at end of file diff --git a/deploy/verify-swipe.js b/deploy/verify-swipe.js new file mode 100644 index 0000000..92766f1 --- /dev/null +++ b/deploy/verify-swipe.js @@ -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(` = ${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); }); \ No newline at end of file diff --git a/web/navi.js b/web/navi.js index 80ab880..bf0bb81 100644 --- a/web/navi.js +++ b/web/navi.js @@ -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 + 真实按键):焦点落在卡片 上时, + // 原生 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 }); })(); /* ── 自动深浅色(跟随系统)──