Skip to content

Instantly share code, notes, and snippets.

@MeoMix
Created August 16, 2015 07:02
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save MeoMix/0736beb6deec47868228 to your computer and use it in GitHub Desktop.
Save MeoMix/0736beb6deec47868228 to your computer and use it in GitHub Desktop.
_.mixin({
// Inspired by: https://gist.github.com/danro/7846358
// Leverage requestAnimationFrame for throttling function calls instead of setTimeout for better perf.
throttleFramerate: function(callback) {
var wait = false;
var args = null;
var context = null;
return function() {
if (!wait) {
wait = true;
args = arguments;
context = this;
requestAnimationFrame(function() {
wait = false;
callback.apply(context, args);
});
}
};
}
});
// There's a lack of support in modern browsers for being notified of a DOM element changing dimensions.
// Provide this functionality by leveraging 'scroll' events attached to hidden DOM elements attached to
// a given element.
// http://stackoverflow.com/questions/19329530/onresize-for-div-elements/19418479#19418479
(function() {
function resetTriggers(element) {
var triggers = element.__resizeTriggers__,
expand = triggers.firstElementChild,
contract = triggers.lastElementChild,
expandChild = expand.firstElementChild;
contract.scrollLeft = contract.scrollWidth;
contract.scrollTop = contract.scrollHeight;
expandChild.style.width = expand.offsetWidth + 1 + 'px';
expandChild.style.height = expand.offsetHeight + 1 + 'px';
expand.scrollLeft = expand.scrollWidth;
expand.scrollTop = expand.scrollHeight;
};
function checkTriggers(element) {
return element.offsetWidth != element.__resizeLast__.width || element.offsetHeight != element.__resizeLast__.height;
}
function scrollListener(e) {
var element = this;
resetTriggers(this);
if (this.__resizeRAF__) cancelAnimationFrame(this.__resizeRAF__);
this.__resizeRAF__ = requestAnimationFrame(function() {
if (checkTriggers(element)) {
element.__resizeLast__.width = element.offsetWidth;
element.__resizeLast__.height = element.offsetHeight;
element.__resizeListeners__.forEach(function(fn) {
fn.call(element, e);
});
}
});
};
window.addResizeListener = function(element, fn) {
if (!element.__resizeTriggers__) {
if (getComputedStyle(element).position == 'static') element.style.position = 'relative';
element.__resizeLast__ = {};
element.__resizeListeners__ = [];
(element.__resizeTriggers__ = document.createElement('div')).className = 'resize-triggers';
element.__resizeTriggers__.innerHTML = '<div class="expand-trigger"><div></div></div>' +
'<div class="contract-trigger"></div>';
element.appendChild(element.__resizeTriggers__);
resetTriggers(element);
element.addEventListener('scroll', scrollListener, true);
}
element.__resizeListeners__.push(fn);
};
window.removeResizeListener = function(element, fn) {
element.__resizeListeners__.splice(element.__resizeListeners__.indexOf(fn), 1);
if (!element.__resizeListeners__.length) {
element.removeEventListener('scroll', scrollListener);
element.__resizeTriggers__ = !element.removeChild(element.__resizeTriggers__);
}
}
})();
var Orientation = {
Horizontal: 'horizontal',
Vertical: 'vertical'
};
var SliderView = Marionette.LayoutView.extend({
tagName: 'streamus-slider',
template: '#sliderTemplate',
ui: {
'track': '[data-ui~=track]',
'thumb': '[data-ui~=thumb]'
},
events: {
'mousedown': '_onMouseDown',
'wheel': '_onWheel'
},
// Values parsed from the HTML declaration.
value: -1,
maxValue: 100,
minValue: 0,
step: 1,
// Values stored to determine mouse movement amounts.
mouseDownValue: 0,
totalMouseMovement: 0,
// The cached length of the slider.
length: -1,
orientation: Orientation.Horizontal,
isVertical: false,
initialize: function() {
// It's important to bind pre-emptively or attempts to call removeEventListener will fail to find the appropriate reference.
this._onWindowMouseMove = this._onWindowMouseMove.bind(this);
this._onWindowMouseUp = this._onWindowMouseUp.bind(this);
this._onResize = this._onResize.bind(this);
// Provide a throttled version of _onWheel because wheel events can fire at a high rate.
// https://developer.mozilla.org/en-US/docs/Web/Events/wheel
this._onWheel = _.throttleFramerate(this._onWheel.bind(this));
this._setDefaultValues();
},
onRender: function() {
// Use custom logic to monitor element for resizing.
// This logic injects a hidden element into the DOM which is used to detect resizes.
window.addResizeListener(this.el, this._onResize);
},
onAttach: function() {
// Cache the length of the slider once it is known.
this.length = this._getLength();
// Initialize with default value and update layout. Can only be done once length is known.
var valueAttribute = this.$el.attr('value');
var value = _.isUndefined(valueAttribute) ? this._getDefaultValue() : parseInt(valueAttribute, 10);
this._setValue(value);
},
onBeforeDestroy: function() {
this._setWindowEventListeners(false);
window.removeResizeListener(this.el, this._onResize);
},
// Monitor changes to the user's mouse position after they begin clicking
// on the track. Adjust the thumb position based on mouse movements.
_onMouseDown: function(event) {
// Don't run this logic on right-click.
if (event.button === 0) {
var target = event.target;
// Snap the thumb to the mouse's position, but only do so if the mouse isn't clicking the thumb.
if (target !== this.ui.thumb[0]) {
var offset = this.isVertical ? event.offsetY : event.offsetX;
// The track element has a transform: scale applied to it.
// Normalize offset relative to parent by unscaling the offset.
if (target === this.ui.track[0]) {
offset *= this._getPercentValue(this.value);
}
var value = this._getValueByPixelValue(offset);
this._setValue(value);
}
// Start keeping track of mouse movements to be able to adjust the thumb position as the mouse moves.
this.mouseDownValue = this.value;
this._setWindowEventListeners(true);
}
},
// Update the value by one step.
_onWheel: function(event) {
var value = this.value + event.originalEvent.deltaY / (-100 / this.step);
this._setValue(value);
},
// Refresh the cached length and update layout whenever element resizes.
_onResize: function() {
var length = this._getLength();
if (this.length !== length) {
this.length = length;
this._updateLayout(this.value);
}
},
_onWindowMouseMove: function(event) {
// Invert movementY because vertical is flipped 180deg.
var movement = this.isVertical ? -event.movementY : event.movementX;
// No action is needed when moving the mouse perpendicular to our direction
if (movement !== 0) {
movement *= this._getScaleFactor();
this.totalMouseMovement += movement;
// Derive new value from initial + total movement rather than value + movement.
// If user drags mouse outside element then current + movement will not equal initial + total movement.
var value = this.mouseDownValue + this.totalMouseMovement;
this._setValue(value);
}
},
_onWindowMouseUp: function() {
this.totalMouseMovement = 0;
this._setWindowEventListeners(false);
},
// Read attributes on DOM element and use them if provided. Otherwise,
// rely on the HTML5 range input spec for default values.
_setDefaultValues: function() {
this.orientation = this.$el.attr('orientation') || this.orientation;
this.isVertical = this.orientation === Orientation.Vertical;
this.minValue = parseInt(this.$el.attr('min'), 10) || this.minValue;
this.maxValue = parseInt(this.$el.attr('max'), 10) || this.maxValue;
this.step = parseInt(this.$el.attr('step'), 10) || this.step;
},
// Temporarily add or remove mouse-monitoring events bound the window.
_setWindowEventListeners: function(isAdding) {
var action = isAdding ? window.addEventListener : window.removeEventListener;
action('mousemove', this._onWindowMouseMove);
action('mouseup', this._onWindowMouseUp);
},
// Update the slider with the given value after ensuring it is within bounds.
_setValue: function(value) {
var boundedValue = this._getBoundedValue(value);
if (this.value !== boundedValue) {
this.value = boundedValue;
// Be sure to record value on the element so $.val() and .value will yield proper values.
this.el.value = boundedValue;
this._updateLayout(boundedValue);
this.$el.trigger('input', boundedValue);
}
},
// Visually update the track and thumb elements.
// Set their translate and scale values such that they represent the given value.
_updateLayout: function(value) {
var percentValue = this._getPercentValue(value);
var pixelValue = this._getPixelValue(value);
var axis = this.isVertical ? 'Y' : 'X';
this.ui.thumb.css('transform', 'translate' + axis + '(' + pixelValue + 'px)');
this.ui.track.css('transform', 'scale' + axis + '(' + percentValue + ')');
},
// Ensure that a given value falls within the min/max and step parameters.
_getBoundedValue: function(value) {
var boundedValue = value;
// Respect min/max values
if (boundedValue > this.maxValue) {
boundedValue = this.maxValue;
}
if (boundedValue < this.minValue) {
boundedValue = this.minValue;
}
// Round value to the nearest number which is divisible by step.
// Subtract and re-add minValue so stepping down will always reach minValue.
// Do this after min/max because step should be respected before setting to maxValue.
boundedValue -= this.minValue;
boundedValue = this.step * Math.round(boundedValue / this.step);
boundedValue += this.minValue;
return boundedValue;
},
// Return the average value between minValue and maxValue.
// Useful when no value has been provided on the HTML element.
_getDefaultValue: function() {
return this.minValue + (this.maxValue - this.minValue) / 2;
},
// Take a given number and determine what percent of the input the value represents.
_getPercentValue: function(value) {
return (value - this.minValue) / (this.maxValue - this.minValue);
},
// Take a given value and return the pixel length needed to represent that value on the slider.
_getPixelValue: function(value) {
var percentValue = this._getPercentValue(value);
var pixelValue;
// Calculating pixelValue for vertical requires inverting the math because
// the slider needs to appear flipped 180deg to feel correct.
if (this.isVertical) {
pixelValue = (1 - percentValue) * this.length;
} else {
pixelValue = percentValue * this.length;
}
return pixelValue;
},
// Take a given pixelValue and convert it to corresponding slider value.
_getValueByPixelValue: function(pixelValue) {
// Vertical slider needs to be flipped 180 so inverse the value.
if (this.isVertical) {
pixelValue = this.length - pixelValue;
}
// Convert px moved to % distance moved.
var offsetPercent = pixelValue / this.length;
// Convert % distance moved into corresponding value.
var valueDifference = this.maxValue - this.minValue;
var value = this.minValue + valueDifference * offsetPercent;
return value;
},
// Query the DOM for width or height of the slider and return it.
// Method is slow and should only be used when cached length is stale.
_getLength: function() {
return this.isVertical ? this.$el.height() : this.$el.width();;
},
// Return a ratio of the range of values able to be iterated over relative to the length of the slider.
// Useful for converting a pixel amount to a slider value.
_getScaleFactor: function() {
return (this.maxValue - this.minValue) / this.length;
}
});
// Register the SliderView as a Web Component for easier re-use.
var SliderPrototype = Object.create(HTMLElement.prototype);
var sliderView;
SliderPrototype.createdCallback = function() {
sliderView = new SliderView({
el: this
});
sliderView.render();
};
SliderPrototype.attachedCallback = function() {
sliderView.triggerMethod('attach');
};
document.registerElement('streamus-slider', {
prototype: SliderPrototype
});
var data = $('#data');
$('streamus-slider').on('input', function(event, value) {
data.text(value);
});
data.text($('streamus-slider').val());
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment