自定义网页按钮快捷键

给网页元素(翻页按钮等)绑定键鼠按键。支持为不同网页(URL)独立配置按键策略与翻页按钮定位规则。自动识别当前访问网址,精准匹配预设的自定义按键及翻页按钮CSS选择器,实现一键翻页的个性化操控体验。

// ==UserScript==
// @name            自定义网页按钮快捷键
// @name:en         PagePilot
// @namespace       https://greasyfork.org/scripts/541642
// @version         0.2.3
// @author          oajsdfk
// @description     给网页元素(翻页按钮等)绑定键鼠按键。支持为不同网页(URL)独立配置按键策略与翻页按钮定位规则。自动识别当前访问网址,精准匹配预设的自定义按键及翻页按钮CSS选择器,实现一键翻页的个性化操控体验。
// @description:en  Bind keyboard and mouse buttons to webpage elements (such as page-turning buttons). PagePilotPagePilot allows custom keybindings and CSS selectors for page-turning buttons to be uniquely configured per URL. Automatically detects webpage addresses and applies your preset rules, enabling precise one-click navigation across all sites.
// @license         MIT
// @match           *://*/*
// @grant           GM_addElement
// @grant           GM_getValue
// @grant           GM_setValue
// ==/UserScript==

(function () {
  'use strict';

  function pilot(url, event, options2) {
    const find = (keys) => {
      if (!keys) return false;
      if (event.type === "keyup") {
        const e = event;
        if (!checkModifier(keys.modifier, e)) return false;
        if (keys.key?.find((i) => i === e.key)) return true;
        if (keys.code?.find((i) => i === e.code)) return true;
      } else if (event.type === "mouseup") {
        const e = event;
        return keys.button === e.button && checkModifier(keys.modifier, e);
      }
      return false;
    };
    function checkModifier(modifier, e) {
      modifier = modifier ?? [];
      if (typeof modifier === "string") modifier = [modifier];
      for (const m of ["Alt", "Control", "Meta", "Shift"]) {
        if (modifier.includes(m) !== e.getModifierState(m)) return false;
      }
      return true;
    }
    const g = [];
    for (const [k, v] of Object.entries(options2.globalKeys)) {
      if (find(v)) g.push(k);
    }
    function matchGlobal(k, p) {
      if (find(p.keys)) return true;
      if (typeof p.globalKeys === "boolean") {
        if (!p.globalKeys) return false;
        return g.indexOf(k) != -1;
      } else if (typeof p.globalKeys === "string") {
        return g.indexOf(p.globalKeys) != -1;
      }
      return g.indexOf(k) != -1;
    }
    function matchKey(p) {
      if (p.cur) {
        if (!p.next && !p.prev && !p.other) {
          for (const i of g) {
            if (i === "next") return { child: p.cur, sibling: 1 };
            if (i === "prev") return { child: p.cur, sibling: -1 };
          }
          return;
        }
        for (const k of ["prev", "next"]) {
          if (!p[k]) continue;
          let r = typeof p[k] === "string" ? {
            child: p.cur,
            sibling: k === "next" ? 1 : -1,
            deep: { child: p[k] }
          } : p[k];
          if (matchGlobal(k, r)) return r;
        }
        for (const [k, v] of Object.entries(p.other ?? [])) {
          let r = typeof v === "string" ? {
            child: p.cur,
            sibling: k === "next" ? 1 : -1,
            deep: { child: v }
          } : v;
          if (matchGlobal(k, r)) return r;
        }
      } else {
        for (const k of ["prev", "next"]) {
          if (!p[k]) continue;
          let r = typeof p[k] === "string" ? { child: p[k] } : p[k];
          if (matchGlobal(k, r)) return r;
        }
        for (const [k, v] of Object.entries(p.other ?? [])) {
          let r = typeof v === "string" ? { child: v } : v;
          if (matchGlobal(k, r)) return r;
        }
      }
      return;
    }
    for (const [r, p] of Object.entries(options2.pilots)) {
      if (!url.match(new RegExp(r))) continue;
      const q = matchKey(p);
      if (!q) continue;
      const { child, sibling, deep } = q;
      if (!child) continue;
      const cur = document.querySelector(child);
      const ele = queryDeep({ sibling, deep }, cur);
      if (!ele) continue;
      ele.click();
      return ele;
    }
    return null;
  }
  function queryDeep({ sibling, child, deep }, e) {
    if (child) e = e?.querySelector(child);
    if (sibling) {
      let c = Math.abs(sibling);
      while (c > 0 && e) {
        c--;
        e = sibling > 0 ? e.nextElementSibling : e.previousElementSibling;
      }
      if (c !== 0) return null;
    }
    return deep ? queryDeep(deep, e) : e;
  }
  function renderPilotConfig(root, options2) {
    root.innerHTML = `
<style>
  #pilot-config-form button {
    transition: background 0.2s, color 0.2s, box-shadow 0.2s;
  }
  #pilot-config-form button:not([type="submit"]):hover,
  #pilot-config-form button:not([type="submit"]):focus {
    background: #ffd369;
    color: #232931;
    box-shadow: 0 2px 8px rgba(255, 211, 105, 0.15);
  }
  #pilot-config-form button:not([type="submit"]):active {
    background: #e1c15a;
    color: #232931;
  }
  #pilot-config-form button[type="submit"]:hover,
  #pilot-config-form button[type="submit"]:focus {
    background: #ff6b7c;
    color: #fff;
    box-shadow: 0 2px 8px rgba(255, 75, 92, 0.15);
  }
  #pilot-config-form button[type="submit"]:active {
    background: #d93a4a;
    color: #fff;
  }
  #pilot-config-form button[type="reset"]:hover,
  #pilot-config-form button[type="reset"]:focus {
    background: #ffd369;
    color: #232931;
    box-shadow: 0 2px 8px rgba(255, 211, 105, 0.15);
  }
  #pilot-config-form button[type="reset"]:active {
    background: #e1c15a;
    color: #232931;
  }
  #pilot-config-form button {
    border: none;
    border-radius: 6px;
    padding: 8px 18px;
    font-weight: 500;
    cursor: pointer;
    background: #393e46;
    color: #ffd369;
  }
</style>
<form style="
  display: flex;
  flex-direction: column;
  height: 100%;
  width: 100%;
  padding: 24px 32px;
  box-sizing: border-box;
  z-index: 50;
  position: fixed;
  left: 0; top: 0; right: 0; bottom: 0;
  background: linear-gradient(135deg, #232526 0%, #414345 100%);
  color: #f5f6fa;
  font-family: 'Segoe UI', 'Roboto', sans-serif;
  font-size: 16px;
  overflow-y: auto;
"
  id="pilot-config-form">
  <h1 style="
    font-weight: bold;
    text-align: center;
    height: 48px;
    margin-bottom: 20px;
    letter-spacing: 1px;
    color: #ffd369;
    text-shadow: 0 2px 8px rgba(0,0,0,0.2);
  ">Page Pilot Setting</h1>
  <textarea
    id="pagepilot_detail"
    style="
      flex-grow: 1;
      resize: none;
      border-radius: 8px;
      border: 1px solid #393e46;
      background: #232931;
      color: #f5f6fa;
      padding: 12px;
      margin-bottom: 18px;
      font-size: 15px;
      outline: none;
      box-shadow: 0 2px 8px rgba(0,0,0,0.08);
    ">
  </textarea>
  <div style="
    display: flex;
    flex-direction: row;
    height: 48px;
    gap: 14px;
    justify-content: center;
    align-items: center;
    padding: 0;
    margin-bottom: 8px;
  ">
    <button type="button" id="pilot-sample-btn">Sample</button>
    <button type="button" id="pilot-clear-btn">Clear</button>
    <button type="button" id="pilot-reset-btn">Reset</button>
    <button type="button" id="pilot-import-btn">Import</button>
    <button type="submit" style=" background: #ff4b5c; color: #fff; ">Save</button>
    <button type="reset" style=" background: #393e46; color: #fff; ">Close</button>
  </div>
</form>
  `;
    const form = root.querySelector("#pilot-config-form");
    const textareaEl = root.querySelector("#pagepilot_detail");
    form.onsubmit = (e) => {
      e.preventDefault();
      options2.save?.();
    };
    form.onreset = (e) => {
      e.preventDefault();
      options2.close?.();
    };
    root.querySelector("#pilot-sample-btn").onclick = () => {
      if (!options2.sample) return;
      textareaEl.value = options2.sample();
    };
    root.querySelector("#pilot-clear-btn").onclick = () => {
      if (!options2.clear) return;
      options2.clear();
      textareaEl.value = "";
    };
    root.querySelector("#pilot-reset-btn").onclick = () => {
      if (!options2.export) return;
      textareaEl.value = options2.export();
    };
    root.querySelector("#pilot-import-btn").onclick = () => {
      if (!options2.import) return;
      textareaEl.value = options2.import(textareaEl.value);
    };
  }
  function stringifyWithDepth(obj, maxDepth, space) {
    function helper(value, depth) {
      if (depth > maxDepth || value === null || typeof value !== "object") {
        return JSON.stringify(value);
      }
      if (Array.isArray(value)) {
        const items = value.map((item) => helper(item, depth + 1));
        if (depth < maxDepth) {
          return "[\n" + items.map((i) => " ".repeat((depth + 1) * space) + i).join(",\n") + "\n" + " ".repeat(depth * space) + "]";
        } else {
          return "[" + items.join(", ") + "]";
        }
      } else {
        const entries = Object.entries(value).map(
          ([k, v]) => depth < maxDepth ? " ".repeat((depth + 1) * space) + JSON.stringify(k) + ": " + helper(v, depth + 1) : JSON.stringify(k) + ": " + helper(v, depth + 1)
        );
        if (depth < maxDepth) {
          return "{\n" + entries.join(",\n") + "\n" + " ".repeat(depth * space) + "}";
        } else {
          return "{" + entries.join(", ") + "}";
        }
      }
    }
    return helper(obj, 0);
  }
  const lastOptionsVer = GM_getValue("pagepilot_options_ver");
  let options = GM_getValue("pagepilot_options");
  const optionsVer = 1;
  console.log({ toggle_pagepilot_options, optionsVer });
  if (!options || lastOptionsVer !== optionsVer) {
    options = {
      globalKeys: {
        // 绑定名称的通用按键,所有网站的元素根据名称自动绑定到这些按键上
        // Common keys bound by name, elements on all sites will be automatically bound to these keys
        prev: { code: ["ArrowUp", "PageUp", "KeyP", "KeyD"], button: 5 },
        next: { code: ["ArrowDown", "PageDown", "KeyN", "KeyF"], button: 4 },
        top: { code: ["Home", "KeyT"] },
        bottom: { code: ["End", "KeyB"] }
      },
      pilots: {
        // 站点正则 => 元素选择器或按键
        // site regex => element selector or key
        "http://example1.com(/.*)?": {
          // 当点击元素对应的按键时候,触发元素的点击事件: PageUp => querySelector('.pg .prev_btn').click()
          // When the corresponding key is pressed, trigger the element's click event: PageUp => querySelector('.pg .prev_btn').click()
          prev: ".pg .prev_btn",
          next: ".pg .next_btn"
        },
        // 通过当前页元素的兄弟来定位翻页按钮
        // locate the page-turning buttons by the siblings of the current page element
        "https://(.*.)?example2.org(/.*)?": { cur: "#cur_page" },
        // 通过当前页元素的兄弟的子孙来定位翻页按钮
        // locate the page-turning buttons by the descendants of the siblings of the current page element
        "https?://(.*.)?example3.org(/.*)?": {
          cur: "#cur_page",
          prev: "a",
          // querySelector('#cur_page').prevElment.querySelector(a).click()
          next: "a"
          // querySelector('#cur_page').nextElment.querySelector(a).click()
        },
        "https?://(.*.)?example4.org(/.*)?": {
          // 多层级子孙、兄弟的翻页按钮的定位规则: querySelector('.nav .pg').prevE.prevE.prevE.querySelector(a).click()
          // multi-level descendants and siblings page button location rules (): querySelector('.nav .pg').prevE.prevE.prevE.querySelector(a).click()
          prev: {
            child: ".nav",
            deep: {
              child: ".pg",
              sibling: -3,
              deep: {
                child: "a"
              }
            }
          },
          next: {
            child: ".nav",
            deep: {
              child: ".pg",
              sibling: 5,
              // querySelector('.nav .pg') .nextE x5 .querySelector(a).click()
              deep: {
                child: "a"
              }
            }
          },
          // 定义翻页以外的任意按钮
          // define any buttons other than page turning
          other: {
            // 显示指定绑定或者禁用全局按键
            // Explicitly bind or disable global keys
            top: "#top",
            bottom: {
              // 显示指定绑定或者禁用全局按键
              // Explicitly bind or disable global keys
              globalKeys: false,
              // disable
              child: "#bottomBtn",
              // 指定只对该网站生效的按键
              // Specify keys that only take effect on this site
              keys: { code: ["KeyN"] }
            },
            close: {
              child: "#closeBtn",
              keys: { code: ["KeyC"] }
            }
          }
        }
      }
    };
    GM_setValue("pagepilot_options", options);
    GM_setValue("pagepilot_options_ver", optionsVer);
  }
  console.log({ pagepilot_options_ver: optionsVer, pagepilot_options: options });
  window.addEventListener("keyup", function(e) {
    const ae = document.activeElement;
    if (ae && ["input", "select", "button", "textarea"].indexOf(
      ae.tagName.toLowerCase()
    ) !== -1) return;
    console.log({ key: e.key, code: e.code, event: e });
    if (pilot(window.location.href, e, options)) {
      e.preventDefault();
    }
  });
  window.addEventListener("mouseup", function(e) {
    if (e.button < 2) return;
    console.log({ button: e.button, event: e });
    if (pilot(window.location.href, e, options)) {
      e.preventDefault();
    }
  });
  function toggle_pagepilot_options() {
    let root = document.getElementById("pagepilot_options");
    if (root) {
      if (root.getAttribute("hidden") === null) root.setAttribute("hidden", "");
      else root.removeAttribute("hidden");
      return;
    }
    root = GM_addElement(document.body, "div", { id: "pagepilot_options" });
    renderPilotConfig(root, {
      export() {
        return stringifyWithDepth(options, 4, 2);
      },
      save() {
        GM_setValue("pagepilot_options", options);
      },
      close() {
        root.setAttribute("hidden", "");
      },
      import(json) {
        try {
          const j = JSON.parse(json);
          for (const [k, v] of Object.entries(j.globalKeys ?? {})) {
            options.globalKeys[k] = v;
          }
          for (const [k, v] of Object.entries(j.pilots ?? {})) {
            options.pilots[k] = v;
          }
          GM_setValue("pagepilot_options", options);
          return stringifyWithDepth(options, 4, 2);
        } catch (e) {
          console.error(e);
          return stringifyWithDepth(options, 4, 2);
        }
      },
      clear() {
        options = { globalKeys: {}, pilots: {} };
      },
      sample() {
        const s = {
          globalKeys: {
            prev: { code: ["ArrowUp", "PageUp", "KeyP", "KeyD"] },
            next: { code: ["ArrowDown", "PageDown", "KeyN", "KeyF"] },
            top: { code: ["Home", "KeyT"] },
            bottom: { code: ["End", "KeyB"] }
          },
          pilots: {
            "http://example1.com(/.*)?": {
              prev: ".pg .prev_btn",
              next: ".pg .next_btn"
            },
            "https://(.*.)?example2.org(/.*)?": { cur: "#cur_page" },
            "https?://(.*.)?example3.org(/.*)?": {
              cur: "#cur_page",
              prev: "a",
              next: "a"
            },
            "https?://(.*.)?example4.org(/.*)?": {
              prev: {
                child: ".nav",
                deep: {
                  child: ".pg",
                  sibling: -3,
                  deep: {
                    child: "a"
                  }
                }
              },
              next: {
                child: ".nav",
                deep: {
                  child: ".pg",
                  sibling: 5,
                  deep: {
                    child: "a"
                  }
                }
              },
              other: {
                top: "#top",
                bottom: {
                  globalKeys: false,
                  child: "#bottomBtn",
                  keys: { code: ["KeyN"] }
                },
                close: {
                  child: "#closeBtn",
                  keys: { code: ["KeyC"] }
                }
              }
            }
          }
        };
        return JSON.stringify(s, null, 2);
      }
    });
  }

})();