此用户脚本会在 Discourse 论坛页面上直接显示快捷键:
默认情况下它是禁用的,点击右上角的图标即可切换开关。 ![]()
它并不追求美观。只需在你想要使用并记忆快捷键时启用它即可 ![]()
它不会显示所有现有的快捷键,但会显示大多数。
与书签相关的快捷键尚未(?)包含在内,因为该功能属于网站的一个小众板块,快捷键数量较多。
在快捷键列表 ? 中,有几个我不太理解的快捷键:
-
Ctrl + l → 筛选侧边栏
-
主题选择:
Shift + d → 忽略选中的主题 -
a 将选中的内容插入到打开的编辑器中
如果有人能向我解释它们的作用 ![]()
完整代码
// ==UserScript==
// @name Discourse Shortcut Overlay
// @namespace https://meta.discourse.org/
// @version 1.1
// @description 将键盘快捷键提示直接注入 Discourse UI 元素,以便在上下文中显示快捷键。
// @match https://meta.discourse.org/*
// @grant none
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function discourseShortcutOverlay() {
"use strict";
if (window.__dso__) {
window.__dso__.stop();
}
// ── CSS ─────────────────────────────────────────────────────────────────────
const css = document.createElement("style");
css.id = "__dso_style";
css.textContent = `
.dso-wrap {
display: inline-flex; align-items: center; gap: 2px;
pointer-events: none; flex-shrink: 0; user-select: none; vertical-align: text-bottom;
}
.dso-inline { margin-left: auto; padding-left: 6px; }
.dso-abs { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); z-index: 99; }
.dso-abs-br { position: absolute; right: 4px; bottom: 4px; z-index: 99; }
.dso-abs-br.dso-hint-eq { right: unset; left: 4px; }
.dso-abs-tr { position: absolute; right: 4px; top: 4px; z-index: 99; }
.dso-wrap kbd {
display: inline-flex; align-items: center; justify-content: center;
padding: 0 5px; font: 700 11px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
color: #fff; border-radius: 4px; border-color: rgba(255,255,255,.22);
box-shadow: 0 1px 3px rgba(0,0,0,.2); min-height: 20px; white-space: nowrap; letter-spacing: 0;
}
.dso-wrap.nav kbd { background: color-mix(in srgb, var(--success) 70%, var(--secondary)); }
.dso-wrap.action kbd { background: color-mix(in srgb, var(--tertiary) 70%, var(--secondary)); }
.dso-wrap.write kbd { background: color-mix(in srgb, #8848b8 70%, var(--secondary)); }
.dso-wrap.post kbd { background: color-mix(in srgb, var(--danger) 70%, var(--secondary)); }
.dso-wrap .dso-plus { font: 700 9px/1 ui-monospace, monospace; color: rgba(255,255,255,.5); margin: 0 1px; }
.dso-wrap .dso-seq { font: 700 9px/1 ui-monospace, monospace; color: rgba(255,255,255,.45); margin: 0 2px; }
.dso-gnav-label { font: 10px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; color: rgba(255,255,255,.35); }
.dso-gnav-panel { display: flex; flex-wrap: wrap; gap: 4px 14px; padding: 4px 0 6px; vertical-align: unset; }
.dso-gnav-panel .dso-gnav-row { display: inline-flex; align-items: center; gap: 5px; }
.dso-gnav-panel .dso-gnav-keys { display: inline-flex; align-items: center; }
.timeline-container .topic-timeline .timeline-scroller-content { overflow: visible !important; }
.timeline-container .topic-timeline .timeline-date-wrapper { max-width: none !important; }
body.dso-hidden .dso-wrap { display: none !important; }
#dso-toggle-btn {
position: fixed; top: calc(14px + 3.5em); right: 14px; z-index: 99999; cursor: pointer;
background: var(--secondary); border: 1px solid var(--primary-low); border-radius: 6px;
color: var(--primary-medium); padding: 5px 7px; line-height: 1;
box-shadow: 0 2px 6px rgba(0,0,0,.1);
}
#dso-toggle-btn:hover { color: var(--primary); border-color: var(--primary-medium); }
#dso-toggle-btn.--off { color: var(--primary-low); border-color: var(--primary-very-low, var(--primary-low)); }
`;
document.head.appendChild(css);
// ── 提示注册表 ─────────────────────────────────────────────────────────────
const HINTS = [];
const $ = (s) => document.querySelector(s);
function keysToClass(keys) {
return (
"dso-hint-" +
keys
.map((k) =>
k
.replace(/⇧\s*/g, "shift-")
.replace(/↵/g, "enter")
.replace(/↓/g, "down")
.replace(/↑/g, "up")
.replace(/\//g, "slash")
.replace(/\?/g, "question")
.replace(/!/g, "bang")
.replace(/#/g, "hash")
.replace(/\./g, "dot")
.replace(/=/g, "eq")
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
)
.join("-")
);
}
// 单目标提示
function hint(
keys,
theme,
targetFn,
mode = "inline",
beforeSel = null,
seq = false,
title = ""
) {
HINTS.push({
keys,
theme,
targetFn,
mode,
beforeSel,
seq,
title,
hintClass: keysToClass(keys),
customBuild: null,
state: { el: null, wrap: null },
});
}
// 多目标提示 – 注入到每个匹配 multiSel 的元素之后
function hintMulti(
keys,
theme,
multiSel,
mode = "after",
seq = false,
title = ""
) {
HINTS.push({
keys,
theme,
multiSel,
mode,
seq,
title,
hintClass: keysToClass(keys),
state: { pairs: [] },
});
}
// 自定义面板提示
function hintPanel(theme, mode, hintClass, targetFn, buildFn, opts = {}) {
HINTS.push({
keys: null,
theme,
mode,
beforeSel: opts.beforeSel ?? null,
seq: opts.seq ?? false,
hintClass,
targetFn,
customBuild: buildFn,
state: { el: null, wrap: null },
});
}
// ── DOM 构建器 ──────────────────────────────────────────────────────────────
// 微助手:创建带有属性赋值的元素
function el(tag, props = {}) {
return Object.assign(document.createElement(tag), props);
}
function buildWrap(keys, theme, mode, seq = false, title = "") {
const modeClass =
{ abs: "dso-abs", "abs-br": "dso-abs-br", "abs-tr": "dso-abs-tr" }[
mode
] ?? (mode === "sibling" || mode === "after" ? "" : "dso-inline");
const wrap = el("span", {
className: `dso-wrap ${theme} ${modeClass}`.trimEnd(),
});
if (title) {
wrap.title = title;
}
keys.forEach((k, i) => {
if (i > 0) {
wrap.appendChild(
el("span", {
className: seq ? "dso-seq" : "dso-plus",
textContent: seq ? "→" : "+",
})
);
}
wrap.appendChild(el("kbd", { textContent: k }));
});
return wrap;
}
// 通用面板:带有可选标签的 kbd 序列行。
// usePlus:使用“+”分隔符(组合键)而不是“→”(序列键)。
function buildPanel(theme, entries, wrapStyle = {}, usePlus = false) {
const wrap = el("span", { className: `dso-wrap ${theme}` });
Object.assign(wrap.style, wrapStyle);
for (const { keys, label } of entries) {
const row = el("span");
Object.assign(row.style, {
display: "inline-flex",
alignItems: "center",
gap: "4px",
});
keys.forEach((k, i) => {
if (i > 0) {
row.appendChild(
el("span", {
className: usePlus ? "dso-plus" : "dso-seq",
textContent: usePlus ? "+" : "→",
})
);
}
row.appendChild(el("kbd", { textContent: k }));
});
if (label) {
row.appendChild(
el("span", { className: "dso-gnav-label", textContent: label })
);
}
wrap.appendChild(row);
}
return wrap;
}
// G-nav 使用专用的 CSS 类进行其包装布局
function buildGNavPanel() {
const panel = el("div", { className: "dso-wrap dso-gnav-panel nav" });
for (const { keys, label } of [
{ keys: ["u"], label: "返回" },
{ keys: ["g", "h"], label: "首页" },
{ keys: ["g", "l"], label: "最新" },
{ keys: ["g", "n"], label: "新帖" },
{ keys: ["g", "u"], label: "未读" },
{ keys: ["g", "y"], label: "未看" },
{ keys: ["g", "c"], label: "分类" },
{ keys: ["g", "t"], label: "热门" },
]) {
const kg = el("span", { className: "dso-gnav-keys" });
keys.forEach((k, i) => {
if (i > 0) {
kg.appendChild(
el("span", { className: "dso-seq", textContent: "→" })
);
}
kg.appendChild(el("kbd", { textContent: k }));
});
const row = el("div", { className: "dso-gnav-row" });
row.appendChild(kg);
row.appendChild(
el("span", { className: "dso-gnav-label", textContent: label })
);
panel.appendChild(row);
}
return panel;
}
// ── 注入逻辑 ───────────────────────────────────────────────────────────
function inject(h) {
const target = h.targetFn();
const { state, keys, theme, mode, beforeSel } = h;
if (target === state.el) {
return;
} // 无变化 – 已注入
if (state.wrap) {
state.wrap.remove();
}
if (state.el?.dataset.dsoPos !== undefined) {
state.el.style.position = state.el.dataset.dsoPos || "";
delete state.el.dataset.dsoPos;
}
state.el = target;
state.wrap = null;
if (!target) {
return;
}
if (mode === "abs" || mode === "abs-br" || mode === "abs-tr") {
if (getComputedStyle(target).position === "static") {
target.dataset.dsoPos = "";
target.style.position = "relative";
}
}
const wrap = h.customBuild
? h.customBuild()
: buildWrap(keys, theme, mode, h.seq, h.title);
if (h.hintClass) {
wrap.classList.add(h.hintClass);
}
let insertParent, insertRef;
if (mode === "after") {
insertParent = target.parentNode;
insertRef = target.nextSibling;
} else {
const beforeEl = beforeSel ? target.querySelector(beforeSel) : null;
insertParent = beforeEl ? beforeEl.parentNode : target;
insertRef = beforeEl;
}
insertParent.insertBefore(wrap, insertRef);
state.wrap = wrap;
}
function injectMulti(h) {
const { state } = h;
const targets = new Set(document.querySelectorAll(h.multiSel));
state.pairs = state.pairs.filter(({ el: e, wrap }) => {
if (!targets.has(e)) {
wrap.remove();
return false;
}
return true;
});
const covered = new Set(state.pairs.map((p) => p.el));
targets.forEach((target) => {
if (covered.has(target)) {
return;
}
const wrap = buildWrap(h.keys, h.theme, h.mode, h.seq, h.title);
if (h.hintClass) {
wrap.classList.add(h.hintClass);
}
target.parentNode?.insertBefore(wrap, target.nextSibling);
state.pairs.push({ el: target, wrap });
});
}
// ────────────────────────────────────────────────────────────────────────────
// 提示定义
// ────────────────────────────────────────────────────────────────────────────
// ── 页眉 ───────────────────────────────────────────────────────────────────
// / → 搜索(输入框聚焦时隐藏)
hint(
["/"],
"action",
() => {
const input = $("#header-search-input");
if (!input || input === document.activeElement) {
return null;
}
return input.closest(".search-input--header")?.parentElement ?? null;
},
"sibling",
".show-advanced-search",
false,
"搜索"
);
// ↑ / ↓ → 浏览搜索结果
hint(
["↑ / ↓"],
"action",
() =>
$(".search-input--header")
? $(".search-menu-initial-options, .search-menu-assistant") || null
: null,
"abs-tr",
null,
false,
"浏览结果"
);
// Ctrl+↵ → 打开全屏搜索(仅当输入框聚焦时)
hint(
["Ctrl", "↵"],
"action",
() => {
const input = $("#header-search-input");
if (!input || input !== document.activeElement) {
return null;
}
return input;
},
"after",
null,
false,
"全屏搜索"
);
// c → 新主题
hint(
["c"],
"write",
() => $("#create-topic"),
"inline",
null,
false,
"新主题"
);
// ? → 键盘快捷键模态框
hint(
["?"],
"action",
() => $(".keyboard-shortcuts-btn, button[aria-label='Keyboard shortcuts']"),
"inline",
null,
false,
"快捷键"
);
// . → 加载新/更新的主题横幅
hint(
["."],
"action",
() => $(".show-more.has-topics a"),
"inline",
null,
false,
"加载新主题"
);
// ── 主题列表 ───────────────────────────────────────────────────────────────
// 排除 .more-topics__list / .suggested-topics,以免提示渗入该区域。
function mainListRows() {
return [...document.querySelectorAll(".topic-list-item")].filter(
(r) => !r.closest(".more-topics__list, .suggested-topics, .more-topics")
);
}
hint(
["j ↓"],
"nav",
() => {
const rows = mainListRows();
if (!rows.length) {
return null;
}
const selIdx = rows.findIndex((r) => r.classList.contains("selected"));
if (selIdx === -1) {
return rows[0]?.querySelector(".main-link") || null;
}
if (selIdx >= rows.length - 1) {
return null;
}
return rows[selIdx + 1]?.querySelector(".main-link") || null;
},
"abs",
null,
false,
"下一个主题"
);
hint(
["k ↑"],
"nav",
() => {
const rows = mainListRows();
if (!rows.length) {
return null;
}
const selIdx = rows.findIndex((r) => r.classList.contains("selected"));
if (selIdx <= 0) {
return null;
}
return rows[selIdx - 1]?.querySelector(".main-link") || null;
},
"abs",
null,
false,
"上一个主题"
);
// o / Enter → 打开选中的主题
hint(
["o / ↵"],
"nav",
() =>
$(
".topic-list tr.selected a.title, .topic-list-item.selected .main-link a, .topic-list-item.selected .topic-title a"
),
"inline",
null,
false,
"打开主题"
);
// ⇧j / ⇧k → 下一个/上一个导航标签部分
hint(
["⇧ j"],
"nav",
() => {
const pills = [...document.querySelectorAll(".nav.nav-pills li")];
const i = pills.findIndex((p) => p.classList.contains("active"));
return pills[i + 1] || null;
},
"inline",
null,
false,
"下一个板块"
);
hint(
["⇧ k"],
"nav",
() => {
const pills = [...document.querySelectorAll(".nav.nav-pills li")];
const i = pills.findIndex((p) => p.classList.contains("active"));
return i > 0 ? pills[i - 1] : null;
},
"inline",
null,
false,
"上一个板块"
);
// ── 编辑器 ──────────────────────────────────────────────────────────────────
hint(
["Ctrl", "↵"],
"write",
() =>
$(".save-or-cancel .create, .reply-area .create, #reply-control .create"),
"inline",
null,
false,
"提交"
);
hint(
["Esc"],
"write",
() => $(".save-or-cancel .cancel, .reply-area .cancel"),
"inline",
null,
false,
"取消"
);
// ⇧c → 返回最小化的编辑器
hint(
["⇧ c"],
"write",
() => $("#reply-control.draft .draft-text"),
"inline",
null,
false,
"返回编辑器"
);
// ⇧F11 → 全屏(当编辑器为草稿时隐藏)
hint(
["⇧ F11"],
"write",
() => ($("#reply-control.draft") ? null : $(".toggle-fullscreen")),
"after",
null,
false,
"全屏"
);
hint(
["Esc"],
"write",
() => $(".toggle-minimize"),
"after",
null,
false,
"最小化"
);
// ── 编辑器选项 (+) 弹出窗口 ────────────────────────────────────────────────
hint(
["Ctrl", "e"],
"write",
() => $('[data-name="format-code"]'),
"abs-br",
null,
false,
"预格式化文本"
);
hint(
["Ctrl", "⇧ 8"],
"write",
() => $('[data-name="apply-unordered-list"]'),
"abs-br",
null,
false,
"无序列表"
);
hint(
["Ctrl", "⇧ 7"],
"write",
() => $('[data-name="apply-ordered-list"]'),
"abs-br",
null,
false,
"有序列表"
);
hint(
["Ctrl", "Alt", "0"],
"write",
() => $('[data-name="heading-paragraph"]'),
"abs-br",
null,
false,
"段落"
);
hint(
["Ctrl", "Alt", "1"],
"write",
() => $('[data-name="heading-1"]'),
"abs-br",
null,
false,
"标题 1"
);
hint(
["Ctrl", "Alt", "2"],
"write",
() => $('[data-name="heading-2"]'),
"abs-br",
null,
false,
"标题 2"
);
hint(
["Ctrl", "Alt", "3"],
"write",
() => $('[data-name="heading-3"]'),
"abs-br",
null,
false,
"标题 3"
);
hint(
["Ctrl", "Alt", "4"],
"write",
() => $('[data-name="heading-4"]'),
"abs-br",
null,
false,
"标题 4"
);
hint(
["Ctrl", "l"],
"write",
() => $('.toolbar__button[title="Hyperlink"]'),
"abs-br",
null,
false,
"插入链接"
);
// ── 帖子操作(注入到每个可见帖子中) ─────────────────────────────
hintMulti(
["r"],
"post",
".topic-post .post-controls button.reply",
"after",
false,
"回复帖子"
);
hintMulti(
["e"],
"post",
".topic-post .post-controls button.edit",
"after",
false,
"编辑帖子"
);
hintMulti(
["l"],
"post",
".topic-post .discourse-reactions-double-button, .topic-post .post-controls button.toggle-like",
"after",
false,
"点赞帖子"
);
hintMulti(
["b"],
"post",
".topic-post .post-controls .post-action-menu__bookmark",
"after",
false,
"收藏帖子"
);
hintMulti(
["s"],
"post",
".topic-post a.post-date",
"after",
false,
"分享帖子"
);
hintMulti(
["q"],
"post",
".topic-post .post-controls button.quote-post",
"after",
false,
"引用帖子"
);
hintMulti(
["!"],
"post",
".topic-post .post-controls .post-action-menu__flag, .topic-post .post-controls button.create-flag",
"after",
false,
"举报帖子"
);
hintMulti(
["d"],
"post",
".topic-post .post-controls .post-action-menu__delete, .topic-post .post-controls button.delete",
"after",
false,
"删除帖子"
);
// ── 主题视图 – 帖子导航 ──────────────────────────────────────────────
hint(
["j ↓"],
"nav",
() => {
if (!$(".timeline-container")) {
return null;
}
const posts = [...document.querySelectorAll(".topic-post")];
const selIdx = posts.findIndex((p) => p.classList.contains("selected"));
if (selIdx === -1) {
return posts[0] || null;
}
return selIdx < posts.length - 1 ? posts[selIdx + 1] : null;
},
"abs",
null,
false,
"下一个帖子"
);
hint(
["k ↑"],
"nav",
() => {
if (!$(".timeline-container")) {
return null;
}
const posts = [...document.querySelectorAll(".topic-post")];
const selIdx = posts.findIndex((p) => p.classList.contains("selected"));
return selIdx > 0 ? posts[selIdx - 1] : null;
},
"abs",
null,
false,
"上一个帖子"
);
// 时间轴滚动条上的 j/k – 定位在其右侧
hintPanel(
"nav",
"abs",
"dso-hint-timeline-jk",
() => $(".timeline-container") && ($(".timeline-scroller-content") || null),
() =>
buildPanel(
"nav",
[
{ keys: ["k ↑"], label: "上一个帖子" },
{ keys: ["j ↓"], label: "下一个帖子" },
],
{
position: "absolute",
left: "calc(100% + 8px)",
top: "50%",
transform: "translateY(-50%)",
flexDirection: "column",
gap: "3px",
zIndex: "99",
}
)
);
// # → 跳转到帖子编号
hint(
["#"],
"nav",
() => $(".timeline-container") && ($(".timeline-date-wrapper") || null),
"inline",
null,
false,
"跳转到帖子"
);
// ⇧l → 转到第一个未读 – 放置在 # 区域内
hint(
["⇧ l"],
"nav",
() => $(".timeline-container") && ($(".timeline-date-wrapper") || null),
"inline",
null,
false,
"第一个未读"
);
// ⇧r → 回复主题(页脚按钮)
hint(
["⇧ r"],
"post",
() =>
$(
".topic-footer-main-buttons button.btn-primary.create, #topic-footer-buttons .btn.reply"
),
"inline",
null,
false,
"回复主题"
);
// ── G 导航 ──────────────────────────────────────────────────────────────
// u + g→h … g→t – 面板注入到 .nav-pills 上方
hintPanel(
"nav",
"sibling",
"dso-hint-gnav",
() =>
$(".navigation-container, .list-controls .container, .navigation-bar"),
buildGNavPanel,
{ beforeSel: ".nav-pills", seq: true }
);
hint(
["g", "b"],
"nav",
() =>
$(
".sidebar-section-link-wrapper a[href*='/bookmarks'], .user-menu a[href*='/bookmarks']"
),
"inline",
null,
true,
"书签"
);
hint(
["g", "m"],
"nav",
() =>
$(
".sidebar-section-link-wrapper a[href*='/messages'], .user-menu a[href*='/messages']"
),
"inline",
null,
true,
"消息"
);
hint(
["g", "d"],
"nav",
() =>
$(
".sidebar-section-link-wrapper a[href*='/drafts'], .user-menu a[href*='/drafts']"
),
"inline",
null,
true,
"草稿"
);
hint(
["g", "p"],
"nav",
() => $("#user-menu-button-profile"),
"inline",
null,
true,
"个人资料"
);
// g→k / g→j – 上一个/下一个主题面板,位于主题标题旁边
hintPanel(
"nav",
"abs",
"dso-hint-topic-nav",
() =>
$(".timeline-container") &&
($(".title-wrapper #topic-title, .title-wrapper .topic-title") || null),
() =>
buildPanel(
"nav",
[
{ keys: ["g", "k"], label: "上一个主题" },
{ keys: ["g", "j"], label: "下一个主题" },
],
{
position: "absolute",
right: "8px",
top: "50%",
transform: "translateY(-50%)",
flexDirection: "column",
gap: "4px",
alignItems: "flex-start",
zIndex: "99",
}
),
{ seq: true }
);
// ── 页眉按钮 ────────────────────────────────────────────────────────────
hint(
["="],
"action",
() => $(".header-sidebar-toggle")?.parentElement ?? null,
"abs-br",
null,
false,
"侧边栏"
);
hint(
["p"],
"action",
() => $("#current-user"),
"abs-br",
null,
false,
"个人资料"
);
// ── 主题操作 ─────────────────────────────────────────────────────────────
// f → 收藏主题
hint(
["f"],
"post",
() =>
$(".timeline-container") &&
($('.topic-footer-main-buttons [data-identifier="bookmark-menu"]') ||
null),
"inline",
null,
false,
"收藏主题"
);
hint(
["⇧ s"],
"post",
() => $("#topic-footer-buttons button.share-and-invite"),
"inline",
null,
false,
"分享主题"
);
hint(
["⇧ p"],
"nav",
() => $(".pinned-button button"),
"inline",
null,
false,
"置顶/取消置顶"
);
hint(
["⇧ u"],
"nav",
() =>
$(".timeline-container") &&
($("#topic-footer-buttons button.defer-topic") || null),
"inline",
null,
false,
"标记为未读"
);
hint(
["⇧ a"],
"action",
() => $(".toggle-admin-menu"),
"abs-br",
null,
false,
"管理操作"
);
hint(
["a"],
"action",
() =>
$(".timeline-container") &&
($("#topic-footer-buttons button.archive-topic") || null),
"inline",
null,
false,
"归档"
);
// m→w / m→t / m→r / m→m – 通知级别面板
hintPanel(
"nav",
"inline",
"dso-hint-tracking",
() => $(".timeline-container") && ($(".timeline-footer-controls") || null),
() =>
buildPanel(
"nav",
[
{ keys: ["m", "w"], label: "关注" },
{ keys: ["m", "t"], label: "跟踪" },
{ keys: ["m", "r"], label: "普通" },
{ keys: ["m", "m"], label: "静音" },
],
{ flexDirection: "column", gap: "3px" }
),
{ seq: true }
);
// ── 批量选择 ───────────────────────────────────────────────────────────────
hint(
["⇧ b"],
"action",
() => $("button.bulk-select"),
"abs-br",
null,
false,
"批量选择"
);
hint(
["⇧ d"],
"action",
() => $("#dismiss-topics-top, #dismiss-new-top, .dismiss-read"),
"inline",
null,
false,
"忽略"
);
hint(
["x"],
"nav",
() =>
$(".topic-list-item.selected td.bulk-select") ||
$(".topic-list-item td.bulk-select"),
"abs-br",
null,
false,
"选择行"
);
// ── 登出 ────────────────────────────────────────────────────────────────────
hint(
["⇧ z", "⇧ z"],
"action",
() => $("li.logout button"),
"inline",
null,
true,
"登出"
);
// ── 聊天 ──────────────────────────────────────────────────────────────────────
hint(
["-"],
"action",
() => $(".chat-header-icon"),
"abs-br",
null,
false,
"切换聊天"
);
// Alt+↑/↓, Alt+⇧↑/↓, ⇧Esc, Ctrl+K – 面板附加到聊天抽屉
hintPanel(
"action",
"inline",
"dso-hint-chat-composer",
() => $(".chat-drawer-content"),
() =>
buildPanel(
"action",
[
{ keys: ["Ctrl", "k"], label: "快速频道" },
{ keys: ["Alt", "↑ / ↓"], label: "切换频道" },
{ keys: ["Alt", "⇧ ↑ / ↓"], label: "切换未读" },
{ keys: ["⇧", "Esc"], label: "全部标记为已读" },
],
{
flexWrap: "wrap",
gap: "6px 12px",
padding: "4px 6px",
borderTop: "1px solid rgba(255,255,255,.08)",
marginTop: "2px",
},
true
)
);
hint(
["Esc"],
"action",
() => $(".c-navbar__close-drawer-button"),
"after",
null,
false,
"关闭聊天"
);
// ── 切换按钮 ─────────────────────────────────────────────────────────────
let visible = localStorage.getItem("dso-visible") === "true";
const toggleBtn = el("button", {
id: "dso-toggle-btn",
title: "切换快捷键覆盖层",
textContent: "⌨",
});
if (!visible) {
toggleBtn.classList.add("--off");
}
document.body.appendChild(toggleBtn);
toggleBtn.addEventListener("click", () => {
window.__dso__.toggle();
});
// ── MutationObserver ──────────────────────────────────────────────────────────
let rafPending = false;
function scheduleRefresh() {
if (!rafPending) {
rafPending = true;
requestAnimationFrame(() => {
rafPending = false;
HINTS.forEach((h) => (h.multiSel ? injectMulti(h) : inject(h)));
});
}
}
const observer = new MutationObserver((mutations) => {
const onlyOurs = mutations.every((m) => {
if (m.type === "attributes") {
return false;
}
return [...m.addedNodes, ...m.removedNodes].every(
(n) => n.nodeType !== 1 || n.classList?.contains("dso-wrap")
);
});
if (!onlyOurs) {
scheduleRefresh();
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["class"],
});
document.addEventListener("focusin", scheduleRefresh, { passive: true });
document.addEventListener("focusout", scheduleRefresh, { passive: true });
document.body.classList.toggle("dso-hidden", !visible);
HINTS.forEach((h) => (h.multiSel ? injectMulti(h) : inject(h)));
// ── 公共 API ────────────────────────────────────────────────────────────────
window.__dso__ = {
stop() {
observer.disconnect();
document.removeEventListener("focusin", scheduleRefresh);
document.removeEventListener("focusout", scheduleRefresh);
HINTS.forEach(({ state }) => {
state.wrap?.remove();
state.pairs?.forEach(({ wrap }) => wrap.remove());
if (state.el?.dataset.dsoPos !== undefined) {
state.el.style.position = state.el.dataset.dsoPos || "";
delete state.el.dataset.dsoPos;
}
});
toggleBtn.remove();
document.body.classList.remove("dso-hidden");
css.remove();
delete window.__dso__;
console.log("%c[DSO] 已移除。", "color:#888");
},
toggle() {
visible = !visible;
localStorage.setItem("dso-visible", visible);
toggleBtn.classList.toggle("--off", !visible);
document.body.classList.toggle("dso-hidden", !visible);
},
};
console.log(
"%c[DSO] Discourse Shortcut Overlay 已激活。",
"color:#6af;font-weight:bold"
);
})();
如果您想在其他 Discourse 论坛上使用它,请替换或添加 URL 匹配项(// @match https://meta.discourse.org/*)。
要安装用户脚本,请使用浏览器扩展:Greasemonkey、Tampermonkey、ScriptCat,或任何提供此功能的外部软件,例如 AdGuard。





