Raccourcis superposés : un userscript pour vous aider à apprendre les raccourcis clavier de Discourse

Ce userscript affiche les raccourcis Discourse directement sur le forum :


Il est désactivé par défaut, cliquez sur l’icône en haut à droite pour l’activer. image

Il n’est pas censé être beau. Activez-le simplement lorsque vous avez envie d’utiliser et de mémoriser les raccourcis :slight_smile:

Il n’affiche pas tous les raccourcis existants, mais la plupart.
Les raccourcis liés aux favoris ne sont pas (encore ?) inclus, car ils sont nombreux pour une section de niche du site.

Il y a quelques raccourcis que je ne comprends pas dans la liste des raccourcis ? :

  • Ctrl + l → Filtrer la barre latérale

  • Sélection de sujet :
    Shift + d → Ignorer les sujets sélectionnés

  • a Insérer la sélection dans le rédacteur ouvert

Si quelqu’un peut m’expliquer ce qu’ils font :thinking:

Code complet
// ==UserScript==
// @name         Discourse Shortcut Overlay
// @namespace    https://meta.discourse.org/
// @version      1.1
// @description  Injecte des indices kbd directement dans les éléments d’interface Discourse pour afficher les raccourcis clavier en contexte.
// @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);

  // ── Registre des indices ─────────────────────────────────────────────────────────────
  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("-")
    );
  }

  // Indice cible unique
  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 },
    });
  }

  // Indice multi-cibles – injecte après chaque élément correspondant à multiSel
  function hintMulti(
    keys,
    theme,
    multiSel,
    mode = "after",
    seq = false,
    title = ""
  ) {
    HINTS.push({
      keys,
      theme,
      multiSel,
      mode,
      seq,
      title,
      hintClass: keysToClass(keys),
      state: { pairs: [] },
    });
  }

  // Indice panneau personnalisé
  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 },
    });
  }

  // ── Constructeurs DOM ─────────────────────────────────────────────────────────────

  // Micro-aide : créer un élément avec des propriétés assignées
  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;
  }

  // Panneau générique : lignes de séquences kbd avec étiquettes optionnelles.
  // usePlus : utiliser des séparateurs « + » (combos) au lieu de « → » (séquences).
  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 utilise des classes CSS dédiées pour sa mise en page enveloppante
  function buildGNavPanel() {
    const panel = el("div", { className: "dso-wrap dso-gnav-panel nav" });
    for (const { keys, label } of [
      { keys: ["u"], label: "Retour" },
      { keys: ["g", "h"], label: "Accueil" },
      { keys: ["g", "l"], label: "Récent" },
      { keys: ["g", "n"], label: "Nouveau" },
      { keys: ["g", "u"], label: "Non lu" },
      { keys: ["g", "y"], label: "Non vu" },
      { keys: ["g", "c"], label: "Catégories" },
      { 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;
  }

  // ── Logique d’injection ───────────────────────────────────────────────────────────
  function inject(h) {
    const target = h.targetFn();
    const { state, keys, theme, mode, beforeSel } = h;

    if (target === state.el) {
      return;
    } // pas de changement – déjà injecté

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

  // ────────────────────────────────────────────────────────────────────────────
  // DÉFINITIONS DES INDICES
  // ────────────────────────────────────────────────────────────────────────────

  // ── En-tête ──────────────────────────────────────────────────────────────────

  // /  → recherche  (masqué lorsque l’entrée est focalisée)
  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,
    "Recherche"
  );

  // ↑ / ↓  → naviguer dans les résultats de recherche
  hint(
    ["↑ / ↓"],
    "action",
    () =>
      $(".search-input--header")
        ? $(".search-menu-initial-options, .search-menu-assistant") || null
        : null,
    "abs-tr",
    null,
    false,
    "Naviguer dans les résultats"
  );

  // Ctrl+↵  → ouvrir la recherche page complète (uniquement lorsque l’entrée est focalisée)
  hint(
    ["Ctrl", "↵"],
    "action",
    () => {
      const input = $("#header-search-input");
      if (!input || input !== document.activeElement) {
        return null;
      }
      return input;
    },
    "after",
    null,
    false,
    "Recherche page complète"
  );

  // c  → nouveau sujet
  hint(
    ["c"],
    "write",
    () => $("#create-topic"),
    "inline",
    null,
    false,
    "Nouveau sujet"
  );

  // ?  → modal raccourcis clavier
  hint(
    ["?"],
    "action",
    () => $(".keyboard-shortcuts-btn, button[aria-label='Keyboard shortcuts']"),
    "inline",
    null,
    false,
    "Raccourcis"
  );

  // .  → charger la bannière des nouveaux sujets mis à jour
  hint(
    ["."],
    "action",
    () => $(".show-more.has-topics a"),
    "inline",
    null,
    false,
    "Charger les nouveaux sujets"
  );

  // ── Liste des sujets ───────────────────────────────────────────────────────────────
  // Exclut .more-topics__list / .suggested-topics pour que les indices ne débordent pas dans cette zone.

  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,
    "Sujet suivant"
  );

  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,
    "Sujet précédent"
  );

  // o / Entrée  → ouvrir le sujet sélectionné
  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,
    "Ouvrir le sujet"
  );

  // ⇧j / ⇧k  → section suivante/précédente de l’onglet de navigation
  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,
    "Section suivante"
  );

  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,
    "Section précédente"
  );

  // ── Rédacteur ──────────────────────────────────────────────────────────────────

  hint(
    ["Ctrl", "↵"],
    "write",
    () =>
      $(".save-or-cancel .create, .reply-area .create, #reply-control .create"),
    "inline",
    null,
    false,
    "Soumettre"
  );

  hint(
    ["Esc"],
    "write",
    () => $(".save-or-cancel .cancel, .reply-area .cancel"),
    "inline",
    null,
    false,
    "Annuler"
  );

  // ⇧c  → revenir au rédacteur minimisé
  hint(
    ["⇧ c"],
    "write",
    () => $("#reply-control.draft .draft-text"),
    "inline",
    null,
    false,
    "Revenir au rédacteur"
  );

  // ⇧F11  → plein écran (masqué lorsque le rédacteur est un brouillon)
  hint(
    ["⇧ F11"],
    "write",
    () => ($("#reply-control.draft") ? null : $(".toggle-fullscreen")),
    "after",
    null,
    false,
    "Plein écran"
  );

  hint(
    ["Esc"],
    "write",
    () => $(".toggle-minimize"),
    "after",
    null,
    false,
    "Minimiser"
  );

  // ── Options du rédacteur (+) popup ────────────────────────────────────────────────

  hint(
    ["Ctrl", "e"],
    "write",
    () => $('[data-name="format-code"]'),
    "abs-br",
    null,
    false,
    "Texte préformaté"
  );
  hint(
    ["Ctrl", "⇧ 8"],
    "write",
    () => $('[data-name="apply-unordered-list"]'),
    "abs-br",
    null,
    false,
    "Liste à puces"
  );
  hint(
    ["Ctrl", "⇧ 7"],
    "write",
    () => $('[data-name="apply-ordered-list"]'),
    "abs-br",
    null,
    false,
    "Liste numérotée"
  );
  hint(
    ["Ctrl", "Alt", "0"],
    "write",
    () => $('[data-name="heading-paragraph"]'),
    "abs-br",
    null,
    false,
    "Paragraphe"
  );
  hint(
    ["Ctrl", "Alt", "1"],
    "write",
    () => $('[data-name="heading-1"]'),
    "abs-br",
    null,
    false,
    "Titre 1"
  );
  hint(
    ["Ctrl", "Alt", "2"],
    "write",
    () => $('[data-name="heading-2"]'),
    "abs-br",
    null,
    false,
    "Titre 2"
  );
  hint(
    ["Ctrl", "Alt", "3"],
    "write",
    () => $('[data-name="heading-3"]'),
    "abs-br",
    null,
    false,
    "Titre 3"
  );
  hint(
    ["Ctrl", "Alt", "4"],
    "write",
    () => $('[data-name="heading-4"]'),
    "abs-br",
    null,
    false,
    "Titre 4"
  );
  hint(
    ["Ctrl", "l"],
    "write",
    () => $('.toolbar__button[title="Hyperlink"]'),
    "abs-br",
    null,
    false,
    "Insérer un lien"
  );

  // ── Actions sur les messages (injectés sur chaque message visible) ─────────────────────────────

  hintMulti(
    ["r"],
    "post",
    ".topic-post .post-controls button.reply",
    "after",
    false,
    "Répondre au message"
  );
  hintMulti(
    ["e"],
    "post",
    ".topic-post .post-controls button.edit",
    "after",
    false,
    "Modifier le message"
  );
  hintMulti(
    ["l"],
    "post",
    ".topic-post .discourse-reactions-double-button, .topic-post .post-controls button.toggle-like",
    "after",
    false,
    "J’aime le message"
  );
  hintMulti(
    ["b"],
    "post",
    ".topic-post .post-controls .post-action-menu__bookmark",
    "after",
    false,
    "Favoriser le message"
  );
  hintMulti(
    ["s"],
    "post",
    ".topic-post a.post-date",
    "after",
    false,
    "Partager le message"
  );
  hintMulti(
    ["q"],
    "post",
    ".topic-post .post-controls button.quote-post",
    "after",
    false,
    "Citer le message"
  );
  hintMulti(
    ["!"],
    "post",
    ".topic-post .post-controls .post-action-menu__flag, .topic-post .post-controls button.create-flag",
    "after",
    false,
    "Signaler le message"
  );
  hintMulti(
    ["d"],
    "post",
    ".topic-post .post-controls .post-action-menu__delete, .topic-post .post-controls button.delete",
    "after",
    false,
    "Supprimer le message"
  );

  // ── Vue du sujet – navigation dans les messages ──────────────────────────────────────

  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,
    "Message suivant"
  );

  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,
    "Message précédent"
  );

  // j/k sur le défilement de la chronologie – positionné à sa droite
  hintPanel(
    "nav",
    "abs",
    "dso-hint-timeline-jk",
    () => $(".timeline-container") && ($(".timeline-scroller-content") || null),
    () =>
      buildPanel(
        "nav",
        [
          { keys: ["k ↑"], label: "Message précédent" },
          { keys: ["j ↓"], label: "Message suivant" },
        ],
        {
          position: "absolute",
          left: "calc(100% + 8px)",
          top: "50%",
          transform: "translateY(-50%)",
          flexDirection: "column",
          gap: "3px",
          zIndex: "99",
        }
      )
  );

  // #  → aller au numéro de message
  hint(
    ["#"],
    "nav",
    () => $(".timeline-container") && ($(".timeline-date-wrapper") || null),
    "inline",
    null,
    false,
    "Aller au message"
  );

  // ⇧l  → aller au premier non lu – placé dans la zone #
  hint(
    ["⇧ l"],
    "nav",
    () => $(".timeline-container") && ($(".timeline-date-wrapper") || null),
    "inline",
    null,
    false,
    "Premier non lu"
  );

  // ⇧r  → répondre au sujet (bouton pied de page)
  hint(
    ["⇧ r"],
    "post",
    () =>
      $(
        ".topic-footer-main-buttons button.btn-primary.create, #topic-footer-buttons .btn.reply"
      ),
    "inline",
    null,
    false,
    "Répondre au sujet"
  );

  // ── Navigation G ──────────────────────────────────────────────────────────────

  // u + g→h … g→t – panneau injecté au-dessus de .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,
    "Favoris"
  );

  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,
    "Brouillons"
  );

  hint(
    ["g", "p"],
    "nav",
    () => $("#user-menu-button-profile"),
    "inline",
    null,
    true,
    "Profil"
  );

  // g→k / g→j – panneau sujet précédent/suivant à côté du titre du sujet
  hintPanel(
    "nav",
    "abs",
    "dso-hint-topic-nav",
    () =>
      $(".timeline-container") &&
      ($(".title-wrapper #topic-title, .title-wrapper .topic-title") || null),
    () =>
      buildPanel(
        "nav",
        [
          { keys: ["g", "k"], label: "Sujet précédent" },
          { keys: ["g", "j"], label: "Sujet suivant" },
        ],
        {
          position: "absolute",
          right: "8px",
          top: "50%",
          transform: "translateY(-50%)",
          flexDirection: "column",
          gap: "4px",
          alignItems: "flex-start",
          zIndex: "99",
        }
      ),
    { seq: true }
  );

  // ── Boutons de l’en-tête ────────────────────────────────────────────────────────────

  hint(
    ["="],
    "action",
    () => $(".header-sidebar-toggle")?.parentElement ?? null,
    "abs-br",
    null,
    false,
    "Barre latérale"
  );
  hint(
    ["p"],
    "action",
    () => $("#current-user"),
    "abs-br",
    null,
    false,
    "Profil"
  );

  // ── Actions sur le sujet ─────────────────────────────────────────────────────────────

  // f  → favoriser le sujet
  hint(
    ["f"],
    "post",
    () =>
      $(".timeline-container") &&
      ($('.topic-footer-main-buttons [data-identifier="bookmark-menu"]') ||
        null),
    "inline",
    null,
    false,
    "Favoriser le sujet"
  );

  hint(
    ["⇧ s"],
    "post",
    () => $("#topic-footer-buttons button.share-and-invite"),
    "inline",
    null,
    false,
    "Partager le sujet"
  );
  hint(
    ["⇧ p"],
    "nav",
    () => $(".pinned-button button"),
    "inline",
    null,
    false,
    "Épingler/Dépingler"
  );
  hint(
    ["⇧ u"],
    "nav",
    () =>
      $(".timeline-container") &&
      ($("#topic-footer-buttons button.defer-topic") || null),
    "inline",
    null,
    false,
    "Marquer comme non lu"
  );
  hint(
    ["⇧ a"],
    "action",
    () => $(".toggle-admin-menu"),
    "abs-br",
    null,
    false,
    "Actions admin"
  );
  hint(
    ["a"],
    "action",
    () =>
      $(".timeline-container") &&
      ($("#topic-footer-buttons button.archive-topic") || null),
    "inline",
    null,
    false,
    "Archiver"
  );

  // m→w / m→t / m→r / m→m – panneau niveau de notification
  hintPanel(
    "nav",
    "inline",
    "dso-hint-tracking",
    () => $(".timeline-container") && ($(".timeline-footer-controls") || null),
    () =>
      buildPanel(
        "nav",
        [
          { keys: ["m", "w"], label: "Surveiller" },
          { keys: ["m", "t"], label: "Suivre" },
          { keys: ["m", "r"], label: "Normal" },
          { keys: ["m", "m"], label: "Muet" },
        ],
        { flexDirection: "column", gap: "3px" }
      ),
    { seq: true }
  );

  // ── Sélection groupée ───────────────────────────────────────────────────────────────

  hint(
    ["⇧ b"],
    "action",
    () => $("button.bulk-select"),
    "abs-br",
    null,
    false,
    "Sélection groupée"
  );
  hint(
    ["⇧ d"],
    "action",
    () => $("#dismiss-topics-top, #dismiss-new-top, .dismiss-read"),
    "inline",
    null,
    false,
    "Ignorer"
  );
  hint(
    ["x"],
    "nav",
    () =>
      $(".topic-list-item.selected td.bulk-select") ||
      $(".topic-list-item td.bulk-select"),
    "abs-br",
    null,
    false,
    "Sélectionner la ligne"
  );

  // ── Déconnexion ────────────────────────────────────────────────────────────────────

  hint(
    ["⇧ z", "⇧ z"],
    "action",
    () => $("li.logout button"),
    "inline",
    null,
    true,
    "Se déconnecter"
  );

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

  hint(
    ["-"],
    "action",
    () => $(".chat-header-icon"),
    "abs-br",
    null,
    false,
    "Basculer le chat"
  );

  // Alt+↑/↓, Alt+⇧↑/↓, ⇧Esc, Ctrl+K – panneau ajouté au tiroir de chat
  hintPanel(
    "action",
    "inline",
    "dso-hint-chat-composer",
    () => $(".chat-drawer-content"),
    () =>
      buildPanel(
        "action",
        [
          { keys: ["Ctrl", "k"], label: "Canal rapide" },
          { keys: ["Alt", "↑ / ↓"], label: "Changer de canal" },
          { keys: ["Alt", "⇧ ↑ / ↓"], label: "Changer non lu" },
          { keys: ["⇧", "Esc"], label: "Tout marquer comme lu" },
        ],
        {
          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,
    "Fermer le chat"
  );

  // ── Bouton de bascule ─────────────────────────────────────────────────────────────
  let visible = localStorage.getItem("dso-visible") === "true";

  const toggleBtn = el("button", {
    id: "dso-toggle-btn",
    title: "Basculer la superposition de raccourcis",
    textContent: "⌨",
  });
  if (!visible) {
    toggleBtn.classList.add("--off");
  }
  document.body.appendChild(toggleBtn);
  toggleBtn.addEventListener("click", () => {
    window.__dso__.toggle();
  });

  // ── MutationObserver ──────────────────────────────────────────────────────────
  let rafPending = false;

  function scheduleRefresh() {
    if (!rafPending) {
      rafPending = true;
      requestAnimationFrame(() => {
        rafPending = false;
        HINTS.forEach((h) => (h.multiSel ? injectMulti(h) : inject(h)));
      });
    }
  }

  const observer = new MutationObserver((mutations) => {
    const onlyOurs = mutations.every((m) => {
      if (m.type === "attributes") {
        return false;
      }
      return [...m.addedNodes, ...m.removedNodes].every(
        (n) => n.nodeType !== 1 || n.classList?.contains("dso-wrap")
      );
    });
    if (!onlyOurs) {
      scheduleRefresh();
    }
  });

  observer.observe(document.body, {
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ["class"],
  });

  document.addEventListener("focusin", scheduleRefresh, { passive: true });
  document.addEventListener("focusout", scheduleRefresh, { passive: true });

  document.body.classList.toggle("dso-hidden", !visible);
  HINTS.forEach((h) => (h.multiSel ? injectMulti(h) : inject(h)));

  // ── API publique ────────────────────────────────────────────────────────────────
  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] retiré.", "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 actif.",
    "color:#6af;font-weight:bold"
  );
})();

Remplacez ou ajoutez des correspondances d’URL (// @match https://meta.discourse.org/*) si vous souhaitez l’utiliser sur un autre forum Discourse.

Pour installer les userscripts, utilisez une extension de navigateur : Greasemonkey, Tampermonkey, ScriptCat, ou tout autre logiciel externe offrant cette fonctionnalité, par exemple AdGuard.

7 « J'aime »

Ceci est pour la barre latérale de l’administrateur

C’est ce que vous pouvez faire ici, au lieu d’utiliser le bouton ignorer en haut

1 « J'aime »

Sacré bleu ! Rien qu’avec votre capture d’écran, j’ai appris plus de 15 raccourcis clavier :eyes: !

4 « J'aime »

J’ai mis à jour le script.

J’ai ajouté les raccourcis manquants, en particulier dans les actions de publication, et j’ai rendu les couleurs moins agressives et indépendantes du thème (elles s’intègrent bien en mode sombre et clair).

Plus important encore, le script mémorise désormais son état et ne se réinitialisera pas lorsque nous rechargerons la page.

Je l’utilise depuis quelques jours et il fonctionne très bien pour apprendre les raccourcis !

Ce que je ressens maintenant en utilisant Discourse :

Hacker Reality: Typing on an RGB Keyboard

3 « J'aime »

Mise à jour mineure :

  • Le bouton bascule a été déplacé vers le bas pour éviter qu’il ne chevauche le menu des avatars sur les thèmes en largeur complète :

  • Correction d’un bug qui empêchait de cliquer sur le logo de Discourse pour revenir à l’accueil sur le thème meta personnalisé.