View Issue Details

IDProjectCategoryView StatusLast Update
0017098MMW 5Otherpublic2020-11-21 20:29
Reporterdrakinite Assigned To 
PriorityimmediateSeveritytweakReproducibilityalways
Status closedResolutionfixed 
Product Version5.0 
Target Version5.0Fixed in Version5.0 
Summary0017098: Improvement to sliders on laptop trackpads
DescriptionSliders (e.g. volume) could not be controlled easily on laptop trackpads, as described here:
https://www.mediamonkey.com/forum/viewtopic.php?f=30&t=97712
I made a significant improvement to sliders' behavior on trackpads. Makes it easy to move sliders quickly and precisely. Tested on volume, equalizer, and tools>options>player>popupdisplay sliders.

Additionally, the mousewheel event is deprecated (see: https://developer.mozilla.org/en-US/docs/Web/API/Element/mousewheel_event and https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event) so the event listener in slider.js is changed accordingly.
Additional Information(modified slider.js is attached)
TagsNo tags attached.
Attached Files
slider.js (23,976 bytes)   
/* '(C) Ventis Media, Licensed under the Ventis Limited Reciprocal License - see: license.txt for details' */

"use strict";

/**
@module UI
*/

requirejs('controls/control');

/**
UI Slider element

@class Slider
@constructor
@extends Control
*/
inheritClass('Slider', Control, {
    initialize: function (parentel, params) {
        Slider.$super.initialize.apply(this, arguments);

        this.initialized = false;
        this._orientation = 'horizontal', this._invert = false;
        this._fromZero = false;
        this._min = 0;
        this._max = 0;
        this._value = 0;
        this._step = 0;
        this._wheelStep = undefined;
        this._tickPlacement = 'none';
        this._tickInterval = 1;
        this._seekBarSize = 0;
        this._seekBarThumbSizes = {
            offsetWidth: 0,
            offsetHeight: 0
        };
        this._seekBarSizes = {
            offsetWidth: 0,
            offsetHeight: 0
        };
        var _mouseOffset = 0;
        var _seeking = false;
        
        // ** to prevent having too many concurrent wheel events
        this._lastEventTime = 0;
        this._minTimeBetweenTicks = 30;

        this._seekBarOuter = document.createElement('div');
        this.container.appendChild(this._seekBarOuter);

        this._ticksOuter = document.createElement('div');
        this._seekBarOuter.appendChild(this._ticksOuter);

        this._seekBar = document.createElement('div');
        this._seekBarOuter.appendChild(this._seekBar);

        this._seekBarBefore = document.createElement('div');
        this._seekBarOuter.appendChild(this._seekBarBefore);

        this._seekBarThumb = document.createElement('div');
        this._seekBarOuter.appendChild(this._seekBarThumb);

        var _isHorz = function () {
            return (this._orientation === 'horizontal');
        }.bind(this);

        var _validSize = function () {
            return (this._max > this._min);
        }.bind(this);

        var _enumWidth = function () {
            if (!this._seekBarSize) {
                var wProp = '';

                if (_isHorz()) {
                    wProp = 'offsetWidth';
                } else {
                    wProp = 'offsetHeight';
                }

                if (this._seekBarSizes[wProp] > 0)
                    this._seekBarSize = this._seekBarSizes[wProp];
                _mouseOffset = Math.round(this._seekBarThumbSizes[wProp] / 2);
            }
        }.bind(this);

        var _liveEvent = function () {
            var liveevt = createNewCustomEvent('livechange', {
                detail: {
                    value: this.value
                },
                bubbles: true,
                cancelable: true
            });
            this.container.dispatchEvent(liveevt);
        }.bind(this);

        var _changeEvent = function () {
            var evt = createNewEvent('change');
            this.container.dispatchEvent(evt);
        }.bind(this);

        this._roundToStep = function (val, st) {
            st = st || this.step;
            if (st !== 0)
                return Math.round(val / st) * st;
            else
                return val;
        }.bind(this);

        var _getSize = function () {
            _enumWidth();
            return this._seekBarSize;
        }.bind(this);

        var _getOffset = function (e) {
            var horz = _isHorz();
            var val;

            if (e.elX !== undefined) {
                if (horz) {
                    val = e.elX - _mouseOffset;
                } else {
                    val = e.elY - _mouseOffset;
                }
            } else {
                if (e.currentTarget !== e.target) {
                    if (!this.sbOuterPos) {
                        this.sbOuterPos = findScreenPos(this._seekBarOuter);
                    }
                    if (horz) {
                        val = e.screenX - this.sbOuterPos.left - _mouseOffset;
                    } else {
                        val = e.screenY - this.sbOuterPos.top - _mouseOffset;
                    }
                } else {
                    if (horz) {
                        val = e.offsetX - _mouseOffset;
                    } else {
                        val = e.offsetY - _mouseOffset;
                    }
                }
            }
            return Math.min(Math.max(val, 0), _getSize());
        }.bind(this);

        this._updateInvert = function () {
            var horz = _isHorz();
            if (this._invert) {
                var twHalf = Math.round(this._seekBarThumbSizes[horz ? 'offsetWidth' : 'offsetHeight'] / 2);
                this._seekBarBefore.style[horz ? 'left' : 'top'] = 'auto';
                this._seekBarBefore.style[horz ? 'right' : 'bottom'] = twHalf + 'px';
            } else {
                this._seekBarBefore.style[horz ? 'left' : 'top'] = '';
                this._seekBarBefore.style[horz ? 'right' : 'bottom'] = '';
            }
        };

        var _updateBarBefore = function (pos) {
            var horz = _isHorz();
            var twHalf = Math.round(this._seekBarThumbSizes[horz ? 'offsetWidth' : 'offsetHeight'] / 2);
            if (this._invert) {
                var w = _getSize();
                pos = w - pos /* + tw*/ ;
            } else {
                //pos += tw;
            }
            if (this._fromZero) {
                var zP = Math.round(this.zeroPos + twHalf);
                if (this.zeroPos < pos) {
                    this._seekBarBefore.style[horz ? 'left' : 'top'] = zP + 'px';
                    this._seekBarBefore.style[horz ? 'width' : 'height'] = (pos - this.zeroPos) + 'px';
                } else {
                    this._seekBarBefore.style[horz ? 'left' : 'top'] = (pos + twHalf) + 'px';
                    this._seekBarBefore.style[horz ? 'width' : 'height'] = (this.zeroPos - pos) + 'px';
                }
            } else {
                if (this._invert) {
                    this._seekBarBefore.style[horz ? 'right' : 'bottom'] = twHalf + 'px';
                } else {
                    this._seekBarBefore.style[horz ? 'left' : 'top'] = twHalf + 'px';
                }
                this._seekBarBefore.style[horz ? 'width' : 'height'] = pos + 'px';
            }
        }.bind(this);

        var _setPos = function (pos) {
            pos = Math.round(pos);
            this._seekBarThumb.style[_isHorz() ? 'left' : 'top'] = pos + 'px';
            _updateBarBefore(pos);
        }.bind(this);

        this._assignClasses = function () {
            if (!this.initialized)
                return;
            if (this._orientation == 'horizontal') {
                this._seekBarOuter.className = 'seekBarOuter';
                this._seekBar.className = 'seekBar';
                this._seekBarBefore.className = 'seekBarBefore';
                this._seekBarThumb.className = 'seekBarThumb';
                this._ticksOuter.className = 'ticksOuter';
            } else {
                this._seekBarOuter.className = 'seekBarOuterVert';
                this._seekBar.className = 'seekBarVert';
                this._seekBarBefore.className = 'seekBarBeforeVert';
                this._seekBarThumb.className = 'seekBarThumbVert';
                this._ticksOuter.className = 'ticksOuterVert';
            }
            this._seekBarSize = 0;
        }.bind(this);

        var _updateVal = function (e) {
            var w = _getSize();
            if (w > 0) {
                var one = (this._max - this._min) / w;
                var val = _getOffset(e);
                var pos = one * val;

                if (this._invert) {
                    this._value = this._max - pos;
                } else
                    this._value = this._min + pos;
            }
        }.bind(this);

        this._updatePos = function () {
            if (this._seekBarThumb && !_seeking) {
                var w = _getSize();
                if (w > 0) {
                    var one = (this._max - this._min) / w;
                    if (one > 0) {
                        var pos = (this.value - this._min) / one;
                        if (this._fromZero) {
                            this.zeroPos = (0 - this._min) / one;
                        } else if (this._invert) {
                            pos = w - pos;
                        }
                        _setPos(pos);
                    }
                }
            }
        }.bind(this);

        var _pointerDownEvent = function (e) {
            if (!_validSize() || this._disabled || (e.button !== 0) /* take only left button */ )
                return;
            _seeking = true;
            var _moveaction = function (e) {
                if (_seeking) {
                    _setPos(_getOffset(e));
                    _updateVal(e);
                    _liveEvent();
                }
            }.bind(this);
            var _endaction = function (e) {
                if (_seeking) {
                    // apply
                    _setPos(_getOffset(e));
                    _updateVal(e);
                }
                _seeking = false;
                this._updatePos();
                _liveEvent();
                _changeEvent();
            }.bind(this);
            _liveEvent(); // call this, so listeners will know, that slider was catched
            handleCapture(this._seekBarOuter, _moveaction, _endaction);
        }.bind(this);

        var _keyDownHandler = function (evt) {
            if (!_validSize())
                return;
            if (evt.ctrlKey || evt.altKey || evt.shiftKey)
                return;
            var horz = _isHorz();
            var keydown = horz ? 37 : 40;
            var keyup = horz ? 39 : 38;
            var step = this.step || (this.max - this.min) / 100;
            if ((!this.invert && !horz) || (horz && this.invert)) {
                step = -step;
            }
            switch (evt.keyCode) {
                case keydown:
                    {
                        this.value = this.value - step;
                        _liveEvent();
                        _changeEvent();
                        evt.stopPropagation();
                        break;
                    }
                case keyup:
                    {
                        this.value = this.value + step;
                        _liveEvent();
                        _changeEvent();
                        evt.stopPropagation();
                        break;
                    }
            }
        }.bind(this);

        this.mouseWheelHandler = function (evt) {
            if (!_validSize())
                return;
            
            var step = 0;
            if (evt.shiftKey)
                step = this.step;
            else
                step = this.wheelStep;
            step = step || (this.max - this.min) / 100;
            
            // ** only process event if it's after the minimum time
            var dt = Date.now() - this._lastEventTime;
            if (dt > this._minTimeBetweenTicks) {
                this._lastEventTime = Date.now();
                
                var delta;
                
                // if trackpad
                if (Math.abs(evt.wheelDelta) < 60) {
                    var maxStep, minStep;
                    // if step is less than 1, it could be very small, so make maxStep in relation to minStep
                    if (step < 1) {
                        minStep = step / 2; // If it's already a decimal, we can lower the min for greater precision
                        maxStep = step * 10;
                    }
                    // if step is greater than 1, give maxStep a bit more oomph
                    else {
                        minStep = 1;
                        maxStep = (step > 2) ? step*2 : 5;
                    }
                    
                    var rawDelta = evt.wheelDelta * (maxStep - minStep) / 40;
                    // ** Add or subtract minStep from rawDelta depending on pos. or neg.
                    delta = (rawDelta > 0) ? rawDelta + minStep : rawDelta - minStep;
                    this.value = this._roundToStep(this.value + delta, minStep);
                }
                else {
                    delta = step * (evt.wheelDelta / 120);
                    this.value = this._roundToStep(this.value + delta, step);
                }
                
                evt.stopPropagation();
                _liveEvent();
                _changeEvent();
            }
            else {
                evt.stopPropagation();
            }
        }.bind(this);

        this.setTabbable = function (val) {
            if (val === this._tabbable)
                return;
            this._tabbable = val;
            if (this._tabbable) {
                this._seekBarOuter.tabIndex = 0; // makes it tabbable and able to catch keys
                app.listen(this._seekBarOuter, 'keydown', _keyDownHandler);
                app.listen(this._seekBarOuter, 'wheel', this.mouseWheelHandler); // ** mousewheel event deprecated
            } else {
                this._seekBarOuter.tabIndex = -1;
                app.unlisten(this._seekBarOuter, 'keydown', _keyDownHandler);
                app.unlisten(this._seekBarOuter, 'wheel', this.mouseWheelHandler); // ** mousewheel event deprecated
            }
        }.bind(this);

        this._generateTicks = function () {
            if (!this.initialized)
                return;
            cleanElement(this._ticksOuter);
            if ((this._tickPlacement !== 'none') && _validSize()) {
                if (this._tickInterval) {
                    this._ticks = new Array();
                    for (var i = this.min; i <= this.max; i += this.tickInterval) {
                        this._ticks.push(i);
                    }
                }
                if (this._ticks) {
                    var horz = _isHorz();
                    var both = (this._tickPlacement === 'both');
                    var cls = both ? 'topLeft' : this._tickPlacement;
                    for (var b = 0; b < (both ? 2 : 1); b++) {
                        for (var i = 0; i < this._ticks.length; i++) {
                            var t = document.createElement('div');
                            t.className = 'tick ' + cls;
                            var pval = 100.0 * ((this._ticks[i] - this.min) / (this.max - this.min));
                            if (this._invert)
                                pval = 100 - pval;
                            t.style[horz ? 'left' : 'top'] = pval + '%';
                            this._ticksOuter.appendChild(t);
                        }
                        cls = 'bottomRight';
                    }
                };
            }
        }.bind(this);

        this.activateImmediateTooltipHandling = function (formatValueFunc) {
            this.container.tooltipImmediate = true;
            if (formatValueFunc)
                this.formatTooltipValue = formatValueFunc;
            else
                this.formatTooltipValue = undefined;

            app.listen(this._seekBarOuter, 'mousemove', function (e) {
                var w = _getSize();
                if (w > 0) {
                    var one = (this._max - this._min) / w;
                    var val = _getOffset(e);
                    var pos = one * val;

                    if (this._invert) {
                        this._hoverValue = this._max - pos;
                    } else
                        this._hoverValue = this._min + pos;
                } else
                    this._hoverValue = undefined;
            }.bind(this), true);

            this.container.tooltipValueCallback = function (tipdiv, vis) {
                if (!vis || (this._hoverValue === undefined) || this.disabled) {
                    tipdiv.innerText = '';
                    return;
                } else {
                    if (this.formatTooltipValue)
                        tipdiv.innerText = this.formatTooltipValue(this._hoverValue);
                    else
                        tipdiv.innerText = this._hoverValue;
                }
            }.bind(this);
        }.bind(this);

        this.tabbable = true; // default
        if (params) {
            // set passed attributes
            for (var key in params) {
                this[key] = params[key];
            }
        };

        this.initialized = true;
        this._assignClasses();
        this._updateInvert();
        this._generateTicks();
        app.listen(this._seekBarOuter, 'mousedown', _pointerDownEvent);
        app.listen(this._seekBarOuter, 'touchstart', _pointerDownEvent);

        this.cleanUp = function () {
            app.unlisten(this._seekBarOuter);
            Slider.$super.cleanUp.apply(this, arguments);
        };
        this.registerEventHandler('layoutchange');
        this.handle_layoutchange(); // make initial setting
    },

    handle_layoutchange: function (evt) {
        this.sbOuterPos = undefined;
        if (this._seekBarThumb) {
            this._seekBarThumbSizes.offsetWidth = this._seekBarThumb.offsetWidth;
            this._seekBarThumbSizes.offsetHeight = this._seekBarThumb.offsetHeight;
        }
        if (this._seekBar) {
            this._seekBarSizes.offsetWidth = this._seekBar.offsetWidth;
            this._seekBarSizes.offsetHeight = this._seekBar.offsetHeight;
        }
        if (this._max > this._min) {
            this._seekBarSize = 0;
            this._updatePos();
        }

        if (evt)
            Slider.$super.handle_layoutchange.call(this, evt);
    },

    ignoreHotkey: function (hotkey) {
        var ar = ['Right', 'Left'];
        return inArray(hotkey, ar, true /* ignore case */ );
    }

}, {
    /**
    Slider orientation. Possible values: 'vertical', 'horizontal'

    @property orientation
    @type string
    @default 'horizontal'
    */
    orientation: {
        get: function () {
            return this._orientation;
        },

        set: function (val) {
            if (this._orientation !== val) {
                this._orientation = val;
                this._assignClasses();
                this._updateInvert();
                this._generateTicks();
            }
        }
    },
    /**
    If true, reverse direction of the slider.

    @property invert
    @type boolean
    @default false
    */
    invert: {
        get: function () {
            return this._invert;
        },

        set: function (val) {
            if (this._invert !== val) {
                this._invert = val;
                this._updateInvert();
                this._updatePos();
                this._generateTicks();
            }
        }
    },

    /**
    If true, show slider value with beginning in zero

    @property fromZero
    @type boolean
    @default false
    */

    fromZero: {
        get: function () {
            return this._fromZero;
        },

        set: function (val) {
            if (this._fromZero !== val) {
                this._fromZero = val;
                if (this._fromZero)
                    this._invert = false;
                this._updateInvert();
                this._updatePos();
                this._generateTicks();
            }
        }
    },

    /**
    Get/set minimal value of the slider.

    @property min
    @type number
    @default 0
    */
    min: {
        get: function () {
            return this._min;
        },

        set: function (val) {
            if (this._min !== val) {
                this._min = val;
                if (this.value < this._min)
                    this.value = this._min;
                this._updatePos();
                this._generateTicks();
            }
        }
    },
    /**
    Get/set maximal value of the slider.

    @property max
    @type number
    @default 0
    */
    max: {
        get: function () {
            return this._max;
        },

        set: function (val) {
            if (this._max !== val) {
                this._max = val;
                if (this.value > this._max)
                    this.value = this._max;
                this._updatePos();
                this._generateTicks();
            }
        }
    },
    /**
    Get/set step of the slider. 0 means no discrete step.

    @property step
    @type number
    @default 0
    */
    step: {
        get: function () {
            return this._step;
        },

        set: function (val) {
            if (this._step !== val) {
                this._step = val;
                this._updatePos();
            }
        }
    },
    /**
    Get/set wheel step of the slider (step used by mouse wheel). Default is undefined = wheel uses "step" property.

    @property wheelStep
    @type number
    @default undefined
    */
    wheelStep: {
        get: function () {
            if (this._wheelStep !== undefined)
                return this._wheelStep;
            else
                return this._step;
        },

        set: function (val) {
            if (this._wheelStep !== val) {
                this._wheelStep = val;
            }
        }
    },
    /**
    Get/set actual value of the slider.

    @property value
    @type number
    @default 0
    */
    value: {
        get: function () {
            return this._roundToStep(this._value);
        },

        set: function (val) {
            val = this._roundToStep(val);
            if ((val >= this.min) && (val <= this.max) && (val !== this._value)) {
                this._value = val;
                this._updatePos();
            }
        }
    },
    /**
    If true, enables focusing control and controlling by arrow keys.

    @property tabbable
    @type boolean
    @default true
    */
    tabbable: {
        get: function () {
            return this._tabbable;
        },

        set: function (val) {
            this.setTabbable(val);
        }
    },
    /**
    Slider ticks placement. Possible values: 'none', 'both', 'bottomRight', 'topLeft', 'center'

    @property tickPlacement
    @type string
    @default 'none'
    */
    tickPlacement: {
        get: function () {
            return this._tickPlacement;
        },

        set: function (val) {
            if (this._tickPlacement !== val) {
                this._tickPlacement = val;
                this._generateTicks();
            }
        }
    },
    /**
    Slider ticks interval.

    @property tickInterval
    @type number
    @default 1
    */
    tickInterval: {
        get: function () {
            return this._tickInterval;
        },

        set: function (val) {
            if (this._tickInterval !== val) {
                this._tickInterval = val;
                this._generateTicks();
            }
        }
    },
    /**
    Array of slider ticks values. For regular ticks user should rather use tickInterval.

    @property tickInterval
    @type array
    @default []
    */
    ticks: {
        get: function () {
            return this._ticks;
        },

        set: function (val) {
            this._ticks = val;
            this._tickInterval = 0;
            this._generateTicks();
        }
    }
});
slider.js (23,976 bytes)   
Fixed in build2275

Activities

michal

2020-11-20 17:49

developer   ~0060301

Code improvements merged to build 2275.

peke

2020-11-21 20:29

developer   ~0060307

Verified 2275

Code improvement did not introduced any regressions in normal windows.