From 9dad9f708d728fe767575b87eb3b860ee47177c0 Mon Sep 17 00:00:00 2001 From: Nickszy <1344398321@qq.com> Date: Sun, 8 Nov 2026 17:45:48 +0800 Subject: [PATCH 1/2] feat: adapt to ds-bot 0.3.x + top-bar ADE entry + stealth switching (0.4.3) - ds-bot 0.3.x moved 'Classic Agent mode' from the account menu into Bot settings -> General; rewire the Bot<->Classic path to the settings page, matching menu items/buttons by text. Removes the old 'last menu item' fallback that clicked 'Sign in to DeepSeek' and opened the login page. - Add a top-bar 'ADE' entry (.bt-side-top, narrow rail -> .bt-rail-top). - Stealth mode: hide account menu / settings layer during automation, restore after. - Fallback: write localStorage['ds-bot:surface'] and reload if DOM paths fail. - Bump to 0.4.3; compatibility records 0.2.1-rc.4 and 0.2.2-rc.1. --- CHANGELOG.md | 22 +++++ lib/client.js | 261 ++++++++++++++++++++++++++++++++++++++++++++----- package.json | 6 +- test/smoke.mjs | 10 +- 4 files changed, 270 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f2a727..47ab08e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ All notable changes to this project. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.3] — 2026-11-08 + +### Added + +- **Top-bar entry**: an "ADE" button injected next to the ds-bot side-panel brand + (`.bt-side-top`, narrow rail falls back to `.bt-rail-top`) for one-click switching, + re-inserted by the 500 ms refresh tick if React re-renders it away. +- **Flicker-free switching (stealth mode)**: while switching to the classic surface the + account menu and settings layer are forced to `display:none` (they stay in the DOM so + programmatic clicks still work) and restored 400 ms after the switch; on failure an + Escape key burst closes any leftover popover. +- **Last-resort fallback**: when both DOM paths fail, write ds-bot's own persisted key + `localStorage["ds-bot:surface"]` and reload the page (ds-bot reads it on startup). + +### Changed + +- **ds-bot 0.3.x adaptation**: the switch entry moved from the account menu to + *Bot settings → General → Classic Agent mode → Switch*. Menu items and buttons are now + matched by their text, and the old "last menu item" fallback (which clicked + "Sign in to DeepSeek" and opened the login page) is removed. +- Compatibility map records `0.2.1-rc.4` and `0.2.2-rc.1` as tested. + ## [0.4.1] - 2026-10-27 - Add real-interface screenshots (Bot surface / classic surface) and `screenshots.json`. diff --git a/lib/client.js b/lib/client.js index 3f51b50..f1f4ebb 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1,12 +1,27 @@ -// dsh-bot-toggle — 浏览器侧(v0.4.0)。 +// dsh-bot-toggle — 浏览器侧(v0.4.3)。 // // 作用:在 ds-bot 的两套界面之间放一枚常驻的分段开关,ds-bot 插件始终加载: // Bot = ds-bot 的 Bot 界面;ADE = 经典 DSH 界面。 // -// 切换依赖 ds-bot 自己渲染的按钮与菜单(见下),ds-bot 升级后可能需要适配。 -// 不伪造任何状态,也不碰它持久化的 localStorage: +// 切换依赖 ds-bot 自己渲染的控件(见下),ds-bot 升级后可能需要适配。 +// 不伪造任何状态;唯一例外是兜底(见下)会写它持久化的 localStorage: // 经典 → Bot:点侧栏底部的 .bt-return(BotReturn,仅 agent 模式存在) -// Bot → 经典:点 .bt-account 打开账号菜单,再点菜单里的「切换经典 Agent 模式」 +// Bot → 经典(v0.4.2 适配 ds-bot 0.3.x):点 .bt-account 打开账号菜单 → +// 点「Bot 设置」打开设置页(默认落在 General)→ 点 Classic Agent mode 行的 +// 「切换」按钮。0.3.x 起账号菜单里不再有「切换经典 Agent 模式」,官方入口 +// 挪到了设置 → General → 模式(Mode)里。 +// 兜底:DOM 路径两连败时,写 ds-bot 自己的持久化键 ds-bot:surface(bot/agent) +// 并 location.reload()——ds-bot 启动时读它决定进哪个界面,等价于它自己的 +// setSurface 加重开页面。代价是整页刷新,所以只作最后手段。 +// +// v0.4.2 另在 ds-bot 顶栏(.bt-side-top,窄侧栏为 .bt-rail-top)注入一枚常驻 +// 「ADE」大入口,用 refresh 的轮询守护对抗 React 重渲染清除;经典界面没有 +// 顶栏容器,此时靠悬浮胶囊和 .bt-return。 +// +// v0.4.3:切换是模拟点击、会真实打开账号菜单和设置页,用户能看到它们闪一下。 +// 现在切经典前先注入一段临时样式,把 .bt-account-menu / .bt-settings-layer 压成 +// display:none(元素仍在 DOM 里,程序化 click 照常生效),切完或失败后延迟移除, +// 视觉上无感;失败时再补一发 Escape 尽力收起残留弹层。 // // v0.4.0 起:界面文案中英双语(跟随页面/浏览器语言);会探测 ds-bot 是否加载、 // 以及能拿到时的版本号——控件缺失时胶囊转琥珀色并说明原因,而不是直接消失。 @@ -31,9 +46,12 @@ var exports = module.exports; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); - var VERSION = "0.4.0"; + var VERSION = "0.4.3"; var LOG = "[bot-toggle]"; var POS_KEY = "dsh-bot-toggle:pos"; + var SURFACE_KEY = "ds-bot:surface"; // ds-bot 自己的界面持久化键(bot/agent) + var STEALTH_ID = "dsst-stealth-style"; // 切换期间临时压掉弹层的样式节点 id + var STEALTH_CSS = ".bt-account-menu,.bt-settings-layer{display:none!important}"; var BOT_MARKERS = ".bt-account, .bt-side-top"; // Bot 界面(侧栏账号按钮 / 侧栏顶部行) var CLASSIC_MARKER = ".bt-return"; // 经典界面(ds-bot 的「DS Bot」返回按钮) var TICK_MS = 500; // 轮询界面状态的间隔:只读 DOM,开销可忽略 @@ -66,7 +84,11 @@ warnNoDsBot: "ds-bot 没有加载(插件页里未启用),没有可切换的界面。", errNoBack: "找不到返回 DS Bot 的按钮(.bt-return)", errNoAccount: "找不到 ds-bot 的账号按钮(.bt-account)", - errNoMenu: "账号菜单里没有「切换经典 Agent 模式」项", + errNoSwitch: "设置页里没找到「经典 Agent 模式 · 切换」按钮", + errNoMenu: "账号菜单里没有「Bot 设置」项", + errNoReload: "兜底切换失败:无法写入 ds-bot:surface", + bigAria: "切换到经典 DSH 界面(ADE)", + bigTip: "经典 DSH 界面(ADE)· Alt+Shift+B 可切回 Bot", notFlipped: function (target) { return "切换未生效(目标:" + target + ")"; }, incompat: function (version) { var suffix = version ? "(ds-bot " + version + ")" : ""; @@ -84,7 +106,11 @@ warnNoDsBot: "ds-bot is not loaded (enable it on the Plugins page); nothing to switch.", errNoBack: "The DS Bot return button (.bt-return) is missing", errNoAccount: "The ds-bot account button (.bt-account) is missing", - errNoMenu: 'No "Switch to classic Agent mode" item in the account menu', + errNoSwitch: 'No "Classic Agent mode → Switch" button found on the settings page', + errNoMenu: 'No "Bot settings" item in the account menu', + errNoReload: "Fallback switch failed: cannot write ds-bot:surface", + bigAria: "Switch to the classic DSH surface (ADE)", + bigTip: "Classic DSH surface (ADE) · Alt+Shift+B switches back to Bot", notFlipped: function (target) { return "Switch did not take effect (target: " + target + ")"; }, incompat: function (version) { var suffix = version ? " (ds-bot " + version + ")" : ""; @@ -202,10 +228,13 @@ }; } - // 账号菜单里的「切换经典 Agent 模式」项。优先按文案匹配,选择器只作兜底, - // 这样 ds-bot 菜单顺序变了也不会点错。 - function findAgentItem() { - var textRe = /(经典\s*Agent|classic\s+Agent)/i; + // ds-bot 0.3.x 起账号菜单里不再有「切换经典 Agent 模式」,官方入口挪到了 + // 设置 → 通用(General)→ 模式(Mode)→ Classic Agent mode 行的「切换」按钮。 + // 打开路径:点 .bt-account 弹账号菜单 → 点「Bot 设置 / Bot settings」项打开设置层 + // (默认落在 General 页)。全部按文案匹配,不依赖菜单顺序,也就不会误点 + // 未登录时菜单里出现的「登录 DeepSeek 账号」(0.4.1 的 bug 根源)。 + function menuSettingsItem() { + var textRe = /(Bot\s*设置|Bot\s+settings)/i; var menus = document.querySelectorAll(".bt-account-menu"); for (var m = 0; m < menus.length; m += 1) { var items = menus[m].querySelectorAll('[role="menuitem"]'); @@ -213,9 +242,36 @@ if (textRe.test((items[i].textContent || "").trim())) return items[i]; } } - return document.querySelector( - '.bt-account-menu > .bt-menu-sep:last-of-type ~ [role="menuitem"]' - ); + return null; + } + + // 设置 → General 页里的 Classic Agent mode 行:文案匹配行标签, + // 再点同一行里文案恰好是「切换 / Switch」的 .bt-soft 按钮。 + function findSwitchButton() { + var rowRe = /(经典\s*Agent\s*模式|Classic\s+Agent\s*mode)/i; + var clickRe = /^(切换|Switch)$/; + var scroll = document.querySelector(".bt-settings-scroll"); + if (scroll === null) return null; + var rows = scroll.querySelectorAll(".bt-set-row"); + for (var r = 0; r < rows.length; r += 1) { + var copy = rows[r].querySelector(".bt-set-copy"); + if (copy === null || !rowRe.test((copy.textContent || "").trim())) continue; + var buttons = rows[r].querySelectorAll("button.bt-soft"); + for (var b = 0; b < buttons.length; b += 1) { + if (clickRe.test((buttons[b].textContent || "").trim())) return buttons[b]; + } + } + return null; + } + + // 设置层可能开在别的页上:先把左侧导航切回 General(通用)。 + function findGeneralNavItem() { + var re = /^(通用|General)$/; + var items = document.querySelectorAll(".bt-settings-nav .bt-settings-item"); + for (var i = 0; i < items.length; i += 1) { + if (re.test((items[i].textContent || "").trim())) return items[i]; + } + return null; } function waitFor(predicate, timeoutMs) { @@ -239,6 +295,64 @@ }); } + // 最后的兜底:ds-bot 没把 setSurface 暴露到 window(只在 React 组件树里传), + // 但它把当前界面持久化在 localStorage 的 ds-bot:surface(bot/agent)里、启动时 + // 读回来——写键 + 刷新等价于它自己的 setSurface 加重开页面。代价是整页刷新, + // 所以只在 DOM 路径失败后才走。返回 true 表示已触发重载。 + function persistAndReload(surface) { + try { + root.localStorage.setItem(SURFACE_KEY, surface); + } catch (_) { + warn(STR.errNoReload); + return false; + } + log("persisted", SURFACE_KEY, "=", surface, "; reloading page"); + root.location.reload(); + return true; + } + + // 切换期间给弹层「隐身」:注入临时样式把账号菜单和设置页压成 display:none。 + // 元素仍在 DOM 里,querySelector 找得到、程序化 click 照常触发,只是不画出来。 + function stealthOn() { + try { + if (document.getElementById(STEALTH_ID) !== null) return; + var node = document.createElement("style"); + node.id = STEALTH_ID; + node.textContent = STEALTH_CSS; + (document.head || document.documentElement).appendChild(node); + } catch (error) { + warn("stealth on failed:", error && error.message ? error.message : error); + } + } + + // 延迟一帧多的时间再撤掉:React 提交 unmount 可能晚于我们的 finally。 + function stealthOff() { + root.setTimeout(function () { + try { + var node = document.getElementById(STEALTH_ID); + if (node !== null && node.parentNode !== null) node.parentNode.removeChild(node); + } catch (_) {} + }, 400); + } + + // 失败时尽力收起可能残留的弹层(Escape 是设置层/菜单常用的关闭键)。 + function dismissPopups() { + try { + ["keydown", "keyup"].forEach(function (type) { + document.dispatchEvent( + new KeyboardEvent(type, { + key: "Escape", + code: "Escape", + keyCode: 27, + which: 27, + bubbles: true, + cancelable: true, + }) + ); + }); + } catch (_) {} + } + // 执行一次界面切换。失败时抛错,由调用方统一提示。 function switchTo(target) { if (target === "bot") { @@ -250,11 +364,28 @@ var account = document.querySelector(".bt-account"); if (account === null) throw new Error(STR.errNoAccount); - if (document.querySelector(".bt-account-menu") === null) account.click(); - return waitFor(findAgentItem, 1500).then(function (item) { - if (item === null) throw new Error(STR.errNoMenu); - item.click(); - }); + + // 设置层已开着(上次尝试残留或用户自己开着)→ 直接定位开关按钮。 + if (document.querySelector(".bt-settings-layer") !== null) { + var nav = findGeneralNavItem(); + if (nav !== null && nav.getAttribute("aria-current") !== "page") nav.click(); + return waitFor(findSwitchButton, 2000).then(function (button) { + if (button === null) throw new Error(STR.errNoSwitch); + button.click(); + }); + } + + account.click(); + return waitFor(menuSettingsItem, 1500) + .then(function (item) { + if (item === null) throw new Error(STR.errNoMenu); + item.click(); + return waitFor(findSwitchButton, 2500); + }) + .then(function (button) { + if (button === null) throw new Error(STR.errNoSwitch); + button.click(); + }); } var STYLES = [ @@ -287,6 +418,17 @@ ".dsst[data-edge='left'] .dsst-tip{right:auto;left:calc(100% + 15px)}", ".dsst-tip[data-show]{opacity:1}", ".dsst-tip[data-tone='error']{background:#8f2f2f}", + // 顶栏大入口:先给不带 color-mix 的兜底配色,支持 color-mix 的内核再用 + // 跟随文字色的半透明配色(深浅主题都不突兀)。 + ".dsst-big{display:inline-flex;align-items:center;gap:5px;height:28px;margin-left:10px;padding:0 10px 0 9px;border:1px solid rgba(127,127,127,.4);border-radius:999px;background:rgba(127,127,127,.08);color:inherit;font:600 12.5px/1 ui-sans-serif,-apple-system,'Segoe UI','PingFang SC',sans-serif;letter-spacing:.04em;cursor:pointer;flex:none;transition:box-shadow .16s ease,transform .16s ease}", + ".dsst-big{border-color:color-mix(in srgb,currentColor 22%,transparent);background:color-mix(in srgb,currentColor 7%,transparent)}", + ".dsst-big:hover{box-shadow:0 2px 10px rgba(127,127,127,.35);transform:translateY(-1px)}", + ".dsst-big:active{transform:translateY(0)}", + ".dsst-big:focus-visible{outline:2px solid rgba(127,127,127,.6);outline-offset:1px}", + ".dsst-big svg{width:15px;height:15px;display:block;flex:none}", + ".dsst-big-label{font-weight:700}", + ".dsst-big[data-busy]{opacity:.65;pointer-events:none}", + ".dsst-big-narrow{height:26px;margin-left:6px;padding:0 8px}", "@media (prefers-reduced-motion:reduce){.dsst,.dsst button,.dsst-thumb,.dsst-tip{transition:none}.dsst[data-shake]{animation:none}}", ].join("\n"); @@ -299,6 +441,7 @@ var pill = null; var thumb = null; var tip = null; + var topBtn = null; // 顶栏大入口(插在 React 子树里,可能被清掉重插) var style = null; var buttons = {}; var tickTimer = null; @@ -331,6 +474,7 @@ } function refresh() { + ensureTopButton(); var next = detectMode(); if (next !== null) { nullSince = 0; @@ -371,6 +515,51 @@ lastPaint = null; } + // ---------- 顶栏大入口 ---------- + // v0.4.2:在 ds-bot 顶栏放一枚显眼的「ADE」按钮(只有 Bot 界面有顶栏容器; + // 经典界面里靠悬浮胶囊和 .bt-return)。按钮插在 React 管理的子树里,重渲染 + // 可能把它清掉——refresh 的 500ms 轮询顺带守护,发现没了就重插。 + function removeTopButton() { + if (topBtn !== null && topBtn.parentNode !== null) topBtn.parentNode.removeChild(topBtn); + topBtn = null; + } + + function ensureTopButton() { + if (disposed) return; + var container = document.querySelector(".bt-side-top"); + var narrow = false; + if (container === null) { + container = document.querySelector(".bt-rail-top"); // 窄侧栏没有 bt-side-top + narrow = true; + } + if (container === null) { + if (topBtn !== null) removeTopButton(); + return; + } + if (topBtn !== null && topBtn.parentNode === container) return; // 完好 + if (topBtn !== null) removeTopButton(); + topBtn = document.createElement("button"); + topBtn.type = "button"; + topBtn.className = narrow ? "dsst-big dsst-big-narrow" : "dsst-big"; + topBtn.setAttribute("aria-label", STR.bigAria); + topBtn.title = STR.bigTip; + topBtn.innerHTML = ICON_CLASSIC + 'ADE'; + topBtn.addEventListener("click", function (event) { + event.preventDefault(); + event.stopPropagation(); + run("classic"); + }); + // brand 带 margin-right:auto,右侧按钮组从它后面开始;插在它后面就是 + // 右侧按钮组的第一枚,不挤占品牌区。 + var brand = container.querySelector(".bt-brand"); + if (brand !== null && brand.nextSibling !== null) { + container.insertBefore(topBtn, brand.nextSibling); + } else { + container.appendChild(topBtn); + } + wake(); + } + // ---------- 空闲变淡 ---------- function wake() { @@ -589,29 +778,46 @@ busy = true; wake(); pill.setAttribute("data-busy", ""); + if (topBtn !== null) topBtn.setAttribute("data-busy", ""); + var flipped = false; + var domError = null; + var stealth = target === "classic"; // 只有切经典要开弹层,才需要隐身 + if (stealth) stealthOn(); try { log("switching", current, "->", target); - await switchTo(target); - var flipped = await waitFor(function () { - return detectMode() === target; - }, 2500); - if (!flipped) { - await switchTo(target); // 失败重试一次 + try { + await switchTo(target); flipped = await waitFor(function () { return detectMode() === target; }, 2500); + if (!flipped) { + await switchTo(target); // 失败重试一次 + flipped = await waitFor(function () { + return detectMode() === target; + }, 2500); + } + } catch (error) { + domError = error; + flipped = false; + } + if (!flipped) { + // DOM 路径失败 → 兜底:写 ds-bot 的持久化键并刷新页面(页面马上重开)。 + if (persistAndReload(target === "classic" ? "agent" : "bot")) return; + throw domError !== null ? domError : new Error(STR.notFlipped(target)); } - if (!flipped) throw new Error(STR.notFlipped(target)); mode = target; lastPaint = null; refresh(); log("switched to", target); } catch (error) { warn("switch failed:", error && error.message ? error.message : error); + dismissPopups(); flashError(error); } finally { + if (stealth) stealthOff(); busy = false; if (pill !== null) pill.removeAttribute("data-busy"); + if (topBtn !== null) topBtn.removeAttribute("data-busy"); scheduleIdle(); } } @@ -784,6 +990,9 @@ root.removeEventListener("pointermove", onPointerMove, true); root.removeEventListener("pointerup", onPointerUp, true); root.removeEventListener("pointercancel", onPointerUp, true); + removeTopButton(); + var stealthNode = document.getElementById(STEALTH_ID); + if (stealthNode !== null && stealthNode.parentNode !== null) stealthNode.parentNode.removeChild(stealthNode); if (pill !== null && pill.parentNode !== null) pill.parentNode.removeChild(pill); if (style !== null && style.parentNode !== null) style.parentNode.removeChild(style); pill = null; diff --git a/package.json b/package.json index cfd5e0e..116eefe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-bot-toggle", - "version": "0.4.1", + "version": "0.4.3", "description": "A floating Bot / ADE switch for the DSH web UI: toggles ds-bot's Bot surface and the classic DSH surface without unloading ds-bot.", "license": "MIT", "author": "JackWoods-code", @@ -53,7 +53,9 @@ "compatibility": { "dshReleases": { "0.2.1-rc.2": "compatible", - "0.2.1-rc.3": "compatible" + "0.2.1-rc.3": "compatible", + "0.2.1-rc.4": "compatible", + "0.2.2-rc.1": "compatible" } } }, diff --git a/test/smoke.mjs b/test/smoke.mjs index 5aeb928..0456ea3 100644 --- a/test/smoke.mjs +++ b/test/smoke.mjs @@ -14,6 +14,7 @@ function makeEl(tag) { const listeners = {}; return { tagName: tag, + id: "", children: [], parentNode: null, className: "", @@ -76,11 +77,18 @@ function makeEl(tag) { function makeEnv({ lang = "en-US", bootEntries = null } = {}) { const matches = new Map(); + const created = []; const doc = { documentElement: { lang }, head: makeEl("head"), body: makeEl("body"), - createElement: (tag) => makeEl(tag), + createElement: (tag) => { + const el = makeEl(tag); + created.push(el); + return el; + }, + // 0.4.3 起 client.js 用 getElementById 管理临时样式节点(隐身模式) + getElementById: (id) => created.find((el) => el.id === id) ?? null, addEventListener() {}, removeEventListener() {}, querySelector(selector) { From 427784741b21860bf45e412324a8d35887c2ecf0 Mon Sep 17 00:00:00 2001 From: Nickszy <1344398321@qq.com> Date: Mon, 9 Nov 2026 22:54:00 +0800 Subject: [PATCH 2/2] fix: keyboard shortcut works on macOS (0.4.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the hotkey on the physical key (event.code === KeyB) instead of the character it produces: on macOS Option is a character modifier and Option+Shift+B reports key "ı", so the old `event.key === "b"` check never fired there and the shortcut only worked on Windows. - macOS additionally accepts Cmd+Shift+B. - Tooltips name the platform's own modifiers (⌥⇧B / ⌘⇧B on macOS, Alt+Shift+B elsewhere). - Auto-repeat and IME-composing key events are ignored. - Smoke tests cover the Windows, macOS and no-`code` fallback paths. --- CHANGELOG.md | 14 ++++++++++ README.md | 4 +-- README.zh.md | 4 +-- lib/client.js | 63 +++++++++++++++++++++++++++++++++++-------- package.json | 2 +- test/smoke.mjs | 72 ++++++++++++++++++++++++++++++++++++++++++++++---- 6 files changed, 138 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47ab08e..71e7812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this project. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.4] — 2026-11-09 + +### Fixed + +- **Keyboard shortcut on macOS**: the hotkey used to be matched with `event.key === "b"`, + but on macOS Option is a character modifier — `⌥⇧B` reports `key: "ı"`, so the shortcut + never fired there (it only worked on Windows, where Alt leaves the character alone). + Matching is now done on the physical key (`event.code === "KeyB"`), which is independent + of keyboard layout and of what the modifier produces; macOS additionally accepts `⌘⇧B`. +- Tooltips now name the platform's own modifiers: `⌥⇧B / ⌘⇧B` on macOS, `Alt+Shift+B` + elsewhere. +- Held keys (auto-repeat) and keystrokes typed while an input method is composing are + ignored, so holding the shortcut no longer flips the surface back and forth. + ## [0.4.3] — 2026-11-08 ### Added diff --git a/README.md b/README.md index ab25f44..6decd9a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ It is a UI layer on top of ds-bot, not a replacement for it: ds-bot keeps runnin - One-click switch between **Bot** and **ADE**; the active side is highlighted - Available in both surfaces as a fixed overlay, kept clear of the content area - Drag it anywhere, snap to an edge; right-click resets to the default position (remembered) -- Keyboard shortcut `Alt+Shift+B` +- Keyboard shortcut `Alt+Shift+B` on Windows / Linux, `⌥⇧B` or `⌘⇧B` on macOS - Fades after 4 s of no interaction, restores on hover; respects `prefers-reduced-motion` - Bilingual UI (Chinese / English) following the page or browser language - ds-bot compatibility probe: reports whether ds-bot is loaded and, when obtainable, its version; @@ -58,7 +58,7 @@ If the plugin page says the change takes effect at the next start, restart DSH. - Click **Bot** to enter the ds-bot Bot surface, **ADE** to return to the classic DSH interface. - Drag the capsule to move it; right-click it to reset the position to the right edge, centered. -- Press `Alt+Shift+B` to toggle from anywhere. +- Press `Alt+Shift+B` (on macOS `⌥⇧B` or `⌘⇧B`) to toggle from anywhere. ## Debugging diff --git a/README.zh.md b/README.zh.md index 832d7ee..fc64d4a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -26,7 +26,7 @@ ds-bot 自带两套界面,却没有一个常驻开关:在 Bot 界面里只 - 点 **Bot** / **ADE** 一键切换,当前界面高亮 - 固定浮层,两套界面里都可见,且避开正文区域 - 可拖动到任意位置并吸附边缘;右键复位到右侧居中(位置记忆在 localStorage) -- 快捷键 `Alt+Shift+B` +- 快捷键 `Alt+Shift+B`(Windows / Linux),macOS 为 `⌥⇧B` 或 `⌘⇧B` - 4 秒无交互自动变淡,鼠标移入恢复;尊重 `prefers-reduced-motion` - 界面文案中英双语,跟随页面或浏览器语言 - ds-bot 兼容性探测:能拿到时报告是否加载、版本号;控件缺失时胶囊转琥珀色并说明原因, @@ -58,7 +58,7 @@ DSH 里:**设置 → 插件 → 添加插件**,然后二选一 - 点 **Bot** 进入 ds-bot 的 Bot 界面,点 **ADE** 回到经典 DSH 界面。 - 拖动胶囊可移动位置;右键复位到右缘居中。 -- 任意位置按 `Alt+Shift+B` 切换。 +- 任意位置按 `Alt+Shift+B`(macOS 为 `⌥⇧B` 或 `⌘⇧B`)切换。 ## 排错 diff --git a/lib/client.js b/lib/client.js index f1f4ebb..fdac394 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1,4 +1,10 @@ -// dsh-bot-toggle — 浏览器侧(v0.4.3)。 +// dsh-bot-toggle — 浏览器侧(v0.4.4)。 +// +// v0.4.4:快捷键跨平台。以前用 event.key 判定 Alt+Shift+B,可 macOS 上 Option 会改变 +// 输入的字符(Option+Shift+B 得到 "ı"),事件里的 key 永远不是 b,于是快捷键在 mac +// 上完全失效(Windows 的 Alt 不改字符,所以只有 Windows 好使)。现在按物理键位 +// event.code === "KeyB" 判定,与键盘布局无关;macOS 额外接受 ⌘+Shift+B;忽略长按 +// 自动重复与输入法合成中的按键;提示文案按平台显示 ⌥⇧B / ⌘⇧B。 // // 作用:在 ds-bot 的两套界面之间放一枚常驻的分段开关,ds-bot 插件始终加载: // Bot = ds-bot 的 Bot 界面;ADE = 经典 DSH 界面。 @@ -46,7 +52,7 @@ var exports = module.exports; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); - var VERSION = "0.4.3"; + var VERSION = "0.4.4"; var LOG = "[bot-toggle]"; var POS_KEY = "dsh-bot-toggle:pos"; var SURFACE_KEY = "ds-bot:surface"; // ds-bot 自己的界面持久化键(bot/agent) @@ -72,13 +78,30 @@ return "en"; } })(); + + // 快捷键:Windows/Linux 是 Alt+Shift+B;macOS 的 Alt 即 ⌥,另加 ⌘+Shift+B, + // 因为 mac 上 Option 常被系统输入法占用。提示文案跟着平台走,免得 mac 用户去 + // 找一个不存在的 Alt 键。 + var IS_MAC = (function () { + try { + var nav = (root && root.navigator) || {}; + var platform = String( + (nav.userAgentData && nav.userAgentData.platform) || nav.platform || nav.userAgent || "" + ); + return /mac|iphone|ipad|ipod/i.test(platform); + } catch (_) { + return false; + } + })(); + var HOTKEY_LABEL = IS_MAC ? "⌥⇧B / ⌘⇧B" : "Alt+Shift+B"; + var MESSAGES = { zh: { pillAria: "界面切换:Bot(Bot 界面)/ ADE(经典 DSH 界面)", botAria: "切换到 Bot 界面(ds-bot)", - botTip: "Bot 界面 · Alt+Shift+B 切换", + botTip: "Bot 界面 · " + HOTKEY_LABEL + " 切换", classicAria: "切换到经典 DSH 界面", - classicTip: "经典 DSH 界面 · Alt+Shift+B 切换", + classicTip: "经典 DSH 界面 · " + HOTKEY_LABEL + " 切换", resetDone: "已复位到默认位置", errNoDsBot: "ds-bot 未加载", warnNoDsBot: "ds-bot 没有加载(插件页里未启用),没有可切换的界面。", @@ -88,7 +111,7 @@ errNoMenu: "账号菜单里没有「Bot 设置」项", errNoReload: "兜底切换失败:无法写入 ds-bot:surface", bigAria: "切换到经典 DSH 界面(ADE)", - bigTip: "经典 DSH 界面(ADE)· Alt+Shift+B 可切回 Bot", + bigTip: "经典 DSH 界面(ADE)· " + HOTKEY_LABEL + " 可切回 Bot", notFlipped: function (target) { return "切换未生效(目标:" + target + ")"; }, incompat: function (version) { var suffix = version ? "(ds-bot " + version + ")" : ""; @@ -98,9 +121,9 @@ en: { pillAria: "Surface switch: Bot (ds-bot) / ADE (classic DSH UI)", botAria: "Switch to the Bot surface (ds-bot)", - botTip: "Bot surface · Alt+Shift+B", + botTip: "Bot surface · " + HOTKEY_LABEL, classicAria: "Switch to the classic DSH surface", - classicTip: "Classic DSH surface · Alt+Shift+B", + classicTip: "Classic DSH surface · " + HOTKEY_LABEL, resetDone: "Position reset", errNoDsBot: "ds-bot is not loaded", warnNoDsBot: "ds-bot is not loaded (enable it on the Plugins page); nothing to switch.", @@ -110,7 +133,7 @@ errNoMenu: 'No "Bot settings" item in the account menu', errNoReload: "Fallback switch failed: cannot write ds-bot:surface", bigAria: "Switch to the classic DSH surface (ADE)", - bigTip: "Classic DSH surface (ADE) · Alt+Shift+B switches back to Bot", + bigTip: "Classic DSH surface (ADE) · " + HOTKEY_LABEL + " switches back to Bot", notFlipped: function (target) { return "Switch did not take effect (target: " + target + ")"; }, incompat: function (version) { var suffix = version ? " (ds-bot " + version + ")" : ""; @@ -822,9 +845,27 @@ } } + // 快捷键判定:认物理键位,不认字符。 + // macOS 上 Option 会改写输入字符——⌥⇧B 得到的 event.key 是 "ı" 而不是 "B", + // 旧实现(event.key === "b"/"B")因此在 mac 上永远不触发(Windows 的 Alt 不 + // 改字符,所以只有 Windows 好使)。event.code 报的是键位 "KeyB",与键盘布局、 + // 修饰键组合都无关,是跨平台唯一可靠的判据。 + var KEY_B_CHARS = { b: true, B: true, "ı": true, "∫": true }; + function isToggleHotkey(event) { + if (!event || typeof event !== "object") return false; + if (event.repeat === true) return false; // 长按不连续切换 + if (event.isComposing === true) return false; // 输入法合成中不抢键 + if (!event.shiftKey) return false; + // Windows/Linux:Alt+Shift+B;macOS:⌥+Shift+B 或 ⌘+Shift+B + if (!event.altKey && !(IS_MAC && event.metaKey)) return false; + if (event.ctrlKey === true) return false; // Ctrl+Alt+Shift+B 留给系统与其它插件 + if (typeof event.code === "string" && event.code !== "") return event.code === "KeyB"; + // 合成事件/老环境没有 code 时退回字符判定(含 macOS 组合后的字符) + return KEY_B_CHARS[String(event.key || "")] === true; + } + function onKeyDown(event) { - if (!event.altKey || !event.shiftKey) return; - if (event.key !== "b" && event.key !== "B") return; + if (!isToggleHotkey(event)) return; event.preventDefault(); event.stopPropagation(); var current = detectMode(); @@ -967,7 +1008,7 @@ reset: resetPosition, probe: probeDsBot, }; - log("v" + VERSION + " mounted; ds-bot surface =", detectMode(), "· Alt+Shift+B 切换"); + log("v" + VERSION + " mounted; ds-bot surface =", detectMode(), "· " + HOTKEY_LABEL + " 切换"); } function dispose() { diff --git a/package.json b/package.json index 116eefe..5c92fb9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-bot-toggle", - "version": "0.4.3", + "version": "0.4.4", "description": "A floating Bot / ADE switch for the DSH web UI: toggles ds-bot's Bot surface and the classic DSH surface without unloading ds-bot.", "license": "MIT", "author": "JackWoods-code", diff --git a/test/smoke.mjs b/test/smoke.mjs index 0456ea3..9c0180a 100644 --- a/test/smoke.mjs +++ b/test/smoke.mjs @@ -75,9 +75,10 @@ function makeEl(tag) { }; } -function makeEnv({ lang = "en-US", bootEntries = null } = {}) { +function makeEnv({ lang = "en-US", bootEntries = null, platform = "Win32" } = {}) { const matches = new Map(); const created = []; + const docListeners = new Map(); const doc = { documentElement: { lang }, head: makeEl("head"), @@ -89,8 +90,16 @@ function makeEnv({ lang = "en-US", bootEntries = null } = {}) { }, // 0.4.3 起 client.js 用 getElementById 管理临时样式节点(隐身模式) getElementById: (id) => created.find((el) => el.id === id) ?? null, - addEventListener() {}, - removeEventListener() {}, + addEventListener(type, fn) { + const list = docListeners.get(type) || []; + list.push(fn); + docListeners.set(type, list); + }, + removeEventListener(type, fn) { + const list = docListeners.get(type) || []; + const index = list.indexOf(fn); + if (index >= 0) list.splice(index, 1); + }, querySelector(selector) { return matches.get(selector) ?? null; }, @@ -109,7 +118,7 @@ function makeEnv({ lang = "en-US", bootEntries = null } = {}) { __DSH_BOOT__: bootEntries ? { entries: bootEntries } : undefined, dshBot: undefined, document: doc, - navigator: { language: lang }, + navigator: { language: lang, platform }, innerWidth: 1440, innerHeight: 900, localStorage: { @@ -124,7 +133,21 @@ function makeEnv({ lang = "en-US", bootEntries = null } = {}) { addEventListener() {}, removeEventListener() {}, }; - return { win, doc, matches }; + // 模拟一次 keydown:返回事件对象,defaultPrevented 即「插件认领了这个键」。 + function dispatchKey(partial) { + const event = { + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true; + }, + stopPropagation() {}, + ...partial, + }; + for (const fn of [...(docListeners.get("keydown") || [])]) fn(event); + return event; + } + + return { win, doc, matches, dispatchKey }; } // 每次用不同的 query 导入,拿到全新的模块实例,避免桩状态跨用例串味。 @@ -266,3 +289,42 @@ test("卸载清理:DOM 与调试对象都移除", async () => { assert.equal(env.doc.head.children.length, 0, "样式应被移除"); assert.equal(env.win.__botToggle, undefined, "调试对象应被移除"); }); + +// 快捷键回归:macOS 的 Option 会改写输入字符(⌥⇧B 的 event.key 是 "ı" 而不是 "B"), +// 旧实现按 event.key 判定,所以快捷键只在 Windows 好使。现在按物理键位 event.code 判定。 +test("快捷键:Windows 上 Alt+Shift+B 生效,其它组合不认", async () => { + const env = makeEnv({ lang: "zh-CN", platform: "Win32" }); + const app = apply(await loadClient(env, "hotkey-win")); + try { + assert.equal(env.dispatchKey({ altKey: true, shiftKey: true, code: "KeyB", key: "b" }).defaultPrevented, true); + assert.equal(env.dispatchKey({ altKey: true, shiftKey: true, code: "KeyB", key: "B" }).defaultPrevented, true); + assert.equal(env.dispatchKey({ altKey: true, shiftKey: false, code: "KeyB", key: "b" }).defaultPrevented, false); + assert.equal(env.dispatchKey({ altKey: true, shiftKey: true, code: "KeyX", key: "x" }).defaultPrevented, false); + assert.equal(env.dispatchKey({ metaKey: true, shiftKey: true, code: "KeyB", key: "b" }).defaultPrevented, false); + assert.equal(env.dispatchKey({ ctrlKey: true, altKey: true, shiftKey: true, code: "KeyB", key: "b" }).defaultPrevented, false); + assert.equal(env.dispatchKey({ altKey: true, shiftKey: true, code: "KeyB", key: "b", repeat: true }).defaultPrevented, false); + assert.equal(env.dispatchKey({ altKey: true, shiftKey: true, code: "KeyB", key: "b", isComposing: true }).defaultPrevented, false); + } finally { + app.dispose(); + } +}); + +test("快捷键:macOS 上 ⌥⇧B(key 变成 ı)与 ⌘⇧B 都生效,提示文案为 mac 写法", async () => { + const env = makeEnv({ lang: "zh-CN", platform: "MacIntel" }); + const app = apply(await loadClient(env, "hotkey-mac")); + try { + assert.equal(env.dispatchKey({ altKey: true, shiftKey: true, code: "KeyB", key: "ı" }).defaultPrevented, true); + assert.equal(env.dispatchKey({ metaKey: true, shiftKey: true, code: "KeyB", key: "B" }).defaultPrevented, true); + // 没有 code 的老环境:退回字符判定,mac 组合后的字符也要认 + assert.equal(env.dispatchKey({ altKey: true, shiftKey: true, key: "ı" }).defaultPrevented, true); + assert.equal(env.dispatchKey({ altKey: true, shiftKey: true, code: "KeyX", key: "x" }).defaultPrevented, false); + + const pill = findPill(env.doc); + const buttons = pill.children.filter((child) => child.tagName === "button"); + buttons[0].dispatch("mouseenter"); + const tip = pill.children.find((child) => child.className === "dsst-tip"); + assert.equal(tip.textContent, "Bot 界面 · ⌥⇧B / ⌘⇧B 切换"); + } finally { + app.dispose(); + } +});