一个双滑块范围选择器组件库
此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.greasyfork.org/scripts/593075/1913652/RangeSlider.js
// ==UserScript==
// @name RangeSlider
// @namespace https://greasyfork.org/users/1570630
// @version 1.0.0
// @description 一个双滑块范围选择器组件库
// @author ryxel
// @license MIT
// @grant GM_addElement
// ==/UserScript==
var RangeSlider = (function () {
'use strict';
const addStyle = typeof GM_addElement === 'function'
? (parent, textContent, id) => GM_addElement(parent, 'style', { textContent, id })
: (parent, textContent, id) => parent.appendChild(Object.assign(document.createElement('style'), { textContent, id }));
class RangeSlider {
static get styleId() {
return 'cs-slider-style';
}
static get css() {
return `
.cs-slider-container {
--cs-track-bg: #e4e7ed;
--cs-progress-bg: #409eff;
--cs-thumb-bg: #fff;
--cs-thumb-border: #409eff;
--cs-thumb-shadow: rgba(0, 0, 0, 0.2);
--cs-thumb-focus-shadow: rgba(64, 158, 255, 0.3);
position: relative;
width: 100%;
height: 20px;
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
touch-action: none;
-webkit-tap-highlight-color: transparent;
}
.cs-slider-track {
position: absolute;
width: 100%;
height: 4px;
background-color: var(--cs-track-bg);
border-radius: 2px;
}
.cs-slider-progress {
position: absolute;
height: 4px;
background-color: var(--cs-progress-bg);
border-radius: 2px;
}
.cs-slider-thumb {
box-sizing: border-box;
position: absolute;
width: 16px;
height: 16px;
background-color: var(--cs-thumb-bg);
border: 2px solid var(--cs-thumb-border);
border-radius: 50%;
top: 50%;
transform: translate(-50%, -50%);
cursor: pointer;
box-shadow: 0 1px 3px var(--cs-thumb-shadow);
transition: box-shadow 0.15s;
z-index: 10;
outline: none;
}
.cs-slider-thumb:focus {
box-shadow: 0 1px 3px var(--cs-thumb-shadow), 0 0 0 3px var(--cs-thumb-focus-shadow);
}
`;
}
constructor(container, options = {}) {
this.container = container;
this.min = options.min !== undefined ? options.min : 0;
this.max = options.max !== undefined ? options.max : 100;
this.step = options.step !== undefined ? options.step : 1;
this.range = this.max - this.min;
this.values = [
options.from !== undefined ? options.from : this.min,
options.to !== undefined ? options.to : this.max
];
this.target = [
((this.values[0] - this.min) / this.range) * 100,
((this.values[1] - this.min) / this.range) * 100
];
this.current = [...this.target];
this.animFrame = null;
this.onChange = options.onChange || function() {};
this.activeThumb = null;
this.trackLeft = 0;
this.trackWidth = 0;
this.onStart = this.onStart.bind(this);
this.onMove = this.onMove.bind(this);
this.onEnd = this.onEnd.bind(this);
this.render = this.render.bind(this);
this.animate = this.animate.bind(this);
this.initHandledKeys();
this.injectStyle();
this.initDOM();
this.render();
}
initHandledKeys() {
if (!RangeSlider.handledKeys) {
RangeSlider.handledKeys = new Set([
'ArrowLeft', 'ArrowDown', 'ArrowRight', 'ArrowUp',
'PageDown', 'PageUp', 'Home', 'End'
]);
}
}
injectStyle() {
const rootNode = this.container.getRootNode();
const root = rootNode instanceof ShadowRoot ? rootNode : document;
const styleTarget = rootNode instanceof ShadowRoot ? rootNode : document.head;
if (root.getElementById(RangeSlider.styleId)) return;
addStyle(styleTarget, RangeSlider.css, RangeSlider.styleId);
}
initDOM() {
this.container.classList.add('cs-slider-container');
this.track = document.createElement('div');
this.track.className = 'cs-slider-track';
this.progress = document.createElement('div');
this.progress.className = 'cs-slider-progress';
const thumb0 = document.createElement('div');
thumb0.className = 'cs-slider-thumb';
thumb0.tabIndex = 0;
const thumb1 = document.createElement('div');
thumb1.className = 'cs-slider-thumb';
thumb1.tabIndex = 0;
this.container.appendChild(this.track);
this.container.appendChild(this.progress);
this.container.appendChild(thumb0);
this.container.appendChild(thumb1);
this.container.addEventListener('mousedown', this.onStart);
this.container.addEventListener('touchstart', this.onStart, { passive: false });
thumb0.addEventListener('keydown', (e) => this.onKeyDown(e, 0));
thumb1.addEventListener('keydown', (e) => this.onKeyDown(e, 1));
this.thumbs = [thumb0, thumb1];
}
getValueFromPointer(e) {
const source = (e.touches && e.touches[0]) || e;
const percent = Math.max(0, Math.min(1, (source.clientX - this.trackLeft) / this.trackWidth));
return this.min + percent * this.range;
}
updateValue(index, value, fn) {
const steps = Math.round((value - this.min) / this.step);
const newValue = Math.min(this.max, Math.max(this.min, this.min + steps * this.step));
if (this.values[index] === newValue) return;
this.values[index] = newValue;
this.target[index] = ((newValue - this.min) / this.range) * 100;
if (fn === this.render) this.current[index] = this.target[index];
this.onChange({
from: Math.min(this.values[0], this.values[1]),
to: Math.max(this.values[0], this.values[1])
});
if (fn) fn();
}
setValues({ from, to } = {}, animate = false) {
const fn = animate ? this.animate : this.render;
if (from !== undefined) this.updateValue(0, from, fn);
if (to !== undefined) this.updateValue(1, to, fn);
}
onStart(e) {
e.preventDefault();
if (e.touches && e.touches.length > 1) return;
const rect = this.container.getBoundingClientRect();
this.trackLeft = rect.left;
this.trackWidth = rect.width;
const currentVal = this.getValueFromPointer(e);
if (e.target === this.thumbs[0]) {
this.activeThumb = 0;
} else if (e.target === this.thumbs[1]) {
this.activeThumb = 1;
} else {
this.activeThumb = Math.abs(currentVal - this.values[0]) <= Math.abs(currentVal - this.values[1]) ? 0 : 1;
}
this.thumbs[this.activeThumb].style.zIndex = 20;
this.thumbs[1 - this.activeThumb].style.zIndex = 10;
this.thumbs[this.activeThumb].focus();
this.updateValue(this.activeThumb, currentVal, this.animate);
document.addEventListener('mousemove', this.onMove);
document.addEventListener('touchmove', this.onMove, { passive: false });
document.addEventListener('mouseup', this.onEnd);
document.addEventListener('touchend', this.onEnd);
document.addEventListener('touchcancel', this.onEnd);
}
onMove(e) {
if (this.activeThumb === null || !this.trackWidth) return;
e.preventDefault();
this.updateValue(this.activeThumb, this.getValueFromPointer(e), this.animFrame ? null : this.render);
}
onEnd(e) {
if (e.touches && e.touches.length > 0) return;
this.activeThumb = null;
document.removeEventListener('mousemove', this.onMove);
document.removeEventListener('touchmove', this.onMove);
document.removeEventListener('mouseup', this.onEnd);
document.removeEventListener('touchend', this.onEnd);
document.removeEventListener('touchcancel', this.onEnd);
}
onKeyDown(e, index) {
if (!RangeSlider.handledKeys.has(e.key)) return;
e.preventDefault();
const step = this.step;
const bigStep = step * 10;
let currentVal = this.values[index];
switch (e.key) {
case 'ArrowLeft':
case 'ArrowDown':
currentVal -= step;
break;
case 'ArrowRight':
case 'ArrowUp':
currentVal += step;
break;
case 'PageDown':
currentVal -= bigStep;
break;
case 'PageUp':
currentVal += bigStep;
break;
case 'Home':
currentVal = this.min;
break;
case 'End':
currentVal = this.max;
break;
}
this.updateValue(index, currentVal, this.animate);
}
animate() {
cancelAnimationFrame(this.animFrame);
const startTime = performance.now();
const s = [...this.current], t = this.target;
const step = (now) => {
const progress = Math.min((now - startTime) / 150, 1);
const ease = progress * (2 - progress);
this.current[0] = s[0] + (t[0] - s[0]) * ease;
this.current[1] = s[1] + (t[1] - s[1]) * ease;
this.render();
if (progress < 1) {
this.animFrame = requestAnimationFrame(step);
} else {
this.animFrame = null;
}
};
this.animFrame = requestAnimationFrame(step);
}
render() {
const p0 = this.current[0];
const p1 = this.current[1];
const minPercent = Math.min(p0, p1);
const maxPercent = Math.max(p0, p1);
this.thumbs[0].style.left = `${p0}%`;
this.thumbs[1].style.left = `${p1}%`;
this.progress.style.left = `${minPercent}%`;
this.progress.style.width = `${maxPercent - minPercent}%`;
}
}
return RangeSlider;
})();