TesterTV_ScrollButtons

Quick scroll buttons that keep the same on-screen size and the same distance from bottom/sides while zooming.

// ==UserScript==
// @name         TesterTV_ScrollButtons
// @namespace    https://greasyfork.org/ru/scripts/482232-testertv-scrollbuttons
// @version      2025.08.30.2
// @description  Quick scroll buttons that keep the same on-screen size and the same distance from bottom/sides while zooming.
// @license      GPL-3.0-or-later
// @author       TesterTV
// @match        *://*/*
// @grant        GM_openInTab
// @grant        GM.setValue
// @grant        GM.getValue
// ==/UserScript==

(async function () {
  'use strict';

  const isIframe = window !== window.top;

  // ====== Constants / Defaults
  const BTN_SIZE = '50px';
  const FONT_SIZE = '50px';
  // Gaps are in CSS px at the moment the script loads (we'll keep them visually constant across zoom)
  const GAP_SIDE = '0px';
  const GAP_BOTTOM = '6px';

  const DEF_SIDE_OPT = '0';    // 0=right,1=left,2=disable
  const DEF_BOTTOM_OPT = '0';  // 0=enable,1=disable
  const DEF_SIDE_SLIDER = '42';  // %
  const DEF_BOTTOM_SLIDER = '50'; // %

  const Z_TOPSIDE = '9996';
  const Z_BOTHSIDE = '9998';
  const Z_BOTTOM = '9999';
  const Z_PANEL = '10000';

  // ====== Styles
  const css = `
    .scroll-btn {
      height:${BTN_SIZE}; width:${BTN_SIZE}; font-size:${FONT_SIZE};
      position:fixed; background:transparent; border:none; outline:none;
      opacity:.05; cursor:pointer; user-select:none;
      transform-origin:center; will-change:transform; /* zoom-fix base */
    }
    .scroll-btn:hover { opacity:1 }
    #TopSideButton { z-index:${Z_TOPSIDE}; }
    #BottomSideButton { z-index:${Z_BOTHSIDE}; }
    #BottomButton { z-index:${Z_BOTTOM}; transform:translate(-50%, 0); }
    #OptionPanel {
      position:fixed; top:50%; left:50%; transform:translate(-50%, -50%);
      background:#303236; color:#fff; padding:10px; border:1px solid grey;
      z-index:${Z_PANEL}; font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;
    }
    #OptionPanel .title { font-weight:bold; text-decoration:underline; font-size:20px; margin-bottom:6px; display:block; }
    #OptionPanel .row { margin:6px 0; }
    #OptionPanel label { font-size:15px; margin-right:6px; }
    #OptionPanel select { font-size:15px; }
    #OptionPanel input[type=range] {
      width:100px; background:#74e3ff; border:none; height:5px; outline:none; appearance:none;
    }
    #DonateBtn {
      width:180px; height:25px; font-size:10px; color:#303236; background:#fff;
      border:1px solid grey; position:relative; left:50%; transform:translateX(-50%); margin-top:8px;
    }
  `;
  const style = document.createElement('style');
  style.textContent = css;
  document.documentElement.appendChild(style);

  // ====== Helpers
  const clamp = (v, min, max) => Math.max(min, Math.min(max, v));

  const makeBtn = (id, text, onClick) => {
    const b = document.createElement('button');
    b.id = id;
    b.className = 'scroll-btn';
    b.textContent = text;
    document.body.appendChild(b);
    b.addEventListener('click', onClick);
    if (isIframe) b.style.display = 'none';
    return b;
  };

  const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'auto' });
  const scrollToBottom = () => window.scrollTo({ top: Math.max(document.body.scrollHeight, document.documentElement.scrollHeight), behavior: 'auto' });

  // ====== Create Buttons
  const TopSideButton = makeBtn('TopSideButton', '⬆️', scrollToTop);
  const BottomSideButton = makeBtn('BottomSideButton', '⬇️', scrollToBottom);
  const BottomButton = makeBtn('BottomButton', '⬆️', scrollToTop);

  // ====== Load settings
  let [sideOpt, sideSlider, bottomOpt, bottomSlider] = await Promise.all([
    GM.getValue('SideButtonsOption', DEF_SIDE_OPT),
    GM.getValue('SideButtonsSliderOption', DEF_SIDE_SLIDER),
    GM.getValue('BottomButtonOption', DEF_BOTTOM_OPT),
    GM.getValue('BottomButtonSliderOption', DEF_BOTTOM_SLIDER),
  ]);

  // Coerce to strings
  sideOpt = String(sideOpt ?? DEF_SIDE_OPT);
  bottomOpt = String(bottomOpt ?? DEF_BOTTOM_OPT);
  sideSlider = String(sideSlider ?? DEF_SIDE_SLIDER);
  bottomSlider = String(bottomSlider ?? DEF_BOTTOM_SLIDER);

  // ====== Zoom + gap compensation
  // Keep button size and gaps visually the same across page zoom by counter-scaling and adjusting px gaps
  const BASE_DPR = window.devicePixelRatio || 1;
  let lastDPR = BASE_DPR;

  const GAP_SIDE_BASE_PX = parseFloat(GAP_SIDE) || 0;
  const GAP_BOTTOM_BASE_PX = parseFloat(GAP_BOTTOM) || 0;

  function gapCssPx(basePx) {
    const dpr = window.devicePixelRatio || 1;
    // Convert desired base CSS px to current CSS px so that device-physical gap stays constant
    const cssPx = (basePx * BASE_DPR) / dpr;
    return cssPx + 'px';
  }

  function updateTransformOrigins() {
    // Side buttons: anchor scaling to the side so horizontal distance stays the same
    if (sideOpt === '1') { // left
      TopSideButton.style.transformOrigin = 'left center';
      BottomSideButton.style.transformOrigin = 'left center';
    } else { // right (default)
      TopSideButton.style.transformOrigin = 'right center';
      BottomSideButton.style.transformOrigin = 'right center';
    }
    // Bottom button: anchor scaling to bottom so bottom distance stays the same
    BottomButton.style.transformOrigin = 'center bottom';
  }

  function applyZoomFix() {
    const currentDPR = window.devicePixelRatio || 1;
    lastDPR = currentDPR;
    const s = BASE_DPR / currentDPR; // counter-scale by zoom changes

    // Scale buttons (size stays constant on screen)
    TopSideButton.style.transform = `scale(${s})`;
    BottomSideButton.style.transform = `scale(${s})`;
    BottomButton.style.transform = `translate(-50%, 0) scale(${s})`;
  }

  // ====== Apply settings to UI
  function applySideButtons() {
    if (isIframe) {
      TopSideButton.style.display = 'none';
      BottomSideButton.style.display = 'none';
      return;
    }

    const disabled = sideOpt === '2';
    TopSideButton.style.display = disabled ? 'none' : 'block';
    BottomSideButton.style.display = disabled ? 'none' : 'block';
    if (disabled) return;

    // Vertical positions
    const v = clamp(Number(sideSlider), 4, 96);
    TopSideButton.style.top = v + '%';
    BottomSideButton.style.top = (100 - v) + '%';

    // Horizontal positions + gap compensation
    const sideGap = gapCssPx(GAP_SIDE_BASE_PX);
    if (sideOpt === '1') {
      TopSideButton.style.left = sideGap;
      BottomSideButton.style.left = sideGap;
      TopSideButton.style.right = '';
      BottomSideButton.style.right = '';
    } else {
      TopSideButton.style.right = sideGap;
      BottomSideButton.style.right = sideGap;
      TopSideButton.style.left = '';
      BottomSideButton.style.left = '';
    }

    updateTransformOrigins();
  }

  function applyBottomButton() {
    if (isIframe) {
      BottomButton.style.display = 'none';
      return;
    }
    const enabled = bottomOpt !== '1';
    BottomButton.style.display = enabled ? 'block' : 'none';

    // Bottom gap compensation
    BottomButton.style.bottom = gapCssPx(GAP_BOTTOM_BASE_PX);

    // Horizontal % with center correction
    const x = clamp(Number(bottomSlider), 0, 95);
    BottomButton.style.left = x + '%';

    updateTransformOrigins();
  }

  function updateButtonsForMediaOrFullscreen() {
    const isMediaUrl = /\.(?:jpg|jpeg|png|gif|svg|webp|apng|webm|mp4|mp3|wav|ogg)(?:[#?].*)?$/i
      .test(location.pathname + location.search + location.hash);
    const isFullScreen = !!(document.fullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement || document.msFullscreenElement);
    const vis = (isMediaUrl || isFullScreen) ? 'hidden' : 'visible';
    [TopSideButton, BottomSideButton, BottomButton].forEach(b => b.style.visibility = vis);
  }

  // Initial apply
  applySideButtons();
  applyBottomButton();
  applyZoomFix();
  updateButtonsForMediaOrFullscreen();

  // ====== Keep in sync on zoom/resize/fullscreen
  const onLayoutChange = () => {
    applySideButtons();      // recalculates side gap and origin
    applyBottomButton();     // recalculates bottom gap and origin
    applyZoomFix();          // reapplies counter-scale
    updateButtonsForMediaOrFullscreen();
  };

  window.addEventListener('resize', onLayoutChange, { passive: true });
  window.addEventListener('orientationchange', onLayoutChange, { passive: true });
  if (window.visualViewport) {
    window.visualViewport.addEventListener('resize', onLayoutChange, { passive: true });
    window.visualViewport.addEventListener('scroll', onLayoutChange, { passive: true }); // pinch-zoom panning
  }
  // Fallback polling for DPR changes
  setInterval(() => {
    const dpr = window.devicePixelRatio || 1;
    if (dpr !== lastDPR) onLayoutChange();
    updateButtonsForMediaOrFullscreen();
  }, 500);

  ['fullscreenchange', 'webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange']
    .forEach(evt => document.addEventListener(evt, updateButtonsForMediaOrFullscreen));

  // ====== Options Panel
  function showOptionsPanel(e) {
    e.preventDefault();

    // Remove any existing panel
    const existing = document.getElementById('OptionPanel');
    if (existing) existing.remove();

    const panel = document.createElement('div');
    panel.id = 'OptionPanel';
    panel.innerHTML = `
      <span class="title">Options</span>
      <div class="row">
        <label>Side buttons:</label>
        <select id="SidePos">
          <option value="0">Right</option>
          <option value="1">Left</option>
          <option value="2">Disable</option>
        </select>
      </div>
      <div class="row">
        <label>Bottom button:</label>
        <select id="BottomVis">
          <option value="0">Enable</option>
          <option value="1">Disable</option>
        </select>
      </div>
      <div class="row">
        <label>⬆️⬇️ position:</label>
        <input id="SideV" type="range" min="4" max="96" step="1">
      </div>
      <div class="row">
        <label>⬆️ position:</label>
        <input id="BottomH" type="range" min="0" max="95" step="1">
      </div>
      <button id="DonateBtn">💳Please support me!🤗</button>
    `;
    document.body.appendChild(panel);

    // Init fields
    const ddSide = panel.querySelector('#SidePos');
    const ddBottom = panel.querySelector('#BottomVis');
    const rngSide = panel.querySelector('#SideV');
    const rngBottom = panel.querySelector('#BottomH');

    ddSide.value = sideOpt;
    ddBottom.value = bottomOpt;
    rngSide.value = clamp(Number(sideSlider), 4, 96);
    rngBottom.value = clamp(Number(bottomSlider), 0, 95);

    // Enforce "at least one visible"
    function wouldHideAll(nextSideOpt = ddSide.value, nextBottomOpt = ddBottom.value) {
      return nextSideOpt === '2' && nextBottomOpt === '1';
    }

    ddSide.addEventListener('change', async () => {
      const next = ddSide.value;
      if (wouldHideAll(next, ddBottom.value)) {
        alert("All buttons can't be invisible! 🫠");
        ddSide.value = sideOpt;
        return;
      }
      sideOpt = next;
      await GM.setValue('SideButtonsOption', sideOpt);
      applySideButtons();
      applyZoomFix();
    });

    ddBottom.addEventListener('change', async () => {
      const next = ddBottom.value;
      if (wouldHideAll(ddSide.value, next)) {
        alert("All buttons can't be invisible! 🫠");
        ddBottom.value = bottomOpt;
        return;
      }
      bottomOpt = next;
      await GM.setValue('BottomButtonOption', bottomOpt);
      applyBottomButton();
      applyZoomFix();
    });

    rngSide.addEventListener('input', async () => {
      sideSlider = String(rngSide.value);
      await GM.setValue('SideButtonsSliderOption', sideSlider);
      applySideButtons();
    });

    rngBottom.addEventListener('input', async () => {
      bottomSlider = String(rngBottom.value);
      await GM.setValue('BottomButtonSliderOption', bottomSlider);
      applyBottomButton();
    });

    panel.querySelector('#DonateBtn').addEventListener('click', () => {
      GM_openInTab('https://...');
    });

    // Close panel when clicking outside
    const onDocClick = (evt) => {
      if (!panel.contains(evt.target)) {
        panel.remove();
        document.removeEventListener('click', onDocClick, true);
      }
    };
    setTimeout(() => document.addEventListener('click', onDocClick, true), 0);
  }

  // Open panel on right-click of any button
  [TopSideButton, BottomSideButton, BottomButton].forEach(b => {
    b.addEventListener('contextmenu', showOptionsPanel);
  });

})();