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