# Overlay для сочетаний клавиш: userscript для изучения горячих клавиш Discourse

**URL:** https://meta.discourse.org/t/shortcuts-overlay-a-userscript-to-help-you-learn-discourse-keyboard-shortcuts/396588
**Category:** Extras
**Tags:** keyboard-shortcuts
**Created:** [20.Февраль.2026 11:43:00 UTC](https://meta.discourse.org/t/shortcuts-overlay-a-userscript-to-help-you-learn-discourse-keyboard-shortcuts/396588 "2026-02-20T11:43:00Z")
**Posts on this page:** 1
**Showing post:** 1

<div class="post-metadata">

### Author: ![Canapin](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/canapin/32/119591_2.png) [@Canapin](https://meta.discourse.org/u/Canapin)
#### Post date: [20.Февраль.2026 11:43:00 UTC](https://meta.discourse.org/t/shortcuts-overlay-a-userscript-to-help-you-learn-discourse-keyboard-shortcuts/396588/1 "2026-02-20T11:43:00Z")

</div>

> **[Discourse Shortcut Overlay](https://greasyfork.org/en/scripts/566848-discourse-shortcut-overlay)**
>
> Injects kbd hints directly into Discourse UI elements to show keyboard shortcuts in context.

Этот userscript показывает горячие клавиши Discourse прямо на форуме:

 ![image](https://global.discourse-cdn.com/meta/original/4X/b/3/6/b363c055d3e9389050682f9351e7d8488e78cca8.png)  
 ![image](https://global.discourse-cdn.com/meta/original/4X/2/0/1/201fdbb291a6e641176eb6c3443a3a7be05c0b87.png)

По умолчанию он отключен, нажмите на иконку в правом верхнем углу, чтобы включить/выключить. ![image](https://global.discourse-cdn.com/meta/original/4X/d/1/c/d1c9822ec764e6333b044132742a7ed3f1c5b6bb.png)

Он не претендует на красоту. Просто включите его, когда захотите использовать и запомнить горячие клавиши 🙂

Он не показывает _все_ существующие горячие клавиши, но большинство из них.  
Горячие клавиши, связанные с закладками, не включены (пока?), их очень много для такой узкоспециализированной части сайта.

Есть несколько горячих клавиш, которые я не понимаю в списке горячих клавиш ?:

- Ctrl + l → Фильтр боковой панели

- Выбор темы:  
Shift + d → Отклонить выбранные темы

- a Вставить выделение в открытый редактор

Если кто-то может объяснить, что они делают 🤔

> **Полный код**
>
> ```js
> // ==UserScript==
> // @name Discourse Shortcut Overlay
> // @namespace https://meta.discourse.org/
> // @version 1.1
> // @description Injects kbd hints directly into Discourse UI elements to show keyboard shortcuts in context.
> // @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);
> 
> // ── Hint registry ─────────────────────────────────────────────────────────────
> 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("-")
> );
> }
> 
> // Single-target hint
> 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 },
> });
> }
> 
> // Multi-target hint – injects after every element matching multiSel
> function hintMulti(
> keys,
> theme,
> multiSel,
> mode = "after",
> seq = false,
> title = ""
> ) {
> HINTS.push({
> keys,
> theme,
> multiSel,
> mode,
> seq,
> title,
> hintClass: keysToClass(keys),
> state: { pairs: [] },
> });
> }
> 
> // Custom-panel hint
> 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 builders ──────────────────────────────────────────────────────────────
> 
> // Micro-helper: create an element with properties assigned
> 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;
> }
> 
> // Generic panel: rows of kbd sequences with optional labels.
> // usePlus: use "+" separators (combos) instead of "→" (sequences).
> 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 uses dedicated CSS classes for its wrapping layout
> function buildGNavPanel() {
> const panel = el("div", { className: "dso-wrap dso-gnav-panel nav" });
> for (const { keys, label } of [
> { keys: ["u"], label: "Back" },
> { keys: ["g", "h"], label: "Home" },
> { keys: ["g", "l"], label: "Latest" },
> { keys: ["g", "n"], label: "New" },
> { keys: ["g", "u"], label: "Unread" },
> { keys: ["g", "y"], label: "Unseen" },
> { keys: ["g", "c"], label: "Categories" },
> { keys: ["g", "t"], label: "Top" },
> ]) {
> 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;
> }
> 
> // ── Injection logic ───────────────────────────────────────────────────────────
> function inject(h) {
> const target = h.targetFn();
> const { state, keys, theme, mode, beforeSel } = h;
> 
> if (target === state.el) {
> return;
> } // no change – already injected
> 
> 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 DEFINITIONS
> // ────────────────────────────────────────────────────────────────────────────
> 
> // ── Header ───────────────────────────────────────────────────────────────────
> 
> // / → search (hidden when the input is focused)
> 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,
> "Search"
> );
> 
> // ↑ / ↓ → navigate search results
> hint(
> ["↑ / ↓"],
> "action",
> () =>
> $(".search-input--header")
> ? $(".search-menu-initial-options, .search-menu-assistant") || null
> : null,
> "abs-tr",
> null,
> false,
> "Navigate results"
> );
> 
> // Ctrl+↵ → open full page search (only when input is focused)
> hint(
> ["Ctrl", "↵"],
> "action",
> () => {
> const input = $("#header-search-input");
> if (!input || input !== document.activeElement) {
> return null;
> }
> return input;
> },
> "after",
> null,
> false,
> "Full page search"
> );
> 
> // c → new topic
> hint(
> ["c"],
> "write",
> () => $("#create-topic"),
> "inline",
> null,
> false,
> "New topic"
> );
> 
> // ? → keyboard shortcuts modal
> hint(
> ["?"],
> "action",
> () => $(".keyboard-shortcuts-btn, button[aria-label='Keyboard shortcuts']"),
> "inline",
> null,
> false,
> "Shortcuts"
> );
> 
> // . → load new/updated topics banner
> hint(
> ["."],
> "action",
> () => $(".show-more.has-topics a"),
> "inline",
> null,
> false,
> "Load new topics"
> );
> 
> // ── Topic list ───────────────────────────────────────────────────────────────
> // Excludes .more-topics__list / .suggested-topics so hints don't bleed into that area.
> 
> 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,
> "Next topic"
> );
> 
> 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,
> "Prev topic"
> );
> 
> // o / Enter → open selected topic
> 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,
> "Open topic"
> );
> 
> // ⇧j / ⇧k → next/previous nav-pill section
> 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,
> "Next section"
> );
> 
> 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,
> "Prev section"
> );
> 
> // ── Composer ──────────────────────────────────────────────────────────────────
> 
> hint(
> ["Ctrl", "↵"],
> "write",
> () =>
> $(".save-or-cancel .create, .reply-area .create, #reply-control .create"),
> "inline",
> null,
> false,
> "Submit"
> );
> 
> hint(
> ["Esc"],
> "write",
> () => $(".save-or-cancel .cancel, .reply-area .cancel"),
> "inline",
> null,
> false,
> "Cancel"
> );
> 
> // ⇧c → return to minimized composer
> hint(
> ["⇧ c"],
> "write",
> () => $("#reply-control.draft .draft-text"),
> "inline",
> null,
> false,
> "Return to composer"
> );
> 
> // ⇧F11 → fullscreen (hidden when composer is a draft)
> hint(
> ["⇧ F11"],
> "write",
> () => ($("#reply-control.draft") ? null : $(".toggle-fullscreen")),
> "after",
> null,
> false,
> "Fullscreen"
> );
> 
> hint(
> ["Esc"],
> "write",
> () => $(".toggle-minimize"),
> "after",
> null,
> false,
> "Minimize"
> );
> 
> // ── Composer options (+) popup ────────────────────────────────────────────────
> 
> hint(
> ["Ctrl", "e"],
> "write",
> () => $('[data-name="format-code"]'),
> "abs-br",
> null,
> false,
> "Preformatted text"
> );
> hint(
> ["Ctrl", "⇧ 8"],
> "write",
> () => $('[data-name="apply-unordered-list"]'),
> "abs-br",
> null,
> false,
> "Bulleted list"
> );
> hint(
> ["Ctrl", "⇧ 7"],
> "write",
> () => $('[data-name="apply-ordered-list"]'),
> "abs-br",
> null,
> false,
> "Ordered list"
> );
> hint(
> ["Ctrl", "Alt", "0"],
> "write",
> () => $('[data-name="heading-paragraph"]'),
> "abs-br",
> null,
> false,
> "Paragraph"
> );
> hint(
> ["Ctrl", "Alt", "1"],
> "write",
> () => $('[data-name="heading-1"]'),
> "abs-br",
> null,
> false,
> "Heading 1"
> );
> hint(
> ["Ctrl", "Alt", "2"],
> "write",
> () => $('[data-name="heading-2"]'),
> "abs-br",
> null,
> false,
> "Heading 2"
> );
> hint(
> ["Ctrl", "Alt", "3"],
> "write",
> () => $('[data-name="heading-3"]'),
> "abs-br",
> null,
> false,
> "Heading 3"
> );
> hint(
> ["Ctrl", "Alt", "4"],
> "write",
> () => $('[data-name="heading-4"]'),
> "abs-br",
> null,
> false,
> "Heading 4"
> );
> hint(
> ["Ctrl", "l"],
> "write",
> () => $('.toolbar__button[title="Hyperlink"]'),
> "abs-br",
> null,
> false,
> "Insert link"
> );
> 
> // ── Post actions (injected on every visible post) ─────────────────────────────
> 
> hintMulti(
> ["r"],
> "post",
> ".topic-post .post-controls button.reply",
> "after",
> false,
> "Reply to post"
> );
> hintMulti(
> ["e"],
> "post",
> ".topic-post .post-controls button.edit",
> "after",
> false,
> "Edit post"
> );
> hintMulti(
> ["l"],
> "post",
> ".topic-post .discourse-reactions-double-button, .topic-post .post-controls button.toggle-like",
> "after",
> false,
> "Like post"
> );
> hintMulti(
> ["b"],
> "post",
> ".topic-post .post-controls .post-action-menu__bookmark",
> "after",
> false,
> "Bookmark post"
> );
> hintMulti(
> ["s"],
> "post",
> ".topic-post a.post-date",
> "after",
> false,
> "Share post"
> );
> hintMulti(
> ["q"],
> "post",
> ".topic-post .post-controls button.quote-post",
> "after",
> false,
> "Quote post"
> );
> hintMulti(
> ["!"],
> "post",
> ".topic-post .post-controls .post-action-menu__flag, .topic-post .post-controls button.create-flag",
> "after",
> false,
> "Flag post"
> );
> hintMulti(
> ["d"],
> "post",
> ".topic-post .post-controls .post-action-menu__delete, .topic-post .post-controls button.delete",
> "after",
> false,
> "Delete post"
> );
> 
> // ── Topic view – post navigation ──────────────────────────────────────────────
> 
> 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,
> "Next post"
> );
> 
> 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,
> "Prev post"
> );
> 
> // j/k on the timeline scroller – positioned to its right
> hintPanel(
> "nav",
> "abs",
> "dso-hint-timeline-jk",
> () => $(".timeline-container") && ($(".timeline-scroller-content") || null),
> () =>
> buildPanel(
> "nav",
> [
> { keys: ["k ↑"], label: "Prev post" },
> { keys: ["j ↓"], label: "Next post" },
> ],
> {
> position: "absolute",
> left: "calc(100% + 8px)",
> top: "50%",
> transform: "translateY(-50%)",
> flexDirection: "column",
> gap: "3px",
> zIndex: "99",
> }
> )
> );
> 
> // # → jump to post number
> hint(
> ["#"],
> "nav",
> () => $(".timeline-container") && ($(".timeline-date-wrapper") || null),
> "inline",
> null,
> false,
> "Jump to post"
> );
> 
> // ⇧l → go to first unread – placed inside the # area
> hint(
> ["⇧ l"],
> "nav",
> () => $(".timeline-container") && ($(".timeline-date-wrapper") || null),
> "inline",
> null,
> false,
> "First unread"
> );
> 
> // ⇧r → reply to topic (footer button)
> hint(
> ["⇧ r"],
> "post",
> () =>
> $(
> ".topic-footer-main-buttons button.btn-primary.create, #topic-footer-buttons .btn.reply"
> ),
> "inline",
> null,
> false,
> "Reply to topic"
> );
> 
> // ── G-navigation ──────────────────────────────────────────────────────────────
> 
> // u + g→h … g→t – panel injected above .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,
> "Bookmarks"
> );
> 
> hint(
> ["g", "m"],
> "nav",
> () =>
> $(
> ".sidebar-section-link-wrapper a[href*='/messages'], .user-menu a[href*='/messages']"
> ),
> "inline",
> null,
> true,
> "Messages"
> );
> 
> hint(
> ["g", "d"],
> "nav",
> () =>
> $(
> ".sidebar-section-link-wrapper a[href*='/drafts'], .user-menu a[href*='/drafts']"
> ),
> "inline",
> null,
> true,
> "Drafts"
> );
> 
> hint(
> ["g", "p"],
> "nav",
> () => $("#user-menu-button-profile"),
> "inline",
> null,
> true,
> "Profile"
> );
> 
> // g→k / g→j – prev/next topic panel beside the topic title
> hintPanel(
> "nav",
> "abs",
> "dso-hint-topic-nav",
> () =>
> $(".timeline-container") &&
> ($(".title-wrapper #topic-title, .title-wrapper .topic-title") || null),
> () =>
> buildPanel(
> "nav",
> [
> { keys: ["g", "k"], label: "Prev topic" },
> { keys: ["g", "j"], label: "Next topic" },
> ],
> {
> position: "absolute",
> right: "8px",
> top: "50%",
> transform: "translateY(-50%)",
> flexDirection: "column",
> gap: "4px",
> alignItems: "flex-start",
> zIndex: "99",
> }
> ),
> { seq: true }
> );
> 
> // ── Header buttons ────────────────────────────────────────────────────────────
> 
> hint(
> ["="],
> "action",
> () => $(".header-sidebar-toggle")?.parentElement ?? null,
> "abs-br",
> null,
> false,
> "Sidebar"
> );
> hint(
> ["p"],
> "action",
> () => $("#current-user"),
> "abs-br",
> null,
> false,
> "Profile"
> );
> 
> // ── Topic actions ─────────────────────────────────────────────────────────────
> 
> // f → bookmark topic
> hint(
> ["f"],
> "post",
> () =>
> $(".timeline-container") &&
> ($('.topic-footer-main-buttons [data-identifier="bookmark-menu"]') ||
> null),
> "inline",
> null,
> false,
> "Bookmark topic"
> );
> 
> hint(
> ["⇧ s"],
> "post",
> () => $("#topic-footer-buttons button.share-and-invite"),
> "inline",
> null,
> false,
> "Share topic"
> );
> hint(
> ["⇧ p"],
> "nav",
> () => $(".pinned-button button"),
> "inline",
> null,
> false,
> "Pin/Unpin"
> );
> hint(
> ["⇧ u"],
> "nav",
> () =>
> $(".timeline-container") &&
> ($("#topic-footer-buttons button.defer-topic") || null),
> "inline",
> null,
> false,
> "Mark unread"
> );
> hint(
> ["⇧ a"],
> "action",
> () => $(".toggle-admin-menu"),
> "abs-br",
> null,
> false,
> "Admin actions"
> );
> hint(
> ["a"],
> "action",
> () =>
> $(".timeline-container") &&
> ($("#topic-footer-buttons button.archive-topic") || null),
> "inline",
> null,
> false,
> "Archive"
> );
> 
> // m→w / m→t / m→r / m→m – notification level panel
> hintPanel(
> "nav",
> "inline",
> "dso-hint-tracking",
> () => $(".timeline-container") && ($(".timeline-footer-controls") || null),
> () =>
> buildPanel(
> "nav",
> [
> { keys: ["m", "w"], label: "Watch" },
> { keys: ["m", "t"], label: "Track" },
> { keys: ["m", "r"], label: "Normal" },
> { keys: ["m", "m"], label: "Mute" },
> ],
> { flexDirection: "column", gap: "3px" }
> ),
> { seq: true }
> );
> 
> // ── Bulk select ───────────────────────────────────────────────────────────────
> 
> hint(
> ["⇧ b"],
> "action",
> () => $("button.bulk-select"),
> "abs-br",
> null,
> false,
> "Bulk select"
> );
> hint(
> ["⇧ d"],
> "action",
> () => $("#dismiss-topics-top, #dismiss-new-top, .dismiss-read"),
> "inline",
> null,
> false,
> "Dismiss"
> );
> hint(
> ["x"],
> "nav",
> () =>
> $(".topic-list-item.selected td.bulk-select") ||
> $(".topic-list-item td.bulk-select"),
> "abs-br",
> null,
> false,
> "Select row"
> );
> 
> // ── Logout ────────────────────────────────────────────────────────────────────
> 
> hint(
> ["⇧ z", "⇧ z"],
> "action",
> () => $("li.logout button"),
> "inline",
> null,
> true,
> "Log out"
> );
> 
> // ── Chat ──────────────────────────────────────────────────────────────────────
> 
> hint(
> ["-"],
> "action",
> () => $(".chat-header-icon"),
> "abs-br",
> null,
> false,
> "Toggle chat"
> );
> 
> // Alt+↑/↓, Alt+⇧↑/↓, ⇧Esc, Ctrl+K – panel appended to the chat drawer
> hintPanel(
> "action",
> "inline",
> "dso-hint-chat-composer",
> () => $(".chat-drawer-content"),
> () =>
> buildPanel(
> "action",
> [
> { keys: ["Ctrl", "k"], label: "Quick channel" },
> { keys: ["Alt", "↑ / ↓"], label: "Switch channel" },
> { keys: ["Alt", "⇧ ↑ / ↓"], label: "Switch unread" },
> { keys: ["⇧", "Esc"], label: "Mark all read" },
> ],
> {
> 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,
> "Close chat"
> );
> 
> // ── Toggle button ─────────────────────────────────────────────────────────────
> let visible = localStorage.getItem("dso-visible") === "true";
> 
> const toggleBtn = el("button", {
> id: "dso-toggle-btn",
> title: "Toggle shortcut overlay",
> 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)));
> 
> // ── Public 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] removed.", "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 active.",
> "color:#6af;font-weight:bold"
> );
> })();
> 
> ```

Замените или добавьте URL-адреса для совпадения (`// @match https://meta.discourse.org/*`), если хотите использовать его на другом форуме Discourse.

Для установки userscripts используйте расширение для браузера: Greasemonkey, Tampermonkey, ScriptCat или любое другое внешнее программное обеспечение, предоставляющее такую функциональность, например AdGuard.

---

_[View the full topic](https://meta.discourse.org/t/shortcuts-overlay-a-userscript-to-help-you-learn-discourse-keyboard-shortcuts/396588)._
