Instant Scroll Beta

优化墨水屏设备。在浏览器中添加悬浮翻页按钮,实现瞬时滚动,带有视觉辅助定位线。使用 Shadow DOM 实现完全的样式隔离。支持通过鼠标或触控拖拽移动翻页按钮位置。

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

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

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

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

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

// ==UserScript==
// @name         Instant Scroll Beta
// @namespace    http://tampermonkey.net/
// @version      3.0
// @description  优化墨水屏设备。在浏览器中添加悬浮翻页按钮,实现瞬时滚动,带有视觉辅助定位线。使用 Shadow DOM 实现完全的样式隔离。支持通过鼠标或触控拖拽移动翻页按钮位置。
// @author       chen
// @match        https://*/*
// @exclude      https://vscode.dev/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // 避免在非顶层窗口(如 iframe 嵌套的广告或小部件)中加载此脚本
    if (window.top !== window.self) return;

    // ==========================================
    // 1. 创建 Shadow DOM 宿主并挂载干净的 UI 结构
    // ==========================================

    // 创建宿主元素,设置极高的 z-index 以保证整个组件位于页面顶层
    const shadowHost = document.createElement('div');
    shadowHost.id = 'instant-scroll-host';
    shadowHost.style.position = 'fixed';
    shadowHost.style.top = '0';
    shadowHost.style.left = '0';
    shadowHost.style.width = '0';
    shadowHost.style.height = '0';
    shadowHost.style.overflow = 'visible';
    shadowHost.style.zIndex = '9999999';
    document.body.appendChild(shadowHost);

    // 开启 Shadow DOM (mode: 'closed' 增加安全性)
    const shadowRoot = shadowHost.attachShadow({ mode: 'closed' });

    // 注入纯净的 CSS 和 HTML 结构
    shadowRoot.innerHTML = `
        <style>
            /* 翻页按钮容器样式 */
            .btn-container {
                position: fixed;
                /* 默认位置由 clamp 保证响应式,当用户拖拽后会切换为纯绝对定位 (top/left) */
                bottom: clamp(60px, 8vh, 100px);
                left: clamp(10px, 3vw, 40px);
                display: flex;
                flex-direction: column;
                gap: clamp(10px, 2vmin, 20px);
                z-index: 2; /* 在 Shadow DOM 内部位于辅助线之上 */
                touch-action: none; /* 关键:防止在按钮上拖拽时触发浏览器的默认平移或缩放行为 */
            }

            /* 单个按钮样式 */
            .scroll-btn {
                width: clamp(35px, 8vmin, 60px);
                height: clamp(35px, 8vmin, 60px);
                border-radius: 50%;
                background-color: transparent;
                color: #000;
                border: solid #333333;
                -webkit-text-stroke: 2px white;
                paint-order: stroke fill;
                font-size: clamp(14px, 3vmin, 22px);
                user-select: none;
                cursor: pointer;
                -webkit-tap-highlight-color: transparent;
                
                /* 居中对齐图标并去除浏览器默认样式干扰 */
                display: flex;
                align-items: center;
                justify-content: center;
                padding: 0;
                margin: 0;
                outline: none;
                box-sizing: border-box;
            }

            /* 阅读辅助线样式 */
            .indicator-line {
                position: fixed;
                border-top: 2px dashed rgba(255, 87, 34, 0.85);
                pointer-events: none;
                opacity: 0;
                z-index: 1; /* 在 Shadow DOM 内部略低于按钮 */
                /* 给辅助线的消失增加一点平滑过渡 */
                transition: opacity 0.3s ease-out;
            }
        </style>

        <div class="btn-container" id="container">
            <button class="scroll-btn" id="btn-up">▲</button>
            <button class="scroll-btn" id="btn-down">▼</button>
        </div>
        <div class="indicator-line" id="indicator-line"></div>
    `;

    // 获取内部元素的引用
    const container = shadowRoot.getElementById('container');
    const btnUp = shadowRoot.getElementById('btn-up');
    const btnDown = shadowRoot.getElementById('btn-down');
    const indicatorLine = shadowRoot.getElementById('indicator-line');

    let lineHideTimer = null; // 用于存储辅助线定时消失的计时器引用

    // ==========================================
    // 2. 动态检测并记录当前激活的滚动容器
    // ==========================================
    let activeScrollContainer = window;

    // 递归向上查找支持滚动的父级容器
    function getScrollContainer(node) {
        let current = node;
        while (current && current !== document && current !== document.body && current !== document.documentElement) {
            if (current.nodeType === 1) {
                const style = window.getComputedStyle(current);
                const overflowY = style.overflowY;
                const isScrollable = (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay');
                // 必须能滚动且内容高度大于容器高度
                if (isScrollable && current.scrollHeight > current.clientHeight) {
                    return current;
                }
            }
            current = current.parentNode;
        }
        return window;
    }

    // 更新当前处于活跃状态的滚动容器
    function updateActiveContainer(e) {
        // 使用 e.composedPath() 穿透 Shadow DOM,如果点击的是我们的翻页组件,则不更新容器
        if (e.composedPath().includes(shadowHost)) return;

        let target = e.target;
        if (target.nodeType !== 1) target = target.parentElement;
        activeScrollContainer = getScrollContainer(target);
    }

    // 监听文档中的点击或触摸行为,更新滚动目标
    document.addEventListener('mousedown', updateActiveContainer, true);
    document.addEventListener('touchstart', updateActiveContainer, true);

    // 获取最终执行滚动的目标容器
    function getTargetContainer() {
        if (activeScrollContainer && activeScrollContainer !== window && document.contains(activeScrollContainer)) {
            return activeScrollContainer;
        }
        // 兜底策略:取屏幕中心的元素,寻找其最近的滚动容器
        const centerX = window.innerWidth / 2;
        const centerY = window.innerHeight / 2;
        const el = document.elementFromPoint(centerX, centerY);
        if (el) {
            const centerContainer = getScrollContainer(el);
            if (centerContainer !== window) {
                activeScrollContainer = centerContainer;
                return centerContainer;
            }
        }
        return window;
    }

    // ==========================================
    // 3. 辅助线绘制逻辑
    // ==========================================
    function drawIndicatorLine(target, actualDistance) {
        if (actualDistance === 0) return;

        const rect = target === window
            ? { top: 0, bottom: window.innerHeight, left: 0, width: window.innerWidth }
            : target.getBoundingClientRect();

        let lineY;
        // 根据滚动方向计算辅助线应当出现的位置
        if (actualDistance > 0) {
            lineY = rect.bottom - actualDistance;
        } else {
            lineY = rect.top - actualDistance;
        }

        // 越界检查(如果目标位置在屏幕外则不显示)
        if (lineY <= rect.top || lineY >= rect.bottom) {
            indicatorLine.style.opacity = '0';
            return;
        }

        indicatorLine.style.top = `${lineY}px`;
        indicatorLine.style.left = `${rect.left}px`;
        indicatorLine.style.width = `${rect.width}px`;
        indicatorLine.style.opacity = '1';

        // 设置定时消失
        if (lineHideTimer) clearTimeout(lineHideTimer);
        lineHideTimer = setTimeout(() => {
            indicatorLine.style.opacity = '0';
        }, 2500);
    }

    // ==========================================
    // 4. 执行滚动的统一下发函数
    // ==========================================
    function doScroll(direction) {
        const target = getTargetContainer();
        const viewHeight = (target === window) ? window.innerHeight : target.clientHeight;
        // 每次滚动屏幕可见高度的 80%
        const distance = direction * viewHeight * 0.80;
        const getScrollTop = () => target === window ? (window.scrollY || document.documentElement.scrollTop) : target.scrollTop;

        const beforeScroll = getScrollTop();

        // 执行瞬时滚动
        if (target === window) {
            window.scrollBy({ top: distance, behavior: 'instant' });
        } else {
            target.scrollBy({ top: distance, behavior: 'instant' });
        }

        const afterScroll = getScrollTop();
        const actualDistance = afterScroll - beforeScroll;

        // 根据实际发生的滚动距离绘制辅助线
        drawIndicatorLine(target, actualDistance);
    }

    // ==========================================
    // 5. 新增:悬浮按钮的拖拽移动机制与持久化
    // ==========================================
    let isDragging = false; // 标记是否处于拖拽状态
    let hasDragged = false; // 标记是否发生了实质性位移(用于区分纯点击误触与真实拖拽)
    let startX = 0, startY = 0; // 记录按下时的鼠标/手指坐标
    let initialLeft = 0, initialTop = 0; // 记录按下时按钮容器的左上角坐标

    // 提取本地存储逻辑
    function loadSavedPosition() {
        try {
            const savedPos = localStorage.getItem('instant-scroll-btn-pos');
            if (savedPos) {
                const pos = JSON.parse(savedPos);
                container.style.bottom = 'auto'; // 覆盖掉默认的 bottom 定位
                container.style.left = pos.left;
                container.style.top = pos.top;
            }
        } catch (e) {
            console.warn('读取本地位置失败:', e);
        }
    }

    function savePosition() {
        try {
            localStorage.setItem('instant-scroll-btn-pos', JSON.stringify({
                left: container.style.left,
                top: container.style.top
            }));
        } catch (e) {
            console.warn('保存本地位置失败:', e);
        }
    }

    // 初始化时加载用户之前保存的位置
    loadSavedPosition();

    // 拖拽开始:记录初始状态
    function dragStart(e) {
        // 多点触控时只响应第一个触摸点
        if (e.type === 'touchstart' && e.touches.length > 1) return;

        // 获取起始坐标
        const clientX = e.type.includes('mouse') ? e.clientX : e.touches[0].clientX;
        const clientY = e.type.includes('mouse') ? e.clientY : e.touches[0].clientY;

        startX = clientX;
        startY = clientY;

        // 获取容器此时此刻的实际位置
        const rect = container.getBoundingClientRect();
        initialLeft = rect.left;
        initialTop = rect.top;

        isDragging = true;
        hasDragged = false; // 重置实质拖拽标记

        // 将定位模式统一转化为直接修改 top 和 left,移除 bottom,防止样式冲突
        container.style.bottom = 'auto';
        container.style.left = initialLeft + 'px';
        container.style.top = initialTop + 'px';
    }

    // 拖拽过程:跟随鼠标/手指移动
    function dragMove(e) {
        if (!isDragging) return;

        const clientX = e.type.includes('mouse') ? e.clientX : e.touches[0].clientX;
        const clientY = e.type.includes('mouse') ? e.clientY : e.touches[0].clientY;

        const dx = clientX - startX;
        const dy = clientY - startY;

        // 只有当移动距离超过 5 像素,才判定为实质性的拖拽动作,防止手指微小抖动被误判
        if (!hasDragged && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) {
            hasDragged = true;
        }

        // 处于实质性拖拽中
        if (hasDragged) {
            // 阻止浏览器默认事件(如页面滚动、手势返回等)
            if (e.cancelable) e.preventDefault();

            let newLeft = initialLeft + dx;
            let newTop = initialTop + dy;

            // 边界约束:确保按钮不会被拖出屏幕可视区域外
            const rect = container.getBoundingClientRect();
            newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - rect.width));
            newTop = Math.max(0, Math.min(newTop, window.innerHeight - rect.height));

            // 实时更新容器位置
            container.style.left = newLeft + 'px';
            container.style.top = newTop + 'px';
        }
    }

    // 拖拽结束:保存位置并终止拖拽状态
    function dragEnd() {
        if (!isDragging) return;
        isDragging = false;

        // 仅在真实发生拖拽后才进行本地存储,减少不必要的磁盘 IO
        if (hasDragged) {
            savePosition();
        }
    }

    // 为容器绑定拖拽开始事件(支持鼠标和触摸屏)
    container.addEventListener('mousedown', dragStart, { passive: true });
    container.addEventListener('touchstart', dragStart, { passive: true });

    // 为全局 document 绑定拖拽移动和结束事件
    // (防止拖拽过快时,鼠标/手指移出按钮区域而导致事件丢失卡死)
    document.addEventListener('mousemove', dragMove, { passive: false });
    document.addEventListener('mouseup', dragEnd, { passive: true });
    document.addEventListener('touchmove', dragMove, { passive: false });
    document.addEventListener('touchend', dragEnd, { passive: true });
    document.addEventListener('touchcancel', dragEnd, { passive: true });

    // 屏幕大小改变时的兜底处理(如移动设备横竖屏切换,防止按钮飞出屏幕外导致无法找回)
    window.addEventListener('resize', () => {
        const rect = container.getBoundingClientRect();
        if (rect.right > window.innerWidth || rect.bottom > window.innerHeight) {
            let newLeft = Math.min(rect.left, window.innerWidth - rect.width);
            let newTop = Math.min(rect.top, window.innerHeight - rect.height);
            // 保证左上角边界不越界
            newLeft = Math.max(0, newLeft);
            newTop = Math.max(0, newTop);

            container.style.left = newLeft + 'px';
            container.style.top = newTop + 'px';

            savePosition(); // 重新保存修正后的位置
        }
    });

    // ==========================================
    // 6. 翻页按钮的点击事件绑定
    // ==========================================

    // 绑定向上翻页按钮事件
    btnUp.addEventListener('click', (e) => {
        e.preventDefault();
        e.stopPropagation();
        // 关键逻辑:如果是结束拖拽引发的冒泡点击事件,则忽略,防止拖动松手时意外触发翻页
        if (hasDragged) return;
        doScroll(-1);
    });

    // 绑定向下翻页按钮事件
    btnDown.addEventListener('click', (e) => {
        e.preventDefault();
        e.stopPropagation();
        // 同理,拦截由拖拽释放引发的误操作
        if (hasDragged) return;
        doScroll(1);
    });

})();