// ==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 // @downloadURL 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 = `
`; // 获取内部元素的引用 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); }); })();