Instant Scroll Beta

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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);
    });

})();