Sovrapposizione Scorciatoie: uno userscript per aiutarti a imparare le scorciatoie da tastiera di Discourse

This userscript shows Discourse shortcuts directly on the forum:


It’s disabled by default, click the top right icon to toggle it. image

It’s not meant to be beautiful. Just enable it when you feel you want to use and memorize shortcuts :slight_smile:

It doesn’t show all existing shortcuts, but most of them.
Bookmark-related shortcuts are not (yet?) included, there are many of them for what is a niche section of the site.

There are a few shortcuts I don’t understand in the shortcut list ?:

  • Ctrl + l β†’ Filter sidebar

  • Topic selection:
    Shift + d β†’ Dismiss selected topics

  • a Insert selection into open composer

If someone can explain to me what they do :thinking:

Full code
// ==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-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: 14px; right: 14px; z-index: 99999; cursor: pointer;
      background: var(--secondary); border: 1px solid var(--primary-low); border-radius: 6px;
      color: var(--primary-medium); font-size: 13px; 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"),
    "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"
  );
})();

Replace or add URLs matches (// @match https://meta.discourse.org/*) if you want to use it on another Discourse forum.

To install userscripts, use a browser extension: Greasemonkey, Tampermonkey, ScriptCat, or any external software that provides such a functionality, for example AdGuard.

7 Mi Piace

Quella Γ¨ per la barra laterale dell’amministratore

Questo Γ¨ ciΓ² che puoi fare qui, invece di usare il pulsante ignora in alto

1 Mi Piace

Golly! Solo dalla tua schermata ho imparato piΓΉ di 15 scorciatoie da tastiera :eyes: !

4 Mi Piace

Ho aggiornato lo script.

Ho aggiunto le scorciatoie mancanti, specialmente nelle azioni post, ho reso i colori meno aggressivi e agnostici rispetto al tema (si abbinano bene al chiaro e allo scuro).

Ancora piΓΉ importante, lo script ora ricorda il suo stato e non si reimposta quando ricarichiamo la pagina.

Lo sto usando da un paio di giorni e funziona benissimo per imparare le scorciatoie!

Come mi sento ora usando Discourse:

Hacker Reality: Typing on a RGB Keyboard

3 Mi Piace