Sobreposição de Atalhos: um userscript para ajudar você a aprender os atalhos de teclado do 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.0.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 = `
    /* Wrapper injected as a child of the real element */
    .dso-wrap {
      display: inline-flex;
      align-items: center;
      gap: 2px;
      pointer-events: none;
      flex-shrink: 0;
      user-select: none;
      vertical-align: text-bottom;
    }
    /* "inline" mode – pushed to right inside a flex parent */
    .dso-inline {
      margin-left: auto;
      padding-left: 6px;
    }
    /* "abs" mode – absolute-positioned to the right edge of the parent */
    .dso-abs {
      position: absolute;
      right: 8px;
      top: 50%;
      transform: translateY(-50%);
      z-index: 99;
    }
    /* "abs-br" mode – absolute-positioned to the bottom-right corner */
    .dso-abs-br {
      position: absolute;
      right: 4px;
      bottom: 4px;
      z-index: 99;
    }
    /* "abs-tr" mode – absolute-positioned to the top-right corner */
    .dso-abs-tr {
      position: absolute;
      right: 4px;
      top: 4px;
      z-index: 99;
    }

    /* Key chips */
    .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 2px 8px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.1);
      min-height: 20px;
      white-space: nowrap;
      letter-spacing: 0;
    }
    /* Colour themes */
    .dso-wrap.nav    kbd { background: rgba( 18, 108,  55, .93); }
    .dso-wrap.action kbd { background: rgba( 20,  78, 175, .93); }
    .dso-wrap.write  kbd { background: rgba(105,  35, 175, .93); }
    .dso-wrap.post   kbd { background: rgba(175,  65,  15, .93); }

    /* "+" separator for combos like Ctrl+↵ */
    .dso-wrap .dso-plus {
      font: 700 9px/1 ui-monospace, monospace;
      color: rgba(255,255,255,.5);
      margin: 0 1px;
    }
    /* "→" separator for sequential keys like g→h */
    .dso-wrap .dso-seq {
      font: 700 9px/1 ui-monospace, monospace;
      color: rgba(255,255,255,.45);
      margin: 0 2px;
    }
    /* Label text (used in panels and timeline j/k) */
    .dso-gnav-label {
      font: 10px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
      color: rgba(255,255,255,.35);
    }
    /* ── G-nav panel (injected above .nav-pills) ── */
    .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;
    }
    /* Allow timeline j/k hints to overflow the scroller-content boundary */
    .timeline-container .topic-timeline .timeline-scroller-content {
      overflow: visible !important;
    }
    /* Hidden state – applied via body class so newly injected wraps are also hidden */
    body.dso-hidden .dso-wrap { display: none !important; }
    /* ── Toggle button ── */
    #dso-toggle-btn {
      position: fixed;
      top: 14px;
      right: 14px;
      z-index: 99999;
      cursor: pointer;
      background: rgba(14,14,20,.85);
      border: 1px solid rgba(255,255,255,.12);
      border-radius: 6px;
      color: rgba(255,255,255,.45);
      font-size: 13px;
      padding: 5px 7px;
      line-height: 1;
      backdrop-filter: blur(10px);
      box-shadow: 0 2px 10px rgba(0,0,0,.4);
    }
    #dso-toggle-btn:hover { color: rgba(255,255,255,.85); border-color: rgba(255,255,255,.25); }
    #dso-toggle-btn.--off { color: rgba(255,255,255,.18); border-color: rgba(255,255,255,.06); }
  `;
  document.head.appendChild(css);

  // ── Hint registry ────────────────────────────────────────────────────────────
  // mode:
  //   "inline"  – appended as flex child, pushed right via margin-left:auto
  //   "abs"     – absolutely positioned to the right edge of the parent element
  //               (parent gets position:relative if it doesn't already have one)
  //   "sibling" – inserted as a sibling before beforeSel (no extra positioning)
  // beforeSel: CSS selector (relative to target) – insert wrap before that child
  // title: tooltip shown on hover
  // hintClass: unique dso-hint-* class added to the wrap element
  const HINTS = [];

  // Converts a keys array into a stable CSS class name, e.g. ["⇧ L"] → "dso-hint-shift-l"
  function keysToClass(keys) {
    const part = 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("-");
    return "dso-hint-" + part;
  }

  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 } });
  }

  // ── DOM helpers ──────────────────────────────────────────────────────────────
  const $ = (s) => document.querySelector(s);

  function buildWrap(keys, theme, mode, seq = false, title = "") {
    const wrap = document.createElement("span");
    const modeClass = mode === "abs" ? "dso-abs" : mode === "abs-br" ? "dso-abs-br" : mode === "abs-tr" ? "dso-abs-tr" : mode === "sibling" || mode === "after" ? "" : "dso-inline";
    wrap.className = `dso-wrap ${theme} ${modeClass}`.trimEnd();
    if (title) { wrap.title = title; }

    keys.forEach((k, i) => {
      if (i > 0) {
        const sep = document.createElement("span");
        if (seq) {
          sep.className = "dso-seq";
          sep.textContent = "→";
        } else {
          sep.className = "dso-plus";
          sep.textContent = "+";
        }
        wrap.appendChild(sep);
      }
      const kbd = document.createElement("kbd");
      kbd.textContent = k;
      wrap.appendChild(kbd);
    });

    return wrap;
  }

  function buildGNavPanel() {
    const entries = [
      { 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 panel = document.createElement("div");
    panel.className = "dso-wrap dso-gnav-panel nav";
    entries.forEach(({ keys, label }) => {
      const row = document.createElement("div");
      row.className = "dso-gnav-row";
      const kbdGroup = document.createElement("span");
      kbdGroup.className = "dso-gnav-keys";
      keys.forEach((k, i) => {
        if (i > 0) {
          const sep = document.createElement("span");
          sep.className = "dso-seq";
          sep.textContent = "→";
          kbdGroup.appendChild(sep);
        }
        const kbd = document.createElement("kbd");
        kbd.textContent = k;
        kbdGroup.appendChild(kbd);
      });
      const lbl = document.createElement("span");
      lbl.className = "dso-gnav-label";
      lbl.textContent = label;
      row.appendChild(kbdGroup);
      row.appendChild(lbl);
      panel.appendChild(row);
    });
    return panel;
  }

  // j↓ / k↑ stacked vertically – positioned to the right of .timeline-scroller-content
  function buildTimelineJK() {
    const wrap = document.createElement("span");
    wrap.className = "dso-wrap nav";
    Object.assign(wrap.style, {
      position: "absolute",
      left: "calc(100% + 8px)",
      top: "50%",
      transform: "translateY(-50%)",
      flexDirection: "column",
      gap: "3px",
      zIndex: "99",
    });
    [{ key: "k ↑", label: "Prev post" }, { key: "j ↓", label: "Next post" }].forEach(({ key, label }) => {
      const row = document.createElement("span");
      Object.assign(row.style, { display: "inline-flex", alignItems: "center", gap: "4px" });
      const kbd = document.createElement("kbd");
      kbd.textContent = key;
      row.appendChild(kbd);
      const lbl = document.createElement("span");
      lbl.className = "dso-gnav-label";
      lbl.textContent = label;
      row.appendChild(lbl);
      wrap.appendChild(row);
    });
    return wrap;
  }

  // g→k / g→j panel – placed to the right of the topic title
  function buildTopicNavHints() {
    const wrap = document.createElement("span");
    wrap.className = "dso-wrap nav";
    Object.assign(wrap.style, {
      position: "absolute",
      right: "8px",
      top: "50%",
      transform: "translateY(-50%)",
      flexDirection: "column",
      gap: "4px",
      alignItems: "flex-start",
      zIndex: "99",
    });
    [{ keys: ["g", "k"], label: "Prev topic" },
     { keys: ["g", "j"], label: "Next topic" }].forEach(({ keys, label }) => {
      const row = document.createElement("span");
      Object.assign(row.style, { display: "inline-flex", alignItems: "center", gap: "4px" });
      keys.forEach((k, i) => {
        if (i > 0) {
          const sep = document.createElement("span");
          sep.className = "dso-seq";
          sep.textContent = "→";
          row.appendChild(sep);
        }
        const kbd = document.createElement("kbd");
        kbd.textContent = k;
        row.appendChild(kbd);
      });
      const lbl = document.createElement("span");
      lbl.className = "dso-gnav-label";
      lbl.textContent = label;
      row.appendChild(lbl);
      wrap.appendChild(row);
    });
    return wrap;
  }

  // m→w / m→t / m→r / m→m panel – appended in .timeline-footer-controls
  function buildTopicTrackingPanel() {
    const wrap = document.createElement("span");
    wrap.className = "dso-wrap nav";
    Object.assign(wrap.style, {
      flexDirection: "column",
      gap: "3px",
    });
    [
      { keys: ["m", "w"], label: "Watch" },
      { keys: ["m", "t"], label: "Track" },
      { keys: ["m", "r"], label: "Normal" },
      { keys: ["m", "m"], label: "Mute" },
    ].forEach(({ keys, label }) => {
      const row = document.createElement("span");
      Object.assign(row.style, { display: "inline-flex", alignItems: "center", gap: "3px" });
      keys.forEach((k, i) => {
        if (i > 0) {
          const sep = document.createElement("span");
          sep.className = "dso-seq";
          sep.textContent = "→";
          row.appendChild(sep);
        }
        const kbd = document.createElement("kbd");
        kbd.textContent = k;
        row.appendChild(kbd);
      });
      const lbl = document.createElement("span");
      lbl.className = "dso-gnav-label";
      lbl.textContent = label;
      row.appendChild(lbl);
      wrap.appendChild(row);
    });
    return wrap;
  }

  // Ctrl+K, Alt+↑/↓, Alt+⇧↑/↓, ⇧Esc panel – appended at end of .chat-drawer-content
  function buildChatComposerHints() {
    const wrap = document.createElement("span");
    wrap.className = "dso-wrap action";
    Object.assign(wrap.style, {
      flexWrap: "wrap",
      gap: "6px 12px",
      padding: "4px 6px",
      borderTop: "1px solid rgba(255,255,255,.08)",
      marginTop: "2px",
    });
    [
      { keys: ["Ctrl", "K"],        label: "Quick channel" },
      { keys: ["Alt", "↑ / ↓"],    label: "Switch channel" },
      { keys: ["Alt", "⇧ ↑ / ↓"], label: "Switch unread" },
      { keys: ["⇧", "Esc"],        label: "Mark all read" },
    ].forEach(({ keys, label }) => {
      const row = document.createElement("span");
      Object.assign(row.style, { display: "inline-flex", alignItems: "center", gap: "3px" });
      keys.forEach((k, i) => {
        if (i > 0) {
          const sep = document.createElement("span");
          sep.className = "dso-plus";
          sep.textContent = "+";
          row.appendChild(sep);
        }
        const kbd = document.createElement("kbd");
        kbd.textContent = k;
        row.appendChild(kbd);
      });
      const lbl = document.createElement("span");
      lbl.className = "dso-gnav-label";
      lbl.textContent = label;
      row.appendChild(lbl);
      wrap.appendChild(row);
    });
    return wrap;
  }

  // ── 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

    // ① Remove from old target
    if (state.wrap) { state.wrap.remove(); }
    if (state.el && 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; }

    // ② For "abs"/"abs-br"/"abs-tr" mode: make parent position:relative if it's static
    if (mode === "abs" || mode === "abs-br" || mode === "abs-tr") {
      const cur = getComputedStyle(target).position;
      if (cur === "static") {
        target.dataset.dsoPos = "";          // mark as modified (empty = was static)
        target.style.position = "relative";
      }
    }

    // ③ Build and inject
    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") {
      // Insert as next sibling of the target, not as a child
      insertParent = target.parentNode;
      insertRef    = target.nextSibling;
    } else {
      const beforeEl = beforeSel ? target.querySelector(beforeSel) : null;
      // insertBefore requires the reference to be a direct child of the parent,
      // so use beforeEl.parentNode rather than target when they differ.
      insertParent = beforeEl ? beforeEl.parentNode : target;
      insertRef    = beforeEl;
    }
    insertParent.insertBefore(wrap, insertRef);
    state.wrap = 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 (top-right of whichever result list is visible)
  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  (text button → inline, kbd appears after the label)
  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");

  // .  → show new/updated topics (the banner that appears at the top of the list)
  hint(["."], "action",
    () => $(".show-more.has-topics a"),
    "inline", null, false, "Load new topics");

  // ── Topic list ──────────────────────────────────────────────────────────────
  // j → row ABOVE the selected one (or first row when nothing is selected).
  // k → row BELOW the selected one (hidden when nothing is selected).
  // o → the selected row's title link.
  //
  // Excludes items inside .more-topics__list / .suggested-topics (related topics
  // section at the bottom of topic view) 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"));
      // No selection → first row; selected → row below it
      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"));
      // Only shown when something is selected and there is a row above it
      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 section (advances the active nav-pill tab)
  hint(["⇧ J"], "nav",
    () => {
      const pills = [...document.querySelectorAll(".nav.nav-pills li")];
      if (!pills.length) { return null; }
      const activeIdx = pills.findIndex((p) => p.classList.contains("active"));
      return pills[activeIdx + 1] || null;
    },
    "inline", null, false, "Next section");

  hint(["⇧ K"], "nav",
    () => {
      const pills = [...document.querySelectorAll(".nav.nav-pills li")];
      if (!pills.length) { return null; }
      const activeIdx = pills.findIndex((p) => p.classList.contains("active"));
      if (activeIdx <= 0) { return null; }
      return pills[activeIdx - 1] || null;
    },
    "inline", null, false, "Prev section");

  // ── Composer ─────────────────────────────────────────────────────────────────

  // Ctrl+↵  → submit (injected into the create/send button)
  hint(["Ctrl", "↵"], "write",
    () => $(".save-or-cancel .create, .reply-area .create, #reply-control .create"),
    "inline", null, false, "Submit");

  // Esc  → close (injected into the cancel button)
  hint(["Esc"], "write",
    () => $(".save-or-cancel .cancel, .reply-area .cancel"),
    "inline", null, false, "Cancel");

  // ⇧ C  → return to minimized composer (only visible when composer is a draft)
  hint(["⇧ C"], "write",
    () => $("#reply-control.draft .draft-text"),
    "inline", null, false, "Return to composer");

  // ⇧ F11  → fullscreen (not shown when composer is in draft/minimized state)
  hint(["⇧ F11"], "write",
    () => $("#reply-control.draft") ? null : $(".toggle-fullscreen"),
    "after", null, false, "Fullscreen");

  // Esc  → collapse/minimize (inserted as sibling after the button, avoids overflow clipping)
  hint(["Esc"], "write",
    () => $(".toggle-minimize"),
    "after", null, false, "Minimize");

  // ── Composer options (+) popup items (only in DOM when the popup is open) ─────

  // Ctrl+E  → preformatted text
  hint(["Ctrl", "E"], "write",
    () => $('[data-name="format-code"]'),
    "abs-br", null, false, "Preformatted text");

  // Ctrl+⇧8  → bulleted list
  hint(["Ctrl", "⇧ 8"], "write",
    () => $('[data-name="apply-unordered-list"]'),
    "abs-br", null, false, "Bulleted list");

  // Ctrl+⇧7  → ordered list
  hint(["Ctrl", "⇧ 7"], "write",
    () => $('[data-name="apply-ordered-list"]'),
    "abs-br", null, false, "Ordered list");

  // ── Composer heading popup items (only in DOM when the popup is open) ─────────
  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");

  // Ctrl+L  → insert link (composer toolbar hyperlink button)
  hint(["Ctrl", "L"], "write",
    () => $('.toolbar__button[title="Hyperlink"]'),
    "abs-br", null, false, "Insert link");

  // ── Post actions ─────────────────────────────────────────────────────────────
  // Prefer the keyboard-selected post; fall back to the first post.

  function postBtn(sel) {
    return $(
      `.topic-post.selected .post-controls ${sel}, ` +
      `.topic-post.selected .actions ${sel}, ` +
      `.topic-post:first-child .post-controls ${sel}, ` +
      `.topic-post:first-child .actions ${sel}`
    );
  }

  hint(["r"], "post", () => postBtn("button.reply"), "inline", null, false, "Reply to post");
  hint(["e"], "post", () => postBtn("button.edit"), "inline", null, false, "Edit post");
  hint(["l"], "post", () => postBtn("button.toggle-like"), "inline", null, false, "Like post");
  hint(["b"], "post", () => postBtn(".post-action-menu__bookmark"), "inline", null, false, "Bookmark post");
  hint(["s"], "post",
    () => {
      const sel = $(
        ".topic-post.selected .post-action-menu__share, " +
        ".topic-post.selected .actions .share"
      );
      return sel || $(
        ".topic-post:first-child .post-action-menu__share, " +
        ".topic-post:first-child .actions .share, " +
        ".topic-post:first-child a.post-date"
      );
    },
    "inline", null, false, "Share post");
  hint(["q"], "post", () => postBtn("button.quote-post"), "inline", null, false, "Quote post");
  hint(["!"], "post", () => postBtn(".post-action-menu__flag, button.create-flag"), "inline", null, false, "Flag post");
  hint(["d"], "post", () => postBtn(".post-action-menu__delete, button.delete"), "inline", null, false, "Delete post");

  // ── Topic view – post navigation ─────────────────────────────────────────────
  // j on the post below selected (or first post); k on the post above selected.
  // Only active in topic view (detected via .timeline-container, not by absence
  // of .topic-list-item which also exists in the related-topics section).

  hint(["j ↓"], "nav",
    () => {
      if (!$(".timeline-container")) { return null; }
      const posts = [...document.querySelectorAll(".topic-post")];
      if (!posts.length) { return null; }
      const selIdx = posts.findIndex((p) => p.classList.contains("selected"));
      if (selIdx === -1) { return posts[0] || null; }
      if (selIdx >= posts.length - 1) { return null; }
      return posts[selIdx + 1] || null;
    },
    "abs", null, false, "Next post");

  hint(["k ↑"], "nav",
    () => {
      if (!$(".timeline-container")) { return null; }
      const posts = [...document.querySelectorAll(".topic-post")];
      if (!posts.length) { return null; }
      const selIdx = posts.findIndex((p) => p.classList.contains("selected"));
      if (selIdx <= 0) { return null; }
      return posts[selIdx - 1] || null;
    },
    "abs", null, false, "Prev post");

  // j/k on the timeline scroller – positioned to its right via abs on the element itself

  HINTS.push({
    keys: null, theme: "nav", mode: "abs", beforeSel: null, seq: false,
    hintClass: "dso-hint-timeline-jk",
    targetFn: () => {
      if (!$(".timeline-container")) { return null; }
      return $(".timeline-scroller-content") || null;
    },
    customBuild: buildTimelineJK,
    state: { el: null, wrap: null },
  });

  // #  → jump to post number (opens timeline input in topic view)
  hint(["#"], "nav",
    () => {
      if (!$(".timeline-container")) { return null; }
      return $(".timeline-date-wrapper") || null;
    },
    "inline", null, false, "Jump to post");

  // ⇧L  → go to first unread post – inserted at the left of the timeline footer
  hint(["⇧ L"], "nav",
    () => {
      if (!$(".timeline-container")) { return null; }
      return $(".timeline-footer-controls") || null;
    },
    "sibling", ":scope > :first-child", 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 (sequential: press g, then the second key) ──────────────────

  // u + g→h … g→t  Navigation panel injected above .nav-pills
  HINTS.push({
    keys: null, theme: "nav", mode: "sibling", seq: true,
    hintClass: "dso-hint-gnav",
    targetFn: () => $(
      ".navigation-container, " +
      ".list-controls .container, " +
      ".navigation-bar"
    ),
    beforeSel: ".nav-pills",
    customBuild: buildGNavPanel,
    state: { el: null, wrap: null },
  });

  // g→b  Bookmarks (sidebar link or user-menu when open)
  hint(["g", "b"], "nav",
    () => $(
      ".sidebar-section-link-wrapper a[href*='/bookmarks'], " +
      ".user-menu a[href*='/bookmarks']"
    ),
    "inline", null, true, "Bookmarks");

  // g→m  Messages
  hint(["g", "m"], "nav",
    () => $(
      ".sidebar-section-link-wrapper a[href*='/messages'], " +
      ".user-menu a[href*='/messages']"
    ),
    "inline", null, true, "Messages");

  // g→d  Drafts
  hint(["g", "d"], "nav",
    () => $(
      ".sidebar-section-link-wrapper a[href*='/drafts'], " +
      ".user-menu a[href*='/drafts']"
    ),
    "inline", null, true, "Drafts");

  // g→p  Profile tab in user menu (only when user menu is open)
  hint(["g", "p"], "nav",
    () => $("#user-menu-button-profile"),
    "inline", null, true, "Profile");

  // g→k / g→j  Prev/Next topic – panel to the right of the topic title (topic pages only)
  HINTS.push({
    keys: null, theme: "nav", mode: "abs", beforeSel: null, seq: true,
    hintClass: "dso-hint-topic-nav",
    targetFn: () => {
      if (!$(".timeline-container")) { return null; }
      return $("#topic-title, .topic-title") || null;
    },
    customBuild: buildTopicNavHints,
    state: { el: null, wrap: null },
  });

  // ── Header buttons ────────────────────────────────────────────────────────────

  // =  → toggle sidebar / hamburger menu
  hint(["="], "action",
    () => $(".header-sidebar-toggle"),
    "abs-br", null, false, "Sidebar");

  // p  → user profile menu
  hint(["p"], "action",
    () => $("#current-user"),
    "abs-br", null, false, "Profile");

  // ── Topic actions (topic view) ────────────────────────────────────────────────

  // f  → bookmark topic (opens bookmark menu in the footer)
  hint(["f"], "post",
    () => {
      if (!$(".timeline-container")) { return null; }
      return $('[data-identifier="bookmark-menu"]') || null;
    },
    "inline", null, false, "Bookmark topic");

  // ⇧ S  → share topic (footer share button)
  hint(["⇧ S"], "post",
    () => $("#topic-footer-buttons button.share-and-invite"),
    "inline", null, false, "Share topic");

  // ⇧ P  → pin/unpin topic for current user
  hint(["⇧ P"], "nav",
    () => $(".pinned-button button"),
    "inline", null, false, "Pin/Unpin");

  // ⇧ U  → defer topic (mark as unread)
  hint(["⇧ U"], "nav",
    () => {
      if (!$(".timeline-container")) { return null; }
      return $("#topic-footer-buttons button.defer-topic") || null;
    },
    "inline", null, false, "Mark unread");

  // ⇧ A  → open topic admin actions menu
  hint(["⇧ A"], "action",
    () => $(".toggle-admin-menu"),
    "abs-br", null, false, "Admin actions");

  // a  → archive personal message / move to inbox
  hint(["a"], "action",
    () => {
      if (!$(".timeline-container")) { return null; }
      return $("#topic-footer-buttons button.archive-topic") || null;
    },
    "inline", null, false, "Archive");

  // m→w / m→t / m→r / m→m  Notification level panel – appended in .timeline-footer-controls
  HINTS.push({
    keys: null, theme: "nav", mode: "inline", beforeSel: null, seq: true,
    hintClass: "dso-hint-tracking",
    targetFn: () => {
      if (!$(".timeline-container")) { return null; }
      return $(".timeline-footer-controls") || null;
    },
    customBuild: buildTopicTrackingPanel,
    state: { el: null, wrap: null },
  });

  // ── Bulk select ───────────────────────────────────────────────────────────────

  // ⇧ B  → toggle bulk select mode (button only present when nav controls show it)
  hint(["⇧ B"], "action",
    () => $("button.bulk-select"),
    "abs-br", null, false, "Bulk select");

  // ⇧ D  → dismiss read/new topics
  hint(["⇧ D"], "action",
    () => $("#dismiss-topics-top, #dismiss-new-top, .dismiss-read"),
    "inline", null, false, "Dismiss");

  // X  → toggle selection of focused row (only visible in bulk select mode)
  hint(["x"], "nav",
    () => {
      const sel = $(".topic-list-item.selected td.bulk-select");
      return sel || $(".topic-list-item td.bulk-select");
    },
    "abs-br", null, false, "Select row");

  // ── Logout ───────────────────────────────────────────────────────────────────

  // ⇧Z ⇧Z  → log out (sequential shift-z twice)
  hint(["⇧ Z", "⇧ Z"], "action",
    () => $("li.logout button"),
    "inline", null, true, "Log out");

  // ── Chat ─────────────────────────────────────────────────────────────────────

  // -  → toggle chat drawer (chat icon button in header)
  hint(["-"], "action",
    () => $(".chat-header-icon"),
    "abs-br", null, false, "Toggle chat");

  // Alt+↑/↓, Alt+⇧↑/↓, ⇧Esc, Ctrl+K  → panel at end of .chat-drawer-content
  HINTS.push({
    keys: null, theme: "action", mode: "inline", beforeSel: null, seq: false,
    hintClass: "dso-hint-chat-composer",
    targetFn: () => $(".chat-drawer-content"),
    customBuild: buildChatComposerHints,
    state: { el: null, wrap: null },
  });

  // Esc  → close chat drawer (abs-br on the close-drawer button)
  hint(["Esc"], "action",
    () => $(".c-navbar__close-drawer-button"),
    "after", null, false, "Close chat");

  // ── Toggle button ─────────────────────────────────────────────────────────────
  const toggleBtn = document.createElement("button");
  toggleBtn.id = "dso-toggle-btn";
  toggleBtn.title = "Toggle shortcut overlay";
  toggleBtn.textContent = "⌨";
  toggleBtn.classList.add("--off");
  document.body.appendChild(toggleBtn);

  toggleBtn.addEventListener("click", () => { window.__dso__.toggle(); });

  // ── MutationObserver ─────────────────────────────────────────────────────────
  // Re-checks hints whenever Ember re-renders the DOM.
  // Ignores mutations caused by our own injections to avoid re-trigger loops.

  let rafPending = false;

  function scheduleRefresh() {
    if (!rafPending) {
      rafPending = true;
      requestAnimationFrame(() => {
        rafPending = false;
        HINTS.forEach(inject);
      });
    }
  }

  const observer = new MutationObserver((mutations) => {
    const onlyOurs = mutations.every((m) => {
      // Attribute mutations (e.g. Discourse adding "selected" to a row) are
      // never caused by us — always treat them as external.
      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,
    // Watch class changes so we react when Discourse adds/removes "selected"
    attributes: true,
    attributeFilter: ["class"],
  });

  // Refresh on focus changes so / ↔ Ctrl+F swap reacts to input focus
  document.addEventListener("focusin", scheduleRefresh, { passive: true });
  document.addEventListener("focusout", scheduleRefresh, { passive: true });

  // Initial injection pass (hidden by default via body class)
  document.body.classList.add("dso-hidden");
  HINTS.forEach(inject);

  // ── Public API ───────────────────────────────────────────────────────────────
  let visible = false;

  window.__dso__ = {
    stop() {
      observer.disconnect();
      document.removeEventListener("focusin", scheduleRefresh);
      document.removeEventListener("focusout", scheduleRefresh);
      HINTS.forEach(({ state }) => {
        if (state.wrap) { state.wrap.remove(); }
        if (state.el && 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;
      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.

6 curtidas

Isso é para a barra lateral do administrador

É isso que você pode fazer aqui, em vez de usar o botão dispensar no topo

1 curtida

Nossa! Apenas com sua captura de tela, aprendi mais de 15 atalhos de teclado :eyes: !

2 curtidas