keyboard-shortcuts

Shared keyboard-shortcut helper for userscripts.

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.greasyfork.org/scripts/592123/1907419/keyboard-shortcuts.js

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// Shared keyboard-shortcut helper for userscripts.
//
// NOT a userscript — no metadata block. Pull it in with:
//
//   // @require https://raw.githubusercontent.com/jshute96/userscripts/main/lib/keyboard-shortcuts.js
//
// The manager runs @require'd code in the userscript's own sandbox
// immediately before the script body, so the `KeyboardShortcuts`
// const below is visible to the body.
//
// What it does:
//   * Declarative key registration with labels.
//   * The guards every one of our key handlers had copy-pasted:
//     skip while typing, skip on unwanted modifiers, Caps-Lock-safe
//     letter matching.
//   * A `?` help overlay listing every shortcut registered on the
//     page — including ones registered by *other* userscripts.
//
// Cross-script sharing: each userscript is sandboxed, so this file
// runs once per script with its own private state. The shared piece
// is a DOM node (`#userscript-shortcuts`) that every instance writes
// its own metadata into, so any instance can render the full list.
// Only the metadata crosses the boundary — handlers stay in their
// own sandbox, which is all the help screen needs.

const KeyboardShortcuts = (function () {
  'use strict';

  const REGISTRY_ID = 'userscript-shortcuts';
  const HELP_ID = 'userscript-shortcuts-help';
  const HELP_KEY = '?';

  // ---------------------------------------------------------------
  // Key specs
  // ---------------------------------------------------------------
  // A spec is zero or more dash-separated modifiers followed by one
  // base key: 'j', 'shift-i', 'ctrl-alt-c', '?'.

  const MODIFIERS = {
    ctrl: 'ctrl', control: 'ctrl',
    alt: 'alt',
    meta: 'meta', cmd: 'meta',
    shift: 'shift',
  };

  const LETTER = /^[a-z]$/;

  function parseSpec(spec) {
    const parts = String(spec).split('-');
    // '-' splits to ['', ''] and 'shift--' to ['shift', '', ''], so
    // both the base and the modifier list can pick up an empty string.
    // The base falls back to '-'; empty modifiers are skipped below
    // rather than reported as unknown ones.
    const key = (parts.pop() || '-').toLowerCase();
    const b = { key, ctrl: false, alt: false, meta: false, shift: false };
    for (const raw of parts) {
      if (raw === '') continue;
      const mod = MODIFIERS[raw.toLowerCase()];
      if (!mod) throw new Error(`unknown modifier "${raw}" in key spec "${spec}"`);
      b[mod] = true;
    }
    return b;
  }

  function matches(b, e) {
    // Compare lowercased, so Caps Lock doesn't stop `j` from matching.
    if (e.key.toLowerCase() !== b.key) return false;
    if (e.ctrlKey !== b.ctrl) return false;
    if (e.altKey !== b.alt) return false;
    if (e.metaKey !== b.meta) return false;
    // Shift is only enforced for letters, where 'i' vs 'shift-i' is a
    // real distinction and we want the *modifier*, not the character
    // case, to decide (Caps Lock + i must not read as Shift-I).
    // Symbol keys like '?' are produced *by* shift on most layouts,
    // so enforcing shift:false there would make them unbindable.
    if (LETTER.test(b.key) && e.shiftKey !== b.shift) return false;
    return true;
  }

  function displaySpec(b) {
    const parts = [];
    if (b.ctrl) parts.push('Ctrl');
    if (b.alt) parts.push('Alt');
    if (b.meta) parts.push('Cmd');
    if (b.shift) parts.push('Shift');
    parts.push(LETTER.test(b.key) && b.shift ? b.key.toUpperCase() : b.key);
    return parts.join('-');
  }

  const HELP_BINDING = parseSpec(HELP_KEY);

  // ---------------------------------------------------------------
  // "Is the user typing?"
  // ---------------------------------------------------------------

  function isTypingElement(el) {
    if (!el || el.nodeType !== 1) return false;
    const tag = el.tagName;
    if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
    return !!el.isContentEditable;
  }

  // composedPath() crosses shadow boundaries. Events originating
  // inside an open shadow root (WaPo's Coral drawer) retarget to the
  // host by the time a document listener sees them, so e.target alone
  // can't tell us the user is typing in a reply box. On a plain page
  // composedPath is just the normal ancestor chain, so this is always
  // the right check.
  function isTypingEvent(e) {
    const path = e.composedPath ? e.composedPath() : [e.target];
    return path.some(isTypingElement);
  }

  // ---------------------------------------------------------------
  // Cross-script registry (a DOM node, because sandboxes don't share
  // JS state but do share the document)
  // ---------------------------------------------------------------

  // Hung off <html> rather than <body>. Registration happens once, at
  // script load, and these scripts are @match'ed at the site root so
  // they stay resident across SPA navigation — if a site ever replaced
  // its <body>, the registry would go with it and `?` would come up
  // empty with no way to rebuild it (the other sandboxes have long
  // since finished registering). <html> is never swapped out.
  function registryHost() {
    let host = document.getElementById(REGISTRY_ID);
    if (!host) {
      host = document.createElement('div');
      host.id = REGISTRY_ID;
      host.hidden = true;
      document.documentElement.appendChild(host);
    }
    return host;
  }

  // One <script type="application/json"> per userscript, keyed by
  // name so a re-run replaces its entry instead of duplicating it.
  // The type keeps it inert — never executed, so no CSP concern —
  // and out of layout.
  function publish(name, entries) {
    const host = registryHost();
    let node = host.querySelector(`script[data-userscript="${CSS.escape(name)}"]`);
    if (!node) {
      node = document.createElement('script');
      node.type = 'application/json';
      node.dataset.userscript = name;
      host.appendChild(node);
    }
    node.textContent = JSON.stringify(entries);
  }

  function readRegistry() {
    const host = document.getElementById(REGISTRY_ID);
    if (!host) return [];
    const groups = [];
    for (const node of host.querySelectorAll('script[data-userscript]')) {
      let entries;
      try {
        entries = JSON.parse(node.textContent);
      } catch (err) {
        console.log('[shortcuts] unreadable registry entry for',
          node.dataset.userscript, err.message);
        continue;
      }
      if (Array.isArray(entries) && entries.length) {
        groups.push({ script: node.dataset.userscript, entries });
      }
    }
    groups.sort((a, b) => a.script.localeCompare(b.script));
    return groups;
  }

  // ---------------------------------------------------------------
  // Help overlay
  // ---------------------------------------------------------------
  // Rendered into a shadow root so the host page's CSS can't reach
  // in and wreck it, and as a <dialog> so it lands in the browser's
  // top layer — immune to z-index and stacking-context fights with
  // the site — and gets Esc-to-close for free.

  const HELP_CSS = `
    :host { all: initial; }
    dialog {
      border: none;
      border-radius: 10px;
      padding: 22px 26px;
      max-width: 620px;
      max-height: 80vh;
      overflow-y: auto;
      background: #1e1f22;
      color: #e8e8ea;
      font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
      box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
    }
    dialog::backdrop { background: rgba(0, 0, 0, 0.55); }
    /* showModal() focuses the dialog, and the UA draws its focus ring
       around the whole panel. Nothing here is keyboard-operable
       except Esc, so the ring is pure noise. */
    dialog:focus, dialog:focus-visible { outline: none; }
    h1 { font-size: 16px; margin: 0 0 14px; font-weight: 600; }
    h2 {
      font-size: 12px;
      font-weight: 600;
      text-transform: uppercase;
      letter-spacing: 0.06em;
      color: #9aa0a6;
      margin: 20px 0 8px;
    }
    dl {
      display: grid;
      grid-template-columns: max-content 1fr;
      gap: 6px 14px;
      margin: 0;
    }
    dt { margin: 0; }
    dd { margin: 0; }
    kbd {
      display: inline-block;
      min-width: 1.1em;
      padding: 1px 7px;
      border: 1px solid #4a4d52;
      border-bottom-width: 2px;
      border-radius: 4px;
      background: #2b2d31;
      font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
      text-align: center;
    }
    .note { color: #9aa0a6; }
    .note::before { content: " — "; }
  `;

  function addRow(dl, keys, label, note) {
    const dt = document.createElement('dt');
    const kbd = document.createElement('kbd');
    kbd.textContent = keys;
    dt.appendChild(kbd);

    const dd = document.createElement('dd');
    dd.textContent = label;
    if (note) {
      const span = document.createElement('span');
      span.className = 'note';
      span.textContent = note;
      dd.appendChild(span);
    }
    dl.append(dt, dd);
  }

  // Returns true if it opened the overlay. Every script on the page
  // binds `?`, so all their handlers fire on the same keypress — the
  // ID check makes the first one win and the rest no-op. It doesn't
  // matter which wins: the renderer reads the shared DOM registry, so
  // it draws every script's bindings, not just its own.
  function showHelp() {
    if (document.getElementById(HELP_ID)) return false;
    const groups = readRegistry();
    if (!groups.length) {
      console.log('[shortcuts] ? pressed but no shortcuts are registered');
      return false;
    }

    const host = document.createElement('div');
    host.id = HELP_ID;
    const root = host.attachShadow({ mode: 'open' });

    const style = document.createElement('style');
    style.textContent = HELP_CSS;
    root.appendChild(style);

    const dlg = document.createElement('dialog');
    dlg.setAttribute('aria-label', 'Keyboard shortcuts from userscripts');

    const h1 = document.createElement('h1');
    h1.textContent = 'Keyboard shortcuts from userscripts';
    dlg.appendChild(h1);

    // `?` itself, as a plain row above the per-script groups. It
    // belongs to no single userscript, so putting it first lets it
    // stand without a heading.
    const own = document.createElement('dl');
    addRow(own, HELP_KEY, 'Show this list');
    dlg.appendChild(own);

    for (const group of groups) {
      const h2 = document.createElement('h2');
      h2.textContent = group.script;
      dlg.appendChild(h2);
      const dl = document.createElement('dl');
      for (const entry of group.entries) {
        addRow(dl, entry.keys, entry.label, entry.note);
      }
      dlg.appendChild(dl);
    }

    root.appendChild(dlg);
    (document.body || document.documentElement).appendChild(host);

    let closed = false;
    function closeHelp() {
      if (closed) return;
      closed = true;
      window.removeEventListener('keydown', onEscape, true);
      if (dlg.open) dlg.close();
      host.remove();
    }

    // Esc is handled explicitly rather than left to <dialog>'s built-in
    // close request. Leaving it to the browser failed in both
    // directions on real sites:
    //
    //   * NYTimes — the dialog closed, but the keydown went on
    //     propagating and NYT's own Escape handler closed the comments
    //     panel behind it. One keystroke dismissed the help *and* what
    //     the user was reading.
    //   * Washington Post — Coral suppresses the default close, so the
    //     overlay stayed up and only a click outside dismissed it.
    //
    // Listening on `window` in capture phase puts us at the very front
    // of the propagation path, ahead of any document-level handler the
    // site registered earlier, and stopImmediatePropagation keeps the
    // site from seeing a keystroke that was aimed at our overlay.
    function onEscape(e) {
      if (e.key !== 'Escape') return;
      e.preventDefault();
      e.stopPropagation();
      e.stopImmediatePropagation();
      closeHelp();
    }
    window.addEventListener('keydown', onEscape, true);

    dlg.addEventListener('close', closeHelp);
    // Clicking the ::backdrop reports the dialog itself as the target.
    dlg.addEventListener('click', e => { if (e.target === dlg) closeHelp(); });
    dlg.showModal();

    const total = groups.reduce((n, g) => n + g.entries.length, 0);
    console.log('[shortcuts] help:', total, 'shortcuts from',
      groups.length, 'userscript(s)');
    return true;
  }

  function helpOpen() {
    return !!document.getElementById(HELP_ID);
  }

  // ---------------------------------------------------------------
  // Service
  // ---------------------------------------------------------------

  function scriptName(explicit, tag) {
    if (explicit) return explicit;
    // GM_info is populated even under `@grant none`. `typeof` rather
    // than a bare reference: an undefined global would throw a
    // ReferenceError and abort the whole userscript.
    if (typeof GM_info !== 'undefined' && GM_info && GM_info.script
        && GM_info.script.name) {
      return GM_info.script.name;
    }
    return tag;
  }

  // options:
  //   tag      log prefix, e.g. '[pb nav]'
  //   name     display name for the help screen; defaults to the
  //            userscript's own @name via GM_info
  //   capture  bind in capture phase and stopImmediatePropagation on
  //            handled keys, to beat a site's own bindings (Reddit)
  function create(options) {
    const opts = options || {};
    const tag = opts.tag || '[shortcuts]';
    const name = scriptName(opts.name, tag);
    const capture = !!opts.capture;

    const bindings = [];
    let unhandled = null;
    let attached = false;
    let flushQueued = false;

    function flush() {
      flushQueued = false;
      publish(name, bindings.map(b => {
        const entry = { keys: b.display, label: b.label };
        if (b.note) entry.note = b.note;
        return entry;
      }));
    }

    // Registration is usually a run of synchronous calls; batch them
    // into one registry write. Later registrations re-flush.
    function queueFlush() {
      if (flushQueued) return;
      flushQueued = true;
      queueMicrotask(flush);
    }

    function onKeyDown(e) {
      if (isTypingEvent(e)) return;
      // The modal owns the keyboard while it's up; Esc closes it.
      if (helpOpen()) return;

      if (matches(HELP_BINDING, e)) {
        if (showHelp()) e.preventDefault();
        return;
      }

      for (const b of bindings) {
        if (!matches(b, e)) continue;
        // A binding whose `when` is false isn't handled at all — it
        // falls through to onUnhandledKey like any unbound key.
        if (b.when && !b.when()) continue;
        e.preventDefault();
        if (capture) {
          e.stopPropagation();
          e.stopImmediatePropagation();
        }
        b.handler(e);
        return;
      }

      if (unhandled) unhandled(e);
    }

    function attach() {
      if (attached) return;
      attached = true;
      document.addEventListener('keydown', onKeyDown, capture);
    }

    const api = {
      // spec:    'j', 'shift-i', 'ctrl-c'
      // label:   user-facing description, shown on the help screen
      // handler: fn(event)
      // extra:   { when: () => bool, note: 'shown greyed on the help screen' }
      register(spec, label, handler, extra) {
        const b = parseSpec(spec);
        b.display = displaySpec(b);
        b.label = label;
        b.handler = handler;
        if (extra) {
          b.when = extra.when;
          b.note = extra.note;
        }
        bindings.push(b);
        attach();
        queueFlush();
        return api;
      },

      // Called for any keypress that passed the typing/modal guards
      // but matched no binding. Comment navigation uses it to drop a
      // remembered scroll target when the user moves the viewport
      // themselves (PageDown, arrows, space).
      onUnhandledKey(cb) {
        unhandled = cb;
        return api;
      },

      // One-line summary for the console, generated from the labels
      // so it can't drift out of sync with what's actually bound.
      logKeys() {
        const list = bindings.map(b => `${b.display}=${b.label}`).join(', ');
        console.log(tag, `keys: ${list}, ${HELP_KEY}=show all shortcuts`);
      },

      get name() { return name; },
      showHelp,
    };

    return api;
  }

  return { create, showHelp, readRegistry, isTypingEvent };
})();