Shortcuts 오버레이: Discourse 키보드 단축키 학습을 돕는 userscript

이 사용자 스크립트는 Discourse 단축키를 포럼 화면에 직접 표시합니다:


기본적으로 비활성화되어 있으며, 오른쪽 위 아이콘을 클릭하여 토글할 수 있습니다. image

아름다움을 추구한 것은 아닙니다. 단축키를 사용하거나 암기하고 싶을 때만 활성화하세요 :slight_smile:

모든 기존 단축키를 표시하는 것은 아니지만, 대부분을 표시합니다.
북마크 관련 단축키는 (아직?) 포함되어 있지 않습니다. 사이트의 니치 섹션인 북마크에 해당하는 단축키가 너무 많기 때문입니다.

단축키 목록에서 이해하지 못하는 단축키가 몇 가지 있습니다 ?:

  • Ctrl + l → 사이드바 필터

  • 토픽 선택:
    Shift + d → 선택한 토픽 무시

  • a 선택한 내용을 열린 작성기에 삽입

누군가 이 단축키들이 무엇을 하는지 설명해 주실 수 있을까요? :thinking:

전체 코드
// ==UserScript==
// @name         Discourse Shortcut Overlay
// @namespace    https://meta.discourse.org/
// @version      1.1
// @description  Discourse UI 요소에 kbd 힌트를 주입하여 컨텍스트 내에서 키보드 단축키를 표시합니다.
// @match        https://meta.discourse.org/*
// @grant        none
// @run-at       document-idle
// @license      MIT
// ==/UserScript==
(function discourseShortcutOverlay() {
  "use strict";

  if (window.__dso__) {
    window.__dso__.stop();
  }

  // ── CSS ─────────────────────────────────────────────────────────────────────
  const css = document.createElement("style");
  css.id = "__dso_style";
  css.textContent = `
    .dso-wrap {
      display: inline-flex; align-items: center; gap: 2px;
      pointer-events: none; flex-shrink: 0; user-select: none; vertical-align: text-bottom;
    }
    .dso-inline { margin-left: auto; padding-left: 6px; }
    .dso-abs    { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); z-index: 99; }
    .dso-abs-br { position: absolute; right: 4px; bottom: 4px; z-index: 99; }
    .dso-abs-br.dso-hint-eq { right: unset; left: 4px; }
    .dso-abs-tr { position: absolute; right: 4px; top: 4px; z-index: 99; }
    .dso-wrap kbd {
      display: inline-flex; align-items: center; justify-content: center;
      padding: 0 5px; font: 700 11px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
      color: #fff; border-radius: 4px; border-color: rgba(255,255,255,.22);
      box-shadow: 0 1px 3px rgba(0,0,0,.2); min-height: 20px; white-space: nowrap; letter-spacing: 0;
    }
    .dso-wrap.nav    kbd { background: color-mix(in srgb, var(--success)  70%, var(--secondary)); }
    .dso-wrap.action kbd { background: color-mix(in srgb, var(--tertiary) 70%, var(--secondary)); }
    .dso-wrap.write  kbd { background: color-mix(in srgb, #8848b8        70%, var(--secondary)); }
    .dso-wrap.post   kbd { background: color-mix(in srgb, var(--danger)   70%, var(--secondary)); }
    .dso-wrap .dso-plus { font: 700 9px/1 ui-monospace, monospace; color: rgba(255,255,255,.5);  margin: 0 1px; }
    .dso-wrap .dso-seq  { font: 700 9px/1 ui-monospace, monospace; color: rgba(255,255,255,.45); margin: 0 2px; }
    .dso-gnav-label { font: 10px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; color: rgba(255,255,255,.35); }
    .dso-gnav-panel { display: flex; flex-wrap: wrap; gap: 4px 14px; padding: 4px 0 6px; vertical-align: unset; }
    .dso-gnav-panel .dso-gnav-row  { display: inline-flex; align-items: center; gap: 5px; }
    .dso-gnav-panel .dso-gnav-keys { display: inline-flex; align-items: center; }
    .timeline-container .topic-timeline .timeline-scroller-content { overflow: visible !important; }
    .timeline-container .topic-timeline .timeline-date-wrapper     { max-width: none !important; }
    body.dso-hidden .dso-wrap { display: none !important; }
    #dso-toggle-btn {
      position: fixed; top: calc(14px + 3.5em); right: 14px; z-index: 99999; cursor: pointer;
      background: var(--secondary); border: 1px solid var(--primary-low); border-radius: 6px;
      color: var(--primary-medium); padding: 5px 7px; line-height: 1;
      box-shadow: 0 2px 6px rgba(0,0,0,.1);
    }
    #dso-toggle-btn:hover { color: var(--primary); border-color: var(--primary-medium); }
    #dso-toggle-btn.--off { color: var(--primary-low); border-color: var(--primary-very-low, var(--primary-low)); }
  `;
  document.head.appendChild(css);

  // ── 힌트 레지스트리 ─────────────────────────────────────────────────────────────
  const HINTS = [];
  const $ = (s) => document.querySelector(s);

  function keysToClass(keys) {
    return (
      "dso-hint-" +
      keys
        .map((k) =>
          k
            .replace(/⇧\s*/g, "shift-")
            .replace(/↵/g, "enter")
            .replace(/↓/g, "down")
            .replace(/↑/g, "up")
            .replace(/\//g, "slash")
            .replace(/\?/g, "question")
            .replace(/!/g, "bang")
            .replace(/#/g, "hash")
            .replace(/\./g, "dot")
            .replace(/=/g, "eq")
            .toLowerCase()
            .replace(/\s+/g, "-")
            .replace(/[^a-z0-9-]/g, "")
            .replace(/-+/g, "-")
            .replace(/^-|-$/g, "")
        )
        .join("-")
    );
  }

  // 단일 대상 힌트
  function hint(
    keys,
    theme,
    targetFn,
    mode = "inline",
    beforeSel = null,
    seq = false,
    title = ""
  ) {
    HINTS.push({
      keys,
      theme,
      targetFn,
      mode,
      beforeSel,
      seq,
      title,
      hintClass: keysToClass(keys),
      customBuild: null,
      state: { el: null, wrap: null },
    });
  }

  // 다중 대상 힌트 – multiSel과 일치하는 모든 요소 뒤에 주입
  function hintMulti(
    keys,
    theme,
    multiSel,
    mode = "after",
    seq = false,
    title = ""
  ) {
    HINTS.push({
      keys,
      theme,
      multiSel,
      mode,
      seq,
      title,
      hintClass: keysToClass(keys),
      state: { pairs: [] },
    });
  }

  // 커스텀 패널 힌트
  function hintPanel(theme, mode, hintClass, targetFn, buildFn, opts = {}) {
    HINTS.push({
      keys: null,
      theme,
      mode,
      beforeSel: opts.beforeSel ?? null,
      seq: opts.seq ?? false,
      hintClass,
      targetFn,
      customBuild: buildFn,
      state: { el: null, wrap: null },
    });
  }

  // ── DOM 빌더 ──────────────────────────────────────────────────────────────

  // 마이크로 헬퍼: 속성이 할당된 요소 생성
  function el(tag, props = {}) {
    return Object.assign(document.createElement(tag), props);
  }

  function buildWrap(keys, theme, mode, seq = false, title = "") {
    const modeClass =
      { abs: "dso-abs", "abs-br": "dso-abs-br", "abs-tr": "dso-abs-tr" }[
        mode
      ] ?? (mode === "sibling" || mode === "after" ? "" : "dso-inline");
    const wrap = el("span", {
      className: `dso-wrap ${theme} ${modeClass}`.trimEnd(),
    });
    if (title) {
      wrap.title = title;
    }
    keys.forEach((k, i) => {
      if (i > 0) {
        wrap.appendChild(
          el("span", {
            className: seq ? "dso-seq" : "dso-plus",
            textContent: seq ? "→" : "+",
          })
        );
      }
      wrap.appendChild(el("kbd", { textContent: k }));
    });
    return wrap;
  }

  // 일반 패널: 선택적 라벨이 있는 kbd 시퀀스 행.
  // usePlus: 시퀀스("→") 대신 콤보("+") 구분자를 사용.
  function buildPanel(theme, entries, wrapStyle = {}, usePlus = false) {
    const wrap = el("span", { className: `dso-wrap ${theme}` });
    Object.assign(wrap.style, wrapStyle);
    for (const { keys, label } of entries) {
      const row = el("span");
      Object.assign(row.style, {
        display: "inline-flex",
        alignItems: "center",
        gap: "4px",
      });
      keys.forEach((k, i) => {
        if (i > 0) {
          row.appendChild(
            el("span", {
              className: usePlus ? "dso-plus" : "dso-seq",
              textContent: usePlus ? "+" : "→",
            })
          );
        }
        row.appendChild(el("kbd", { textContent: k }));
      });
      if (label) {
        row.appendChild(
          el("span", { className: "dso-gnav-label", textContent: label })
        );
      }
      wrap.appendChild(row);
    }
    return wrap;
  }

  // G-nav는 래핑 레이아웃을 위한 전용 CSS 클래스를 사용합니다
  function buildGNavPanel() {
    const panel = el("div", { className: "dso-wrap dso-gnav-panel nav" });
    for (const { keys, label } of [
      { keys: ["u"], label: "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;
  }

  // ── 주입 로직 ───────────────────────────────────────────────────────────
  function inject(h) {
    const target = h.targetFn();
    const { state, keys, theme, mode, beforeSel } = h;

    if (target === state.el) {
      return;
    } // 변경 없음 – 이미 주입됨

    if (state.wrap) {
      state.wrap.remove();
    }
    if (state.el?.dataset.dsoPos !== undefined) {
      state.el.style.position = state.el.dataset.dsoPos || "";
      delete state.el.dataset.dsoPos;
    }

    state.el = target;
    state.wrap = null;
    if (!target) {
      return;
    }

    if (mode === "abs" || mode === "abs-br" || mode === "abs-tr") {
      if (getComputedStyle(target).position === "static") {
        target.dataset.dsoPos = "";
        target.style.position = "relative";
      }
    }

    const wrap = h.customBuild
      ? h.customBuild()
      : buildWrap(keys, theme, mode, h.seq, h.title);
    if (h.hintClass) {
      wrap.classList.add(h.hintClass);
    }

    let insertParent, insertRef;
    if (mode === "after") {
      insertParent = target.parentNode;
      insertRef = target.nextSibling;
    } else {
      const beforeEl = beforeSel ? target.querySelector(beforeSel) : null;
      insertParent = beforeEl ? beforeEl.parentNode : target;
      insertRef = beforeEl;
    }
    insertParent.insertBefore(wrap, insertRef);
    state.wrap = wrap;
  }

  function injectMulti(h) {
    const { state } = h;
    const targets = new Set(document.querySelectorAll(h.multiSel));
    state.pairs = state.pairs.filter(({ el: e, wrap }) => {
      if (!targets.has(e)) {
        wrap.remove();
        return false;
      }
      return true;
    });
    const covered = new Set(state.pairs.map((p) => p.el));
    targets.forEach((target) => {
      if (covered.has(target)) {
        return;
      }
      const wrap = buildWrap(h.keys, h.theme, h.mode, h.seq, h.title);
      if (h.hintClass) {
        wrap.classList.add(h.hintClass);
      }
      target.parentNode?.insertBefore(wrap, target.nextSibling);
      state.pairs.push({ el: target, wrap });
    });
  }

  // ────────────────────────────────────────────────────────────────────────────
  // 힌트 정의
  // ────────────────────────────────────────────────────────────────────────────

  // ── 헤더 ───────────────────────────────────────────────────────────────────

  // /  → 검색  (입력 필드가 포커스될 때 숨김)
  hint(
    ["/"],
    "action",
    () => {
      const input = $("#header-search-input");
      if (!input || input === document.activeElement) {
        return null;
      }
      return input.closest(".search-input--header")?.parentElement ?? null;
    },
    "sibling",
    ".show-advanced-search",
    false,
    "Search"
  );

  // ↑ / ↓  → 검색 결과 탐색
  hint(
    ["↑ / ↓"],
    "action",
    () =>
      $(".search-input--header")
        ? $(".search-menu-initial-options, .search-menu-assistant") || null
        : null,
    "abs-tr",
    null,
    false,
    "Navigate results"
  );

  // Ctrl+↵  → 전체 페이지 검색 열기 (입력 필드가 포커스된 경우에만)
  hint(
    ["Ctrl", "↵"],
    "action",
    () => {
      const input = $("#header-search-input");
      if (!input || input !== document.activeElement) {
        return null;
      }
      return input;
    },
    "after",
    null,
    false,
    "Full page search"
  );

  // c  → 새 토픽
  hint(
    ["c"],
    "write",
    () => $("#create-topic"),
    "inline",
    null,
    false,
    "New topic"
  );

  // ?  → 키보드 단축키 모달
  hint(
    ["?"],
    "action",
    () => $(".keyboard-shortcuts-btn, button[aria-label='Keyboard shortcuts']"),
    "inline",
    null,
    false,
    "Shortcuts"
  );

  // .  → 새/업데이트된 토픽 배너 로드
  hint(
    ["."],
    "action",
    () => $(".show-more.has-topics a"),
    "inline",
    null,
    false,
    "Load new topics"
  );

  // ── 토픽 목록 ───────────────────────────────────────────────────────────────
  // 힌트가 해당 영역으로 넘치지 않도록 .more-topics__list / .suggested-topics 제외.

  function mainListRows() {
    return [...document.querySelectorAll(".topic-list-item")].filter(
      (r) => !r.closest(".more-topics__list, .suggested-topics, .more-topics")
    );
  }

  hint(
    ["j ↓"],
    "nav",
    () => {
      const rows = mainListRows();
      if (!rows.length) {
        return null;
      }
      const selIdx = rows.findIndex((r) => r.classList.contains("selected"));
      if (selIdx === -1) {
        return rows[0]?.querySelector(".main-link") || null;
      }
      if (selIdx >= rows.length - 1) {
        return null;
      }
      return rows[selIdx + 1]?.querySelector(".main-link") || null;
    },
    "abs",
    null,
    false,
    "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  → 선택된 토픽 열기
  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  → 다음/이전 네비게이션 필 섹션
  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"
  );

  // ── 작성기 ──────────────────────────────────────────────────────────────────

  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  → 축소된 작성기로 돌아감
  hint(
    ["⇧ c"],
    "write",
    () => $("#reply-control.draft .draft-text"),
    "inline",
    null,
    false,
    "Return to composer"
  );

  // ⇧F11  → 전체 화면 (작성기가 초안일 때 숨김)
  hint(
    ["⇧ F11"],
    "write",
    () => ($("#reply-control.draft") ? null : $(".toggle-fullscreen")),
    "after",
    null,
    false,
    "Fullscreen"
  );

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

  // ── 작성기 옵션 (+) 팝업 ────────────────────────────────────────────────

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

  // ── 게시물 작업 (모든 표시된 게시물에 주입) ─────────────────────────────

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

  // ── 토픽 보기 – 게시물 탐색 ──────────────────────────────────────────────

  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 – 오른쪽에 위치
  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",
        }
      )
  );

  // #  → 게시물 번호로 점프
  hint(
    ["#"],
    "nav",
    () => $(".timeline-container") && ($(".timeline-date-wrapper") || null),
    "inline",
    null,
    false,
    "Jump to post"
  );

  // ⇧l  → 첫 번째 안 읽은 게시물로 이동 – # 영역 안에 배치
  hint(
    ["⇧ l"],
    "nav",
    () => $(".timeline-container") && ($(".timeline-date-wrapper") || null),
    "inline",
    null,
    false,
    "First unread"
  );

  // ⇧r  → 토픽에 답변 (푸터 버튼)
  hint(
    ["⇧ r"],
    "post",
    () =>
      $(
        ".topic-footer-main-buttons button.btn-primary.create, #topic-footer-buttons .btn.reply"
      ),
    "inline",
    null,
    false,
    "Reply to topic"
  );

  // ── G-내비게이션 ──────────────────────────────────────────────────────────────

  // u + g→h … g→t – .nav-pills 위에 주입되는 패널
  hintPanel(
    "nav",
    "sibling",
    "dso-hint-gnav",
    () =>
      $(".navigation-container, .list-controls .container, .navigation-bar"),
    buildGNavPanel,
    { beforeSel: ".nav-pills", seq: true }
  );

  hint(
    ["g", "b"],
    "nav",
    () =>
      $(
        ".sidebar-section-link-wrapper a[href*='/bookmarks'], .user-menu a[href*='/bookmarks']"
      ),
    "inline",
    null,
    true,
    "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 – 토픽 제목 옆의 이전/다음 토픽 패널
  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 }
  );

  // ── 헤더 버튼 ────────────────────────────────────────────────────────────

  hint(
    ["="],
    "action",
    () => $(".header-sidebar-toggle")?.parentElement ?? null,
    "abs-br",
    null,
    false,
    "Sidebar"
  );
  hint(
    ["p"],
    "action",
    () => $("#current-user"),
    "abs-br",
    null,
    false,
    "Profile"
  );

  // ── 토픽 작업 ─────────────────────────────────────────────────────────────

  // f  → 토픽 북마크
  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 – 알림 수준 패널
  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 }
  );

  // ── 일괄 선택 ───────────────────────────────────────────────────────────────

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

  // ── 로그아웃 ────────────────────────────────────────────────────────────────────

  hint(
    ["⇧ z", "⇧ z"],
    "action",
    () => $("li.logout button"),
    "inline",
    null,
    true,
    "Log out"
  );

  // ── 채팅 ──────────────────────────────────────────────────────────────────────

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

  // Alt+↑/↓, Alt+⇧↑/↓, ⇧Esc, Ctrl+K – 채팅 드로워에 추가되는 패널
  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"
  );

  // ── 토글 버튼 ─────────────────────────────────────────────────────────────
  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)));

  // ── 공개 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"
  );
})();

다른 Discourse 포럼에서 사용하려면 URL 매칭(// @match https://meta.discourse.org/*)을 교체하거나 추가하세요.

사용자 스크립트를 설치하려면 브라우저 확장 프로그램(Greasemonkey, Tampermonkey, ScriptCat) 또는 AdGuard와 같이 해당 기능을 제공하는 외부 소프트웨어를 사용하세요.

7개의 좋아요

이것은 관리자 사이드바를 위한 것입니다.

여기서 할 수 있는 작업입니다. 상단의 무시 버튼을 사용하는 대신 이 방법을 사용할 수 있습니다.

1개의 좋아요

와! 스크린샷 하나만 보고도 키보드 단축키 15개 이상을 배웠어요 :eyes: !

4개의 좋아요

스크립트를 업데이트했습니다.

특히 게시물 작업(post actions)에서 누락된 단축키를 추가하고, 색상을 덜 강하게 조정하여 다크 모드와 라이트 모드 모두에서 자연스럽게 어울리도록 만들었습니다.

더 중요한 것은 스크립트가 이제 상태를 기억하므로 페이지를 새로고침해도 초기화되지 않는다는 점입니다.

지난 며칠 동안 사용해 봤는데, 단축키를 익히는 데 정말 잘 작동합니다!

이제 디스코스를 사용할 때 제가 느끼는 기분:

Hacker Reality: Typing on a RGB Keyboard

3개의 좋아요

작은 업데이트:

  • 전체 너비 테마에서 아바타 메뉴와 겹치지 않도록 토글 버튼을 아래로 내렸습니다:

  • 메타 브랜디드 테마에서 Discourse 로고를 클릭하여 홈으로 이동하지 못하던 버그를 수정했습니다.