RangeSlider

一个双滑块范围选择器组件库

이 스크립트는 직접 설치하는 용도가 아닙니다. 다른 스크립트에서 메타 지시문 // @require https://update.greasyfork.org/scripts/593075/1913652/RangeSlider.js을(를) 사용하여 포함하는 라이브러리입니다.

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

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

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

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

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

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

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

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

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

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

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

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

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

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

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