类Eink整页翻按钮(瞬时、80%、侧边居中)

两侧中部 ▲/▼ 按钮,点击瞬时跳转 80% 视口高度,无滚动动画,适合类 Eink 翻页阅读。

// ==UserScript==
// @name         Eink-style Page Turn Buttons (Instant, 80%, Side-Centered)
// @name:zh-CN   类Eink整页翻按钮(瞬时、80%、侧边居中)
// @namespace    https://example.com/userscripts/pageturn
// @version      0.5.1
// @description        Two side-centered ▲/▼ buttons for instant 80%-viewport page jumps without animation.
// @description:zh-CN  两侧中部 ▲/▼ 按钮,点击瞬时跳转 80% 视口高度,无滚动动画,适合类 Eink 翻页阅读。
// @match        *://*/*
// @run-at       document-end
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';
  if (window.top !== window.self) return;

  const cfg = {
    btnSize: 48,
    btnOffset: 16,
    btnGap: 12,
    pageRatio: 0.8,
    theme: 'dark',     // 'dark' | 'light'
    bgAlpha: 0.18,
    btnOpacity: 0.38,
    leftId: 'ptb-side-left',
    rightId: 'ptb-side-right'
  };

  // force no smooth scrolling
  const style = document.createElement('style');
  style.textContent = 'html{scroll-behavior:auto!important}';
  document.documentElement.appendChild(style);

  const scroller = document.scrollingElement || document.documentElement;

  const jumpByRatio = (dir) => {
    const delta = Math.round(window.innerHeight * cfg.pageRatio);
    scroller.scrollTop = scroller.scrollTop + dir * delta;
  };

  const mkButton = (label, dir) => {
    const btn = document.createElement('button');
    btn.textContent = label;
    Object.assign(btn.style, {
      width: cfg.btnSize + 'px',
      height: cfg.btnSize + 'px',
      borderRadius: '50%',
      border: 'none',
      cursor: 'pointer',
      fontSize: '18px',
      lineHeight: cfg.btnSize + 'px',
      textAlign: 'center',
      boxShadow: '0 2px 8px rgba(0,0,0,0.25)',
      opacity: String(cfg.btnOpacity)
    });
    if (cfg.theme === 'dark') {
      btn.style.background = `rgb(0 0 0 / ${cfg.bgAlpha})`;
      btn.style.color = '#fff';
    } else {
      const alpha = Math.min(0.9, Math.max(0.15, 0.6 + cfg.bgAlpha));
      btn.style.background = `rgb(255 255 255 / ${alpha})`;
      btn.style.color = '#000';
      btn.style.border = '1px solid rgba(0,0,0,0.15)';
    }
    btn.addEventListener('click', (e) => {
      e.preventDefault();
      e.stopPropagation();
      jumpByRatio(dir);
    });
    return btn;
  };

  const mkSideGroup = (side, id) => {
    const wrap = document.createElement('div');
    wrap.id = id;
    Object.assign(wrap.style, {
      position: 'fixed',
      top: '50%',
      transform: 'translateY(-50%)',
      zIndex: '2147483647',
      display: 'flex',
      flexDirection: 'column',
      alignItems: 'center',
      gap: cfg.btnGap + 'px',
      pointerEvents: 'auto'
    });
    wrap.style[side] = cfg.btnOffset + 'px';

    wrap.appendChild(mkButton('▲', -1));
    wrap.appendChild(mkButton('▼', +1));
    (document.body || document.documentElement).appendChild(wrap);
  };

  const ensureUI = () => {
    if (!document.getElementById(cfg.leftId)) mkSideGroup('left', cfg.leftId);
    if (!document.getElementById(cfg.rightId)) mkSideGroup('right', cfg.rightId);
  };

  ensureUI();

  const observer = new MutationObserver(() => ensureUI());
  observer.observe(document.documentElement, { childList: true, subtree: true });
})();