Lightweight floating scroll helper with drag, shortcuts, theme colors, settings, and lazy-loaded dialog.
// ==UserScript==
// @name Simple Scrolling
// @name:zh-CN 简单滑块
// @namespace https://greasyfork.org/users/82488
// @namespace https://github.com/boyliuxiaopeng/Simple-scrolling/
// @version 1.0.5
// @description Lightweight floating scroll helper with drag, shortcuts, theme colors, settings, and lazy-loaded dialog.
// @description:zh-CN 轻量级悬浮滚动助手,为任意网页添加浮动的滑块,实现快速的上下滑动,支持拖拽、快捷键、主题配色、设置面板和懒加载对话框。
// @author 暖色浮余生
// @match *://*/*
// @run-at document-idle
// @noframes
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @license MIT
// ==/UserScript==
(function () {
'use strict';
/*
* 1. 常量与默认值 / CONSTANTS & DEFAULTS
*/
/** GM_* 和 localStorage 共用的存储键名。
* Key used for both GM_* and localStorage. */
const STORAGE_KEY = 'ScrollHelperConfig';
/** 悬浮面板的 DOM id,每个页面必须唯一。
* DOM id of the floating panel. Must be unique per page. */
const PANEL_ID = 'scroll-helper-panel';
/** 指针移动超过此距离 (px) 才认定为拖拽。
* Minimum pointer movement (px) before a drag is recognised. */
const DRAG_THRESHOLD = 5;
/** 图标风格循环顺序(右键/长按切换)。
* Ordered list — decides the right-click / long-press cycle sequence. */
const ICON_STYLE_ORDER = ['outline', 'chevron', 'solid', 'rounded'];
/** 设置对话框中两个快捷键的可选项。
* Options shown in the settings dialog for the two shortcut keys. */
const TOP_KEY_OPTIONS = ['ArrowUp', 'PageUp', 'Home', 'w', 'W'];
const BOTTOM_KEY_OPTIONS = ['ArrowDown', 'PageDown', 'End', 's', 'S'];
/** 实时 MediaQueryList 对象——每次访问时重新求值。
* Live MediaQueryList objects — evaluate on every access. */
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)');
/**
* 经过校验的默认回退值。所有写入 config 的值都会按下述范围校验。
* Sanity-checked fallback values.
* Every value written to config will be validated against the ranges below.
*/
const defaultConfig = {
scrollKeys: { top: 'ArrowUp', bottom: 'ArrowDown' },
autoShow: true,
iconSize: 18, // px — 控制按钮尺寸 (iconSize+18) / controls button dimensions
iconStyle: 'outline',
iconStroke: 2.25, // px — 适用于 Outline、Chevron 和 Rounded / applies to those three styles
iconScale: 58, // % — 图标相对按钮的大小比例 / icon-to-button size ratio
iconGap: 8, // px — 按钮之间的垂直间距 / vertical gap between buttons
borderRadius: 12, // px — 按钮圆角 / button corner rounding
panelOpacity: 60, // % — 面板整体透明度 / overall panel opacity
iconPos: { right: 30, bottom: 100 }, // px — 以右下角为锚点 / anchored to bottom-right
darkMode: false,
keyEnabled: true,
lightColor: '#007BFF',
darkColor: '#444444',
settingsColor: '#FFB800'
};
/*
* 2. 存储(GM_* 优先,localStorage 兜底) / STORAGE
*/
/** 读取原始配置字符串。优先使用 GM 存储以跨站点共享设置;
* 无 GM 时回退到按域名的 localStorage。
* Read raw config string. Prefers GM storage so settings are shared
* across all sites; falls back to per-origin localStorage. */
function readStoredConfig() {
if (typeof GM_getValue === 'function') {
return GM_getValue(STORAGE_KEY, '{}');
}
return localStorage.getItem(STORAGE_KEY) || '{}';
}
/** 写入原始配置字符串。 / Write raw config string. */
function writeStoredConfig(raw) {
if (typeof GM_setValue === 'function') {
GM_setValue(STORAGE_KEY, raw);
return;
}
localStorage.setItem(STORAGE_KEY, raw);
}
/** 解析存储的 JSON;任何异常均返回 {},由默认值接管。
* Parse stored JSON; return {} on any failure so defaults kick in. */
function safeParseConfig() {
try {
return JSON.parse(readStoredConfig());
} catch (error) {
console.warn('[Simple Scrolling] Failed to read saved config. Defaults restored.', error);
return {};
}
}
/*
* 3. 校验与合并 / VALIDATION & MERGE
*/
/** 将数值限制在 [min, max] 范围内;输入不是有效数字时返回 fallback。
* Clamp a numeric value; return fallback when the input is not a finite number. */
function clamp(value, min, max, fallback) {
const number = Number(value);
if (!Number.isFinite(number)) {
return fallback;
}
return Math.min(Math.max(number, min), max);
}
/** 只接受 CSS 合法的 #rgb / #rgba / #rrggbb / #rrggbbaa 十六进制颜色字符串。
* Accept only valid CSS #rgb / #rgba / #rrggbb / #rrggbbaa hex colours. */
function sanitizeColor(value, fallback) {
return typeof value === 'string' &&
/^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(value)
? value
: fallback;
}
/**
* 将存储的覆盖值深合并到 defaultConfig 中,并对每个字段做范围约束。
* 这是所有配置流转的唯一入口——无论来自存储还是设置对话框。
*
* Deep-merge stored overrides into defaultConfig and constrain every
* field. This is the single funnel through which all config flows —
* whether from storage or the settings dialog.
*/
function mergeConfig(rawConfig) {
// JSON.parse('null') 等值虽能解析,但并不是可合并的配置对象。
// A successfully parsed JSON value is not necessarily a mergeable config object.
if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) {
rawConfig = {};
}
const merged = {
...defaultConfig,
...rawConfig,
// 深合并嵌套对象,避免部分覆盖时丢失子键。
// Deep-merge nested objects so partial overrides don't lose keys.
scrollKeys: {
...defaultConfig.scrollKeys,
...(rawConfig.scrollKeys || {})
},
iconPos: {
...defaultConfig.iconPos,
...(rawConfig.iconPos || {})
}
};
// 数值范围——硬限制,与设置对话框的输入范围保持一致。
// Numeric ranges — hard limits that match the settings-dialog inputs.
merged.iconSize = clamp(merged.iconSize, 14, 48, defaultConfig.iconSize);
merged.iconStroke = clamp(merged.iconStroke, 1, 4, defaultConfig.iconStroke);
merged.iconScale = clamp(merged.iconScale, 40, 75, defaultConfig.iconScale);
merged.iconGap = clamp(merged.iconGap, 4, 20, defaultConfig.iconGap);
merged.borderRadius = clamp(merged.borderRadius, 0, 24, defaultConfig.borderRadius);
merged.panelOpacity = clamp(merged.panelOpacity, 20, 100, defaultConfig.panelOpacity);
merged.iconPos.right = clamp(merged.iconPos.right, 0, 9999, defaultConfig.iconPos.right);
merged.iconPos.bottom= clamp(merged.iconPos.bottom, 0, 9999, defaultConfig.iconPos.bottom);
// 枚举值与布尔值归一化。
// Enums & booleans.
merged.iconStyle = ICON_STYLE_ORDER.includes(merged.iconStyle)
? merged.iconStyle
: 'outline';
merged.autoShow = Boolean(merged.autoShow);
merged.darkMode = Boolean(merged.darkMode);
merged.keyEnabled = Boolean(merged.keyEnabled);
// 颜色安全性校验。
// Color safety.
merged.lightColor = sanitizeColor(merged.lightColor, defaultConfig.lightColor);
merged.darkColor = sanitizeColor(merged.darkColor, defaultConfig.darkColor);
merged.settingsColor = sanitizeColor(merged.settingsColor, defaultConfig.settingsColor);
// 快捷键不能为空。
// Shortcut keys must not be empty.
merged.scrollKeys.top = merged.scrollKeys.top || defaultConfig.scrollKeys.top;
merged.scrollKeys.bottom = merged.scrollKeys.bottom || defaultConfig.scrollKeys.bottom;
return merged;
}
/** 整个脚本唯一使用的可变配置对象。
* The single mutable configuration object used by the entire script. */
const config = mergeConfig(safeParseConfig());
/*
* 4. 可变运行时状态 / MUTABLE RUNTIME STATE
*/
const state = {
panel: null, // HTMLElement — 悬浮 div / the floating div
isDragged: false, // 指针移动超过 DRAG_THRESHOLD 后置为 true / set after pointer moved past threshold
dragCleanup: null, // function — 拆除指针事件监听 / tears down pointer listeners
scrollCleanup: null, // function — 拆除滚动/窗口大小监听 / tears down scroll/resize listeners
keydownHandler: null, // function — 当前键盘监听器 / the current keyboard listener
mediaHandler: null, // function — 监听系统配色方案变化 / listens for prefers-color-scheme changes
suppressNextClick: false // long-press / drag 后阻止合成 click / suppress synthetic click after long-press or drag
};
/*
* 5. 滚动辅助函数 / SCROLLING HELPERS
*/
/** 实际控制视口滚动的元素。
* The element that actually scrolls the viewport. */
function getScrollRoot() {
return document.scrollingElement || document.documentElement || document.body;
}
/** 最大垂直滚动距离(总可滚动高度 − 视口高度)。
* Maximum vertical scroll offset (total scrollable height − viewport). */
function getMaxScrollTop() {
const root = getScrollRoot();
return Math.max(0, root.scrollHeight - window.innerHeight);
}
/** 尊重操作系统的 "减少动态效果" 偏好设置。
* Respect the OS "reduce motion" preference. */
function getScrollBehavior() {
return prefersReducedMotion.matches ? 'auto' : 'smooth';
}
/** 持久化当前配置。已包裹 try/catch,调用方无需自行处理异常。
* Persist current config. Wrapped so callers don't each need try/catch. */
function saveConfig() {
try {
writeStoredConfig(JSON.stringify(config));
} catch (error) {
console.warn('[Simple Scrolling] Failed to save config.', error);
}
}
/** 当按钮应使用深色配色方案时返回 true。
* True when buttons should use the dark color scheme. */
function shouldUseDarkMode() {
return config.darkMode || prefersDarkScheme.matches;
}
/** 估算面板尺寸,确保拖拽时不会被拖出屏幕。
* 当面板已挂载时使用实测值,否则使用基于配置的计算值。
* Estimate panel dimensions so dragging never pushes it off-screen.
* Uses live measurements when the panel exists, otherwise calculated defaults. */
function getPanelSize() {
if (state.panel) {
return {
width: state.panel.offsetWidth || (config.iconSize + 18),
height: state.panel.offsetHeight ||
((config.iconSize + 18) * 4 + config.iconGap * 3)
};
}
const buttonSize = config.iconSize + 18;
return { width: buttonSize, height: buttonSize * 4 + config.iconGap * 3 };
}
/** 将面板位置限制在视口范围内,确保面板完全可见。
* Clamp the position so the entire panel stays visible. */
function limitToViewport(position) {
const { width, height } = getPanelSize();
position.right = clamp(position.right, 0,
Math.max(0, window.innerWidth - width), defaultConfig.iconPos.right);
position.bottom = clamp(position.bottom, 0,
Math.max(0, window.innerHeight - height), defaultConfig.iconPos.bottom);
}
/*
* 6. 滚动动作 / SCROLL ACTIONS
*/
function scrollToTop() {
window.scrollTo({ top: 0, behavior: getScrollBehavior() });
}
function scrollToMiddle() {
window.scrollTo({ top: getMaxScrollTop() / 2, behavior: getScrollBehavior() });
}
function scrollToBottom() {
window.scrollTo({ top: getMaxScrollTop(), behavior: getScrollBehavior() });
}
/*
* 7. SVG 图标系统 / SVG ICON SYSTEM
*/
/**
* 四套内联 SVG 图标,通过 `currentColor` 染色以自动匹配按钮的白色文字。
*
* Outline / Chevron — Feather 描边风格 (MIT)
* Solid / Rounded — 实心三角 + Material 齿轮 (Apache-2.0);
* Rounded 外加圆角连线描边以柔化边角。
*
* Four inline-SVG glyph sets, tinted via `currentColor` so they
* automatically match the button's white text colour.
*
* Outline / Chevron — Feather-style strokes (MIT)
* Solid / Rounded — filled triangles + Material settings gear (Apache-2.0);
* Rounded adds a round-joined outer stroke for softer edges.
*/
/** Outline 和 Chevron 共用的齿轮图标 (Feather, MIT)。
* Shared gear icon used by Outline and Chevron (Feather, MIT). */
const FEATHER_GEAR = '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>';
/** Solid 和 Rounded 共用的实心路径集 (Material gear, Apache-2.0)。
* Shared filled-path set used by Solid and Rounded (Material gear, Apache-2.0). */
const FILLED_PATHS = {
top: '<path d="M12 6 L19 18 L5 18 Z"/>',
middle: '<rect x="4" y="10.5" width="16" height="3" rx="1.5"/>',
bottom: '<path d="M12 18 L5 6 L19 6 Z"/>',
settings: '<path d="M19.43 12.98c.04-.32.07-.64.07-.98 0-.34-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98 0 .33.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"/>'
};
/**
* 每种风格声明一个 `render` 模式,buildIcon() 据此决定输出哪些
* fill/stroke 属性。四种风格共用 24×24 的 viewBox;
* 颜色由父按钮上的 currentColor 继承而来。
*
* Each style declares a `render` mode so buildIcon() knows which
* fill/stroke attributes to emit. All four styles share the same 24×24
* viewBox; colour comes from currentColor on the parent button.
*/
const ICON_SETS = {
outline: {
render: 'stroke',
paths: {
top: '<line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/>',
middle: '<line x1="5" y1="12" x2="19" y2="12"/>',
bottom: '<line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/>',
settings: FEATHER_GEAR
}
},
chevron: {
render: 'stroke',
paths: {
top: '<polyline points="5 13 12 6 19 13"/><polyline points="5 19 12 12 19 19"/>',
middle: '<line x1="5" y1="12" x2="19" y2="12"/>',
bottom: '<polyline points="5 5 12 12 19 5"/><polyline points="5 11 12 18 19 11"/>',
settings: FEATHER_GEAR
}
},
solid: {
render: 'fill',
paths: FILLED_PATHS
},
rounded: {
render: 'fillRound', // fill + 外层描边以柔化边角 / fill + outer stroke for softened corners
paths: FILLED_PATHS
}
};
/**
* 为单个按钮构建内联 <svg> 字符串。
* Build an inline <svg> string for one button.
* @param {'top'|'middle'|'bottom'|'settings'} name 图标名称 / icon key
* @param {number} size — px,正方形尺寸 / square dimensions
*/
function buildIcon(name, size) {
const set = ICON_SETS[config.iconStyle] || ICON_SETS.outline;
let paint;
if (set.render === 'fill') {
paint = 'fill="currentColor" stroke="none"';
} else if (set.render === 'fillRound') {
paint = `fill="currentColor" stroke="currentColor" stroke-width="${config.iconStroke}" stroke-linecap="round" stroke-linejoin="round"`;
} else {
paint = `fill="none" stroke="currentColor" stroke-width="${config.iconStroke}" stroke-linecap="round" stroke-linejoin="round"`;
}
return `<svg viewBox="0 0 24 24" ${paint} aria-hidden="true" focusable="false" style="width:${size}px;height:${size}px;display:block;pointer-events:none">${set.paths[name] || ''}</svg>`;
}
/** 切换到下一个图标风格,持久化并重新渲染。
* Advance to the next icon style, persist, and re-render. */
function cycleIconStyle() {
const idx = ICON_STYLE_ORDER.indexOf(config.iconStyle);
config.iconStyle = ICON_STYLE_ORDER[(idx + 1) % ICON_STYLE_ORDER.length];
saveConfig();
render();
}
/*
* 8. 按钮工厂 / BUTTON FACTORY
*/
/**
* 创建一个带有内联 SVG、悬停/按下动画以及拖拽感知点击处理的 DOM 按钮。
*
* Create one DOM button with inline SVG, hover/press animation,
* and drag-aware click handling.
*
* @param {string} id — DOM id 属性 / DOM id attribute
* @param {string} iconName — ICON_SETS[style].paths 中的键 / key into icon paths
* @param {string} title — 提示文字与 aria-label / tooltip + aria-label
* @param {Function} onClick — 按钮被点击(非拖拽)时的回调 / action when clicked (not dragged)
*/
function createButton(id, iconName, title, onClick) {
var isSettingsButton = id === 'scroll-settings';
var background = isSettingsButton
? config.settingsColor
: (shouldUseDarkMode() ? config.darkColor : config.lightColor);
var button = document.createElement('button');
button.id = id;
button.type = 'button';
button.innerHTML = buildIcon(iconName, Math.round((config.iconSize + 18) * (config.iconScale / 100)));
button.title = title;
button.setAttribute('aria-label', title);
Object.assign(button.style, {
all: 'unset',
width: `${config.iconSize + 18}px`,
height: `${config.iconSize + 18}px`,
borderRadius: `${config.borderRadius}px`,
background: background,
color: '#FFFFFF',
cursor: 'pointer',
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.2)',
display: 'grid',
placeItems: 'center',
userSelect: 'none',
transition: 'transform 0.18s ease, box-shadow 0.18s ease'
});
// ---- 视觉反馈(悬停 / 按下) / visual feedback (hover / press) ----
button.addEventListener('mouseenter', function () {
button.style.transform = 'scale(1.08)';
button.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.22)';
});
button.addEventListener('mouseleave', function () {
button.style.transform = 'scale(1)';
button.style.boxShadow = '0 1px 4px rgba(0, 0, 0, 0.2)';
});
button.addEventListener('mousedown', function () {
button.style.transform = 'scale(0.94)';
});
button.addEventListener('mouseup', function () {
button.style.transform = 'scale(1.08)';
});
// 拖拽使用指针捕获时,某些浏览器不会再自动生成 click。
// 因此非拖拽的 pointerup 直接执行动作;随后的 click 仅负责兼容键盘操作。
button.addEventListener('pointerup', function (event) {
if (event.button !== 0 && event.pointerType === 'mouse') return;
if (state.isDragged || state.suppressNextClick) return;
state.suppressNextClick = true;
onClick();
// 若浏览器没有派发 click,不让标记影响下一次正常操作。
setTimeout(function () { state.suppressNextClick = false; }, 0);
});
// ---- 点击:如果指针刚完成拖拽则忽略本次点击 / ignore click after drag ----
button.addEventListener('click', function (event) {
if (state.isDragged || state.suppressNextClick) {
state.isDragged = false;
state.suppressNextClick = false;
event.preventDefault();
event.stopPropagation();
return;
}
onClick();
});
return button;
}
/*
* 9. 面板生命周期 / PANEL LIFECYCLE
*/
/** 拆除所有事件监听并从 DOM 中移除面板。
* Tear down all listeners and remove the panel from the DOM. */
function destroyPanel() {
if (typeof state.dragCleanup === 'function') {
state.dragCleanup();
state.dragCleanup = null;
}
if (typeof state.scrollCleanup === 'function') {
state.scrollCleanup();
state.scrollCleanup = null;
}
if (state.keydownHandler) {
document.removeEventListener('keydown', state.keydownHandler);
state.keydownHandler = null;
}
if (state.mediaHandler) {
prefersDarkScheme.removeEventListener('change', state.mediaHandler);
state.mediaHandler = null;
}
if (state.panel) {
state.panel.remove();
state.panel = null;
}
}
/**
* 页面在顶部时隐藏面板(autoShow 模式)。
* 关闭 autoShow 后面板始终可见。
*
* Hide the panel when the page is near the top (autoShow mode).
* When autoShow is off the panel is always visible.
*/
function updatePanelVisibility() {
if (!state.panel) return;
if (!config.autoShow) {
state.panel.style.display = 'flex';
return;
}
state.panel.style.display = window.scrollY > 200 ? 'flex' : 'none';
}
/** 绑定滚动与窗口大小变化的监听,并立即同步可见性状态。
* Bind scroll + resize listeners and immediately sync visibility. */
function bindScrollVisibility() {
updatePanelVisibility();
var handleScroll = function () { updatePanelVisibility(); };
var handleResize = function () {
limitToViewport(config.iconPos);
if (state.panel) {
state.panel.style.right = `${config.iconPos.right}px`;
state.panel.style.bottom = `${config.iconPos.bottom}px`;
}
updatePanelVisibility();
};
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleResize, { passive: true });
state.scrollCleanup = function () {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleResize);
};
}
/*
* 10. 指针事件拖拽系统 / POINTER-EVENT DRAG SYSTEM
*/
/**
* 使用统一的指针事件 (Pointer Events),鼠标、触屏、触控笔均可工作。
* `setPointerCapture` 在指针离开面板后仍能跟踪;面板上的
* `touch-action: none` 防止浏览器将手势误解为页面滚动。
*
* Uses unified Pointer Events so mouse, touch, and pen all work.
* `setPointerCapture` keeps tracking the pointer even when it leaves
* the panel rect, and `touch-action: none` on the panel prevents the
* browser from interpreting the gesture as page scroll.
*/
function bindDrag() {
var pointerId = null; // 跟踪活动指针跨越 move/up / tracks the active pointer across move/up
var captureTarget = null;
var startX = 0;
var startY = 0;
var onPointerMove = function (event) {
if (pointerId === null || !state.panel || event.pointerId !== pointerId) {
return;
}
var dx = event.clientX - startX;
var dy = event.clientY - startY;
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) {
state.isDragged = true;
}
// 在屏幕空间内移动面板,然后限制在视口内。
// Move the panel in screen space, then clamp to viewport.
config.iconPos.right -= dx;
config.iconPos.bottom -= dy;
limitToViewport(config.iconPos);
state.panel.style.right = `${config.iconPos.right}px`;
state.panel.style.bottom = `${config.iconPos.bottom}px`;
startX = event.clientX;
startY = event.clientY;
};
var onPointerUp = function (event) {
if (pointerId === null || event.pointerId !== pointerId) return;
pointerId = null;
if (captureTarget && captureTarget.hasPointerCapture(event.pointerId)) {
captureTarget.releasePointerCapture(event.pointerId);
}
captureTarget = null;
// 仅在确实发生了拖拽时才持久化位置。
// Only persist config when the user actually dragged.
if (state.isDragged) {
saveConfig();
}
state.isDragged = false;
};
var onPointerDown = function (event) {
// 鼠标只接受主按键;触屏/触控笔始终放行。
// Accept only primary button for mouse; touch/pen always pass through.
if (event.button !== 0 && event.pointerType === 'mouse') return;
// 按钮只负责滚动或打开设置。拖拽从面板边缘或按钮间空白区域开始,
// 避免 Chrome 在按钮上使用指针捕获时改变 click 的派发行为。
// Buttons are reserved for actions. Drag from the panel padding or gaps,
// avoiding Chrome's pointer-capture differences for buttons.
if (event.target !== state.panel) return;
state.isDragged = false;
pointerId = event.pointerId;
startX = event.clientX;
startY = event.clientY;
captureTarget = state.panel;
captureTarget.setPointerCapture(event.pointerId);
// 不在 pointerdown 阶段取消默认事件:部分浏览器会因此不再为
// button 派发 click,导致滚动和设置按钮失效。面板的 touch-action:
// none 已足以阻止触屏拖拽被解释成页面滚动。
// Do not cancel pointerdown: some browsers then suppress the button's
// click event. touch-action: none already handles touch dragging.
};
state.panel.addEventListener('pointerdown', onPointerDown);
state.panel.addEventListener('pointermove', onPointerMove);
state.panel.addEventListener('pointerup', onPointerUp);
state.panel.addEventListener('pointercancel', onPointerUp);
state.dragCleanup = function () {
if (state.panel) {
state.panel.removeEventListener('pointerdown', onPointerDown);
state.panel.removeEventListener('pointermove', onPointerMove);
state.panel.removeEventListener('pointerup', onPointerUp);
state.panel.removeEventListener('pointercancel', onPointerUp);
}
};
}
/*
* 11. 设置对话框 / SETTINGS DIALOG
*/
/** 构建设置表单 HTML。所有 ID 均有前缀以避免与宿主页面冲突。
* Build the settings form HTML. All IDs are prefixed to avoid clashes. */
function buildSettingsHtml(colorPresets) {
return [
'<div style="text-align:left;max-height:500px;overflow-y:auto">',
// ---- 颜色预设 / colour presets ----
'<div style="margin-bottom:15px;padding:10px;background:#f9f9f9;border-radius:5px">',
'<p style="margin:0 0 10px 0;font-weight:bold;font-size:14px">Color presets / 颜色预设</p>',
'<div style="display:flex;gap:5px;flex-wrap:wrap">',
Object.entries(colorPresets).map(function (entry) {
var name = entry[0], colors = entry[1];
return [
'<button type="button"',
`data-light="${colors.light}"`,
`data-dark="${colors.dark}"`,
`data-settings="${colors.settings}"`,
'class="scroll-helper-preset"',
'style="padding:8px 12px;background:#f0f0f0;border:1px solid #ddd;border-radius:3px;cursor:pointer;font-size:12px"',
`>${name}</button>`
].join(' ');
}).join(''),
'</div>',
'</div>',
// ---- 外观 / appearance ----
'<label>Icon size / 图标大小: ',
'<input type="number" id="set-iconSize" min="14" max="48" value="', config.iconSize, '" style="width:60px">',
'</label><br><br>',
'<label>Icon style / 图标风格: ',
'<select id="set-iconStyle" style="width:110px">',
ICON_STYLE_ORDER.map(function (style) {
var sel = config.iconStyle === style ? ' selected' : '';
return `<option value="${style}"${sel}>${style.charAt(0).toUpperCase() + style.slice(1)}</option>`;
}).join(''),
'</select>',
'</label><br><br>',
'<label>Icon scale / 图标缩放 (%): ',
'<input type="number" id="set-iconScale" min="40" max="75" value="', config.iconScale, '" style="width:60px">',
'</label><br><br>',
'<label>Stroke width / 描边宽度 (not Solid): ',
'<input type="number" id="set-iconStroke" min="1" max="4" step="0.25" value="', config.iconStroke, '" style="width:60px">',
'</label><br><br>',
'<label>Corner radius / 圆角: ',
'<input type="number" id="set-radius" min="0" max="24" value="', config.borderRadius, '" style="width:60px">',
'</label><br><br>',
'<label>Opacity / 透明度 (%): ',
'<input type="number" id="set-opacity" min="20" max="100" value="', config.panelOpacity, '" style="width:60px">',
'</label><br><br>',
// ---- 颜色 / colours ----
'<label>Light color / 浅色: ',
'<input type="color" id="set-lightColor" value="', config.lightColor, '" style="width:60px;height:30px;cursor:pointer">',
'</label><br><br>',
'<label>Dark color / 深色: ',
'<input type="color" id="set-darkColor" value="', config.darkColor, '" style="width:60px;height:30px;cursor:pointer">',
'</label><br><br>',
'<label>Settings color / 设置按钮色: ',
'<input type="color" id="set-settingsColor" value="', config.settingsColor, '" style="width:60px;height:30px;cursor:pointer">',
'</label><br><br>',
// ---- 行为 / behaviour ----
'<label><input type="checkbox" id="set-autoShow"', config.autoShow ? ' checked' : '', '> Auto show only after scrolling / 仅滚动后显示</label><br><br>',
'<label>Top shortcut / 上键 (Ctrl +): ',
'<select id="set-scrollKeyTop" style="width:110px">',
TOP_KEY_OPTIONS.map(function (key) {
return `<option value="${key}"${config.scrollKeys.top === key ? ' selected' : ''}>${key}</option>`;
}).join(''),
'</select>',
'</label><br>',
'<label>Bottom shortcut / 下键 (Ctrl +): ',
'<select id="set-scrollKeyBottom" style="width:110px">',
BOTTOM_KEY_OPTIONS.map(function (key) {
return `<option value="${key}"${config.scrollKeys.bottom === key ? ' selected' : ''}>${key}</option>`;
}).join(''),
'</select>',
'</label><br><br>',
'<label><input type="checkbox" id="set-keyEnabled"', config.keyEnabled ? ' checked' : '', '> Enable shortcut / 启用快捷键</label><br><br>',
'<label><input type="checkbox" id="set-darkMode"', config.darkMode ? ' checked' : '', '> Force dark button theme / 强制深色主题</label>',
'</div>'
].join('');
}
/**
* 从已打开的设置弹窗中读取每个字段、校验后写入 config。
* Read every field from the open settings dialog, validate, and write to config.
* @param {HTMLElement} popup — settings dialog element
*/
function applySettingsFromDialog(popup) {
var field = function (id) { return popup.querySelector('#' + id); };
config.iconSize = clamp(field('set-iconSize').value, 14, 48, defaultConfig.iconSize);
config.iconStyle = ICON_STYLE_ORDER.includes(field('set-iconStyle').value) ? field('set-iconStyle').value : 'outline';
config.iconScale = clamp(field('set-iconScale').value, 40, 75, defaultConfig.iconScale);
config.iconStroke = clamp(field('set-iconStroke').value, 1, 4, defaultConfig.iconStroke);
config.borderRadius = clamp(field('set-radius').value, 0, 24, defaultConfig.borderRadius);
config.panelOpacity = clamp(field('set-opacity').value, 20, 100, defaultConfig.panelOpacity);
config.lightColor = sanitizeColor(field('set-lightColor').value, defaultConfig.lightColor);
config.darkColor = sanitizeColor(field('set-darkColor').value, defaultConfig.darkColor);
config.settingsColor = sanitizeColor(field('set-settingsColor').value, defaultConfig.settingsColor);
config.autoShow = field('set-autoShow').checked;
config.keyEnabled = field('set-keyEnabled').checked;
config.scrollKeys.top = field('set-scrollKeyTop').value || defaultConfig.scrollKeys.top;
config.scrollKeys.bottom = field('set-scrollKeyBottom').value || defaultConfig.scrollKeys.bottom;
config.darkMode = field('set-darkMode').checked;
saveConfig();
}
/* ---- 原生设置对话框 / NATIVE SETTINGS DIALOG ---- */
/**
* 使用浏览器原生 <dialog>,避免在当前网页上下文加载并执行第三方 CDN 脚本。
* Uses a native <dialog>, avoiding third-party code execution in the current page.
*/
function openSettings() {
var existingDialog = document.getElementById('scroll-helper-settings-dialog');
if (existingDialog) return;
var colorPresets = {
Blue: { light: '#007BFF', dark: '#444444', settings: '#FFB800' },
Green: { light: '#28A745', dark: '#2D5016', settings: '#FFD700' },
Purple: { light: '#6F42C1', dark: '#3D1E5C', settings: '#FF6B9D' },
Red: { light: '#DC3545', dark: '#721C24', settings: '#FFA500' },
Gray: { light: '#495057', dark: '#212529', settings: '#6C757D' }
};
var dialog = document.createElement('dialog');
dialog.id = 'scroll-helper-settings-dialog';
dialog.setAttribute('aria-label', 'Scroll helper settings / 滚动助手设置');
dialog.innerHTML = [
'<div style="padding:20px;min-width:min(420px,calc(100vw - 48px));max-width:calc(100vw - 48px);box-sizing:border-box">',
'<h2 style="margin:0 0 16px;font-size:18px">Scroll helper settings / 滚动助手设置</h2>',
buildSettingsHtml(colorPresets),
'<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:18px">',
'<button type="button" id="scroll-helper-cancel">Cancel / 取消</button>',
'<button type="button" id="scroll-helper-save">Save / 保存</button>',
'</div>',
'</div>'
].join('');
Object.assign(dialog.style, {
border: '0',
borderRadius: '10px',
padding: '0',
color: '#222',
background: '#fff',
boxShadow: '0 12px 36px rgba(0,0,0,.32)'
});
var closeDialog = function () {
if (dialog.open && typeof dialog.close === 'function') dialog.close();
dialog.remove();
};
dialog.querySelectorAll('.scroll-helper-preset').forEach(function (btn) {
btn.addEventListener('click', function () {
dialog.querySelector('#set-lightColor').value = btn.dataset.light;
dialog.querySelector('#set-darkColor').value = btn.dataset.dark;
dialog.querySelector('#set-settingsColor').value = btn.dataset.settings;
});
});
dialog.querySelector('#scroll-helper-cancel').addEventListener('click', closeDialog);
dialog.querySelector('#scroll-helper-save').addEventListener('click', function () {
applySettingsFromDialog(dialog);
closeDialog();
render();
});
dialog.addEventListener('cancel', function () { dialog.remove(); });
document.body.appendChild(dialog);
if (typeof dialog.showModal === 'function') {
dialog.showModal();
} else {
// Fallback for older browsers that have no dialog API.
dialog.setAttribute('open', '');
dialog.style.position = 'fixed';
dialog.style.zIndex = '100000';
dialog.style.top = '50%';
dialog.style.left = '50%';
dialog.style.transform = 'translate(-50%, -50%)';
}
}
/*
* 12. Tampermonkey 扩展菜单 / TAMPERMONKEY MENU
*/
/**
* 将常用操作注册到 Tampermonkey 弹出菜单中。
* 这些命令显示在 Chrome 工具栏的 Tampermonkey 图标内、当前脚本名称下方。
* Register common actions in Tampermonkey's extension popup menu.
*/
function registerMenuCommands() {
if (typeof GM_registerMenuCommand !== 'function') return;
GM_registerMenuCommand('⚙️ 打开滚动助手设置', openSettings);
GM_registerMenuCommand('⚙️ 切换自动显示', function () {
config.autoShow = !config.autoShow;
saveConfig();
render();
});
GM_registerMenuCommand('⚙️ 切换快捷键', function () {
config.keyEnabled = !config.keyEnabled;
saveConfig();
render();
});
GM_registerMenuCommand('⚙️ 重置面板位置', function () {
config.iconPos = { ...defaultConfig.iconPos };
saveConfig();
render();
});
GM_registerMenuCommand('⚙️ 恢复默认设置', function () {
Object.assign(config, mergeConfig({}));
saveConfig();
render();
});
}
/*
* 12. 键盘快捷键 / KEYBOARD SHORTCUTS
*/
/**
* Ctrl + ↑ / ↓(可通过 config.scrollKeys 自定义)。
* 当焦点在可编辑元素内时自动跳过,不干扰输入。
*
* Ctrl + ↑ / ↓ (customisable via config.scrollKeys).
* Intentionally skips events whose target is an editable element
* so typing is never interrupted.
*/
function bindShortcuts() {
if (!config.keyEnabled) return;
state.keydownHandler = function (event) {
if (!event.ctrlKey) return;
// 当用户在表单字段或 contenteditable 区域内时不拦截。
// Don't hijack when the user is inside a form field or contenteditable.
var target = event.target;
if (target && (target.isContentEditable ||
/^(input|textarea|select)$/i.test(target.tagName))) {
return;
}
if (event.key === config.scrollKeys.top) {
event.preventDefault();
scrollToTop();
} else if (event.key === config.scrollKeys.bottom) {
event.preventDefault();
scrollToBottom();
}
};
document.addEventListener('keydown', state.keydownHandler);
}
/*
* 13. 面板创建 / PANEL CREATION
*/
/** 创建包含四个按钮的悬浮 <div>。幂等——任何已存在的面板会先被移除
* (由 destroyPanel / render 负责)。
* Create the floating <div> with all four buttons. Idempotent — any
* existing panel is removed first (destroyPanel / render does this). */
function createPanel() {
// 双保险:如果有残留实例则先行移除。
// Belt-and-suspenders: remove stale instance if one somehow lingers.
var existingPanel = document.getElementById(PANEL_ID);
if (existingPanel) existingPanel.remove();
var panel = document.createElement('div');
panel.id = PANEL_ID;
limitToViewport(config.iconPos);
Object.assign(panel.style, {
position: 'fixed',
right: `${config.iconPos.right}px`,
bottom: `${config.iconPos.bottom}px`,
display: 'flex',
flexDirection: 'column',
gap: `${config.iconGap}px`,
padding: '4px',
zIndex: '99999',
opacity: String(config.panelOpacity / 100),
cursor: 'move',
touchAction: 'none' // 拖拽时阻止浏览器滚动/缩放 / prevent browser scroll/zoom during drag
});
// ---- 构建四个按钮 / build the four buttons ----
panel.appendChild(createButton('scroll-top', 'top', 'Scroll to top / 滚动到顶部', scrollToTop));
panel.appendChild(createButton('scroll-mid', 'middle', 'Scroll to middle / 滚动到中间', scrollToMiddle));
panel.appendChild(createButton('scroll-bottom', 'bottom', 'Scroll to bottom / 滚动到底部', scrollToBottom));
var settingsButton = createButton(
'scroll-settings', 'settings',
'Open settings (right-click or long-press to cycle icon style) / 打开设置(右键或长按切换图标风格)',
openSettings
);
// 右键齿轮 → 切换图标风格(桌面端)
// Right-click on gear → cycle icon style (desktop)
settingsButton.addEventListener('contextmenu', function (event) {
event.preventDefault();
cycleIconStyle();
});
// 长按齿轮 → 切换图标风格(移动端,无右键)
// Long-press on gear → cycle icon style (mobile, no right-click)
(function () {
var pressTimer = null;
var longPressTriggered = false;
settingsButton.addEventListener('pointerdown', function (event) {
if (event.button !== 0 && event.pointerType === 'mouse') return;
longPressTriggered = false;
pressTimer = setTimeout(function () {
// 仅当指针没有拖动面板时才切换。
// Only cycle if the pointer hasn't been dragging the panel.
if (!state.isDragged) {
longPressTriggered = true;
state.suppressNextClick = true;
cycleIconStyle();
// 重渲染会移除正在按住的按钮;若浏览器不再派发 click,
// 则在本轮事件结束后清理标记,避免影响下一次正常点击。
// Rendering removes the pressed button; clear the marker after this event
// turn in case the browser consequently does not dispatch a click.
setTimeout(function () { state.suppressNextClick = false; }, 0);
}
}, 600);
});
var clearPress = function () {
clearTimeout(pressTimer);
pressTimer = null;
// 长按已经完成时,click 事件由 createButton() 中的标记统一吞掉。
// After a completed long press, createButton() consumes the synthetic click.
if (longPressTriggered) longPressTriggered = false;
};
settingsButton.addEventListener('pointerup', clearPress);
settingsButton.addEventListener('pointercancel', clearPress);
settingsButton.addEventListener('contextmenu', clearPress); // 避免与右键重复触发 / avoid double-fire with right-click
})();
panel.appendChild(settingsButton);
document.body.appendChild(panel);
state.panel = panel;
// 挂载后重新 clamp——此时已有真实尺寸,估算值可能有偏差。
// Re-clamp now that real dimensions are known (the estimate may be off).
limitToViewport(config.iconPos);
panel.style.right = `${config.iconPos.right}px`;
panel.style.bottom = `${config.iconPos.bottom}px`;
}
/*
* 14. RENDER——重建 UI 的唯一入口
* the single entry point for (re)building the UI
*/
/**
* 幂等:销毁已有面板并创建全新面板,重新绑定所有事件监听。
* 在初始化、保存配置、切换图标风格及系统配色变化时调用。
*
* Idempotent: destroys any existing panel, creates a fresh one, and
* wires all listeners. Called on init, on config save, on icon-style
* cycle, and when the OS colour scheme changes.
*/
function render() {
destroyPanel();
createPanel();
bindDrag();
bindScrollVisibility();
bindShortcuts();
// 当系统配色方案改变时重新渲染以更新按钮颜色
//(除非用户已开启强制深色)。
// When the OS colour scheme flips, re-render so button colours update
// (unless the user has force-dark enabled).
state.mediaHandler = function () {
if (!config.darkMode) render();
};
prefersDarkScheme.addEventListener('change', state.mediaHandler);
}
/*
* 15. 启动 / BOOTSTRAP
*/
function init() {
if (!document.body) return;
registerMenuCommands();
render();
}
// 等待 DOM 就绪;若已就绪则立即执行。
// Wait for the DOM to be ready; if it already is, fire immediately.
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();