Mouse Plus

Ctrl + right-click mouse gestures for basic webpage navigation.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

作者のサイトでサポートを受ける。または、このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Mouse Plus
// @name:zh-CN  Mouse Plus 鼠标手势
// @namespace    https://github.com/maya1900/mouse-plus
// @version      1.0.2
// @description  Ctrl + right-click mouse gestures for basic webpage navigation.
// @description:zh-CN  使用 Ctrl + 右键鼠标手势执行网页后退、前进、刷新、滚动等基础操作。
// @author       mayang
// @match        *://*/*
// @homepageURL  https://github.com/maya1900/mouse-plus
// @supportURL   https://github.com/maya1900/mouse-plus/issues
// @license      MIT
// @compatible   chrome
// @compatible   edge
// @compatible   firefox
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_openInTab
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.openInTab
// @run-at       document-start
// @noframes
// ==/UserScript==

(function () {
  'use strict';

  const SETTINGS = {
    button: 2,
    startGestureDistance: 10,
    minMoveDistance: 10,
    minDirectionDistance: 24,
    overlayZIndex: 2147483647,
    lineColor: '#2f80ed',
    lineWidth: 3,
    pointColor: 'rgba(47, 128, 237, 0.16)',
    hintDuration: 950,
    hintOffset: 18,
  };

  const GESTURES = new Map([
    ['L', { label: '后退', action: () => window.history.back() }],
    ['R', { label: '前进', action: () => window.history.forward() }],
    ['U', { label: '回到顶部', action: () => window.scrollTo({ top: 0, behavior: 'smooth' }) }],
    ['D', { label: '滚到底部', action: () => window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' }) }],
    ['UD', { label: '刷新', action: () => window.location.reload() }],
    ['DU', { label: '硬性重新加载', action: () => hardReload() }],
    ['UL', { label: '清空缓存刷新', action: () => clearCacheAndReload() }],
    ['LDR', { label: '打开刚才关闭的网页', action: (point) => openRecentlyClosedPage(point) }],
    ['LR', { label: '重新打开当前页', action: () => window.location.reload() }],
    ['RL', { label: '停止加载', action: () => window.stop() }],
    ['DR', { label: '跳转空白页', action: () => openBlankPage() }],
    ['DL', { label: '最小化滚动到左侧', action: () => window.scrollTo({ left: 0, behavior: 'smooth' }) }],
    ['RD', { label: '向下翻页', action: () => window.scrollBy({ top: window.innerHeight * 0.9, behavior: 'smooth' }) }],
    ['RU', { label: '向上翻页', action: () => window.scrollBy({ top: -window.innerHeight * 0.9, behavior: 'smooth' }) }],
  ]);

  const DIRECTIONS = {
    L: '←',
    R: '→',
    U: '↑',
    D: '↓',
  };

  const RECENT_CLOSED_KEY = 'mouse-plus:recent-closed-page';
  const CACHE_BUSTER_PARAM = '__mouse_plus_cache_reload';

  let state = null;
  let canvas = null;
  let context = null;
  let hint = null;
  let hintTimer = 0;
  let suppressContextMenuUntil = 0;

  function gmGetValue(key, fallbackValue) {
    if (typeof GM_getValue === 'function') {
      return Promise.resolve(GM_getValue(key, fallbackValue));
    }

    if (typeof GM !== 'undefined' && typeof GM.getValue === 'function') {
      return GM.getValue(key, fallbackValue);
    }

    return Promise.resolve(fallbackValue);
  }

  function gmSetValue(key, value) {
    if (typeof GM_setValue === 'function') {
      GM_setValue(key, value);
      return Promise.resolve();
    }

    if (typeof GM !== 'undefined' && typeof GM.setValue === 'function') {
      return GM.setValue(key, value);
    }

    return Promise.resolve();
  }

  function gmOpenInTab(url) {
    if (typeof GM_openInTab === 'function') {
      GM_openInTab(url, { active: true, insert: true });
      return;
    }

    if (typeof GM !== 'undefined' && typeof GM.openInTab === 'function') {
      GM.openInTab(url, { active: true, insert: true });
      return;
    }

    window.open(url, '_blank', 'noopener,noreferrer');
  }

  function canStoreCurrentPage() {
    return window.location.protocol === 'http:' || window.location.protocol === 'https:';
  }

  function storeCurrentPage() {
    if (!canStoreCurrentPage()) {
      return;
    }

    gmSetValue(RECENT_CLOSED_KEY, {
      title: document.title || window.location.href,
      url: window.location.href,
      storedAt: Date.now(),
    });
  }

  function makeCacheBustedUrl() {
    const url = new URL(window.location.href);
    url.searchParams.set(CACHE_BUSTER_PARAM, String(Date.now()));
    return url.toString();
  }

  function hardReload() {
    window.location.replace(makeCacheBustedUrl());
  }

  async function clearCacheAndReload() {
    if ('caches' in window) {
      const names = await window.caches.keys();
      await Promise.all(names.map((name) => window.caches.delete(name)));
    }

    window.location.replace(makeCacheBustedUrl());
  }

  async function openRecentlyClosedPage(point) {
    const recentPage = await gmGetValue(RECENT_CLOSED_KEY, null);

    if (!recentPage || !recentPage.url) {
      showHint('没有记录到刚才关闭的网页', point.x, point.y, 'error');
      return;
    }

    gmOpenInTab(recentPage.url);
  }

  function openBlankPage() {
    window.location.href = 'about:blank';
  }

  function runGestureAction(gesture, point) {
    try {
      const result = gesture.action(point);

      if (result && typeof result.catch === 'function') {
        result.catch(() => {
          showHint('操作执行失败', point.x, point.y, 'error');
        });
      }
    } catch (error) {
      showHint('操作执行失败', point.x, point.y, 'error');
    }
  }

  function isEditableTarget(target) {
    if (!(target instanceof Element)) {
      return false;
    }

    const editable = target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]');
    return Boolean(editable);
  }

  function createOverlay() {
    if (canvas && context && hint) {
      return;
    }

    canvas = document.createElement('canvas');
    canvas.style.cssText = [
      'position:fixed',
      'inset:0',
      'width:100vw',
      'height:100vh',
      'pointer-events:none',
      `z-index:${SETTINGS.overlayZIndex}`,
    ].join(';');

    hint = document.createElement('div');
    hint.style.cssText = [
      'position:fixed',
      'left:0',
      'top:0',
      'transform:translate3d(-9999px,-9999px,0)',
      'pointer-events:none',
      `z-index:${SETTINGS.overlayZIndex}`,
      'box-sizing:border-box',
      'max-width:min(360px,calc(100vw - 32px))',
      'padding:8px 11px',
      'border-radius:8px',
      'background:rgba(18,22,30,0.92)',
      'color:#fff',
      'font:13px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
      'box-shadow:0 8px 22px rgba(0,0,0,0.22)',
      'white-space:nowrap',
      'user-select:none',
    ].join(';');

    const root = document.documentElement;
    root.appendChild(canvas);
    root.appendChild(hint);
    context = canvas.getContext('2d');
    resizeCanvas();
  }

  function resizeCanvas() {
    if (!canvas || !context) {
      return;
    }

    const ratio = window.devicePixelRatio || 1;
    const width = window.innerWidth;
    const height = window.innerHeight;

    canvas.width = Math.max(1, Math.floor(width * ratio));
    canvas.height = Math.max(1, Math.floor(height * ratio));
    canvas.style.width = `${width}px`;
    canvas.style.height = `${height}px`;
    context.setTransform(ratio, 0, 0, ratio, 0, 0);
  }

  function clearCanvas() {
    if (!context) {
      return;
    }

    context.clearRect(0, 0, window.innerWidth, window.innerHeight);
  }

  function showHint(text, x, y, type = 'normal') {
    createOverlay();
    clearTimeout(hintTimer);

    hint.textContent = text;
    hint.style.background = type === 'error' ? 'rgba(168, 44, 44, 0.94)' : 'rgba(18,22,30,0.92)';

    const maxX = Math.max(12, window.innerWidth - hint.offsetWidth - 12);
    const maxY = Math.max(12, window.innerHeight - hint.offsetHeight - 12);
    hint.style.transform = `translate3d(${clamp(x + SETTINGS.hintOffset, 12, maxX)}px, ${clamp(y + SETTINGS.hintOffset, 12, maxY)}px, 0)`;

    hintTimer = window.setTimeout(() => {
      if (hint) {
        hint.style.transform = 'translate3d(-9999px,-9999px,0)';
      }
    }, SETTINGS.hintDuration);
  }

  function clamp(value, min, max) {
    return Math.max(min, Math.min(value, max));
  }

  function formatGesture(sequence) {
    if (!sequence) {
      return '无';
    }

    return Array.from(sequence, (direction) => DIRECTIONS[direction] || direction).join('');
  }

  function drawPath(points) {
    clearCanvas();

    if (!context || points.length < 2) {
      return;
    }

    context.lineCap = 'round';
    context.lineJoin = 'round';
    context.lineWidth = SETTINGS.lineWidth;
    context.strokeStyle = SETTINGS.lineColor;
    context.beginPath();
    context.moveTo(points[0].x, points[0].y);

    for (let index = 1; index < points.length; index += 1) {
      context.lineTo(points[index].x, points[index].y);
    }

    context.stroke();

    const last = points[points.length - 1];
    context.fillStyle = SETTINGS.pointColor;
    context.beginPath();
    context.arc(last.x, last.y, 9, 0, Math.PI * 2);
    context.fill();
  }

  function resolveDirection(fromPoint, toPoint) {
    const dx = toPoint.x - fromPoint.x;
    const dy = toPoint.y - fromPoint.y;
    const distance = Math.hypot(dx, dy);

    if (distance < SETTINGS.minDirectionDistance) {
      return '';
    }

    if (Math.abs(dx) > Math.abs(dy)) {
      return dx > 0 ? 'R' : 'L';
    }

    return dy > 0 ? 'D' : 'U';
  }

  function updateGesture(event) {
    if (!state) {
      return;
    }

    const point = { x: event.clientX, y: event.clientY };
    const lastPoint = state.points[state.points.length - 1];
    const moveDistance = Math.hypot(point.x - lastPoint.x, point.y - lastPoint.y);
    const startDistance = Math.hypot(point.x - state.startPoint.x, point.y - state.startPoint.y);

    if (!state.active && startDistance < SETTINGS.startGestureDistance) {
      return;
    }

    if (!state.active) {
      state.active = true;
      suppressContextMenuUntil = Date.now() + 800;
      createOverlay();
      resizeCanvas();
      clearCanvas();
      showHint('开始手势', state.startPoint.x, state.startPoint.y);
    }

    if (moveDistance < SETTINGS.minMoveDistance) {
      return;
    }

    state.points.push(point);
    const direction = resolveDirection(state.directionAnchor, point);

    if (direction) {
      if (direction !== state.sequence.charAt(state.sequence.length - 1)) {
        state.sequence += direction;
      }

      state.directionAnchor = point;
    }

    drawPath(state.points);
    const gesture = GESTURES.get(state.sequence);
    const label = gesture ? gesture.label : '识别中';
    showHint(`${formatGesture(state.sequence)} ${label}`, point.x, point.y);
  }

  function startGesture(event) {
    if (event.button !== SETTINGS.button || !event.ctrlKey || isEditableTarget(event.target)) {
      return;
    }

    const startPoint = { x: event.clientX, y: event.clientY };
    state = {
      active: false,
      startPoint,
      points: [startPoint],
      sequence: '',
      directionAnchor: startPoint,
      startedAt: Date.now(),
      target: event.target,
    };

    event.preventDefault();
    event.stopPropagation();
    suppressContextMenuUntil = Date.now() + 1200;
  }

  function executeGesture(currentState, point) {
    clearCanvas();

    if (!currentState.sequence) {
      showHint('未检测到手势', point.x, point.y, 'error');
      return;
    }

    const gesture = GESTURES.get(currentState.sequence);
    if (!gesture) {
      showHint(`无效手势 ${formatGesture(currentState.sequence)}`, point.x, point.y, 'error');
      return;
    }

    showHint(`${formatGesture(currentState.sequence)} ${gesture.label}`, point.x, point.y);
    window.setTimeout(() => {
      runGestureAction(gesture, point);
    }, 80);
  }

  function finishGesture(event) {
    if (!state || event.button !== SETTINGS.button) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();
    suppressContextMenuUntil = Date.now() + 800;

    const currentState = state;
    const point = { x: event.clientX, y: event.clientY };
    state = null;

    if (!currentState.active) {
      clearCanvas();
      return;
    }

    executeGesture(currentState, point);
  }

  function cancelGesture(event) {
    if (!state) {
      return;
    }

    const wasActive = state.active;

    if (wasActive) {
      event.preventDefault();
      event.stopPropagation();
      suppressContextMenuUntil = Date.now() + 800;
    }

    state = null;
    clearCanvas();

    if (!wasActive) {
      return;
    }

    showHint('手势已取消', event.clientX || 20, event.clientY || 20, 'error');
  }

  function blockContextMenu(event) {
    if (!state && Date.now() > suppressContextMenuUntil) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();

    suppressContextMenuUntil = Date.now() + 800;
  }

  function onMouseMove(event) {
    if (!state) {
      return;
    }

    updateGesture(event);

    if (state && state.active) {
      event.preventDefault();
      event.stopPropagation();
    }
  }

  window.addEventListener('resize', resizeCanvas, true);
  window.addEventListener('blur', cancelGesture, true);
  window.addEventListener('pagehide', storeCurrentPage, true);
  window.addEventListener('beforeunload', storeCurrentPage, true);
  document.addEventListener('mousedown', startGesture, true);
  document.addEventListener('mousemove', onMouseMove, true);
  document.addEventListener('mouseup', finishGesture, true);
  document.addEventListener('contextmenu', blockContextMenu, true);
  document.addEventListener('keydown', (event) => {
    if (event.key === 'Escape') {
      cancelGesture(event);
    }
  }, true);
})();