%PDF- %PDF-
| Direktori : /proc/self/root/data/old/home/stash/atlassian-stash/static/util/ |
| Current File : //proc/self/root/data/old/home/stash/atlassian-stash/static/util/synchronized-scroll.js |
define('util/synchronized-scroll', [
'underscore',
'util/function',
'util/math',
'util/performance',
'exports'
],
/**
* Used for synchronizing the scroll positions of two elements in such a way that
* regions within those two elements are on-screen at approximately the same times.
*
* @exports util/synchronized-scroll
*/
function(
_,
fn,
math,
performance,
exports
) {
'use strict';
var spreadMultiply = fn.spread(math.multiply);
function weightedAverage(items, weights) {
// NOTE: The weights add up to 1, so there is no need to divide by the total weight to get the average.
return _.chain(_.zip(items, weights)).map(spreadMultiply).reduce(math.add, 0).value();
}
/**
* Returns an object that represents a single scrollable UI element. This object implements all the methods required
* for synchronized scrolling with other elements.
*
* @param {HTMLElement|jQuery} el - a jQuery-able to reference for scroll information
* @constructor
*/
function Scrollable(el) {
if (el instanceof Scrollable) {
return el;
}
if (!(this instanceof Scrollable)) {
return new Scrollable(el);
}
this._el = el;
this._programmatic = false;
return this;
}
/**
* Get dimensions and scroll location for this scrollable in px.
*
* @returns {{top: number, left: number, width: number, height: number, clientWidth: number, clientHeight: number}}
*/
Scrollable.prototype.getScrollInfo = function() {
return {
top : this._el.scrollTop,
left : this._el.scrollLeft,
width : this._el.scrollWidth,
height : this._el.scrollHeight,
clientWidth : this._el.clientWidth,
clientHeight : this._el.clientHeight
};
};
/**
* Set a location to scroll to.
*
* @param {number} [x] - A new scrollLeft value, or null to ignore
* @param {number} [y] - A new scrollTop value, or null to ignore
*/
Scrollable.prototype.scrollTo = function(x, y) {
if (y != null) {
this._programmatic = true;
this._el.scrollTop = y;
}
if (x != null) {
this._programmatic = true;
this._el.scrollLeft = x;
}
};
/**
* Return whether a programmatic scroll has occurred since the last time this method was called, and reset it to false.
* @returns {*}
*/
Scrollable.prototype.getAndUnsetProgrammaticScrollMarker = function() {
var ret = this._programmatic;
this._programmatic = false;
return ret;
};
/**
* Returns an object that represents a vertical 'region' within a scrollable. This object implements methods
* that provide dimensions and offsets for the region, relative to the parent scrollable.
*
* @param {Scrollable} scrollable - the parent scrollable for this region
* @param {HTMLElement} el - a DOM element to reference for region dimensions
* @constructor
*/
function Region(scrollable, el) {
if (!(this instanceof Region)) {
return new Region(scrollable, el);
}
this._el = el;
this._parentScrollable = scrollable;
if (!this._parentScrollable._el) {
throw new Error("Regions can't (yet) be created for custom Scrollables. We would need to solve the API problem of where to put a function to get the offset relative to the top of the scrollable.");
}
return this;
}
/**
* Get the height of the region
* @returns {number} height of this region
*/
Region.prototype.getHeight = function() {
return this._el.offsetHeight;
};
/**
* Get this region's vertical offset (px) from the parent {@link Scrollable}. It should be negative
* if the region begins above the visible area of the Scrollable, and positive otherwise.
* @returns {number}
*/
Region.prototype.getOffsetTop = function() {
return this._el.getBoundingClientRect().top - this._parentScrollable._el.getBoundingClientRect().top;
};
function scrollTopFromDistanceToTop(info, dist) {
return dist;
}
function scrollTopFromDistanceToBottom(info, dist) {
return info.scrollHeight - info.clientHeight - dist;
}
var distanceToTop = fn.dot('scrollTop');
function distanceToBottom(info) {
return info.scrollHeight - info.clientHeight - info.scrollTop;
}
/**
* This function takes 3 different methods of "synchronized" scrolling and returns a weighted average of all of them.
*
* It uses:
* - a "linked" algorithm that tries to keep 'linked' regions in each scrollable on screen at the same time.
* - a "topness" algorithm that tries to move scrollables toward the top at proportional speeds.
* - a "bottomness" algorithm that tries to move scrollables toward the bottom at proportional speeds.
*
* The "linked" algorithm is ideal towards the middle of a diff. The "topness" algorithm is important at the top
* of a diff so dependent scrollables reach the top and aren't left hanging somewhere in the middle of the diff.
* Similarly, the "bottomness" algorithm is important at the bottom for the same reason.
*
* @param {Object} selfOldInfo
* @param {Object} selfInfo
* @param {Object[]} otherInfos
* @param {Function} getRegions
* @param {Function} getLinkedRegion
* @param {number} focusHeightFraction
* @returns {Array} of scroll commands, shaped like {scrollable: Scrollable, scrollTop: number, scrollLeft: number }
*/
function getSynchedScrollCommands(selfOldInfo, selfInfo, otherInfos, getRegions, getLinkedRegion, focusHeightFraction) {
// we don't care about any scrollables that are too small to scroll.
var scrolledVertically = (selfInfo.scrollTop !== selfOldInfo.scrollTop);
var scrolledHorizontally = (selfInfo.scrollLeft !== selfOldInfo.scrollLeft);
otherInfos = _.filter(otherInfos, function(info) {
return (scrolledVertically && info.canScrollVertically) || (scrolledHorizontally && info.canScrollHorizontally);
});
if (!otherInfos.length) {
return [];
}
if (!scrolledVertically) {
// shortcut if we only have horizontal scrolls - they're easier
return _.map(otherInfos, function(otherInfo) {
return getScrollCommand(otherInfo, null, selfInfo.scrollLeft);
});
}
var thisDirection = selfInfo.scrollTop > selfOldInfo.scrollTop ? 'down' : 'up';
var allInfos = otherInfos.concat(selfInfo);
var verticallyRelevantInfos = _.filter(allInfos, fn.dot('canScrollVertically'));
var binaryMax = fn.binary(Math.max);
function maxPropValue(arr, prop) {
return _.chain(arr)
.pluck(prop)
.reduce(binaryMax, 0)
.value();
}
var topness = maxPropValue(verticallyRelevantInfos, 'topness');
var bottomness = maxPropValue(verticallyRelevantInfos, 'bottomness');
// -ness^2 means further distances decrease faster (exponentially)
// weights are represented as [ linkedWeight, topnessWeight, bottomnessWeight ]
var weights = balanceWeights(topness * topness, bottomness * bottomness, thisDirection);
var linkedWeight = weights[0];
var topnessWeight = weights[1];
var bottomnessWeight = weights[2];
var linkedScrolls = linkedWeight > 0 ?
getLinkedScrollTops(focusHeightFraction, selfInfo, otherInfos, getRegions, getLinkedRegion) :
[];
var topnessScrolls = topnessWeight > 0 ?
_.map(otherInfos, thisDirection === 'up' ?
proportionalScroll(selfOldInfo, selfInfo, distanceToTop, scrollTopFromDistanceToTop) :
equivalentScroll(selfOldInfo, selfInfo, distanceToTop, scrollTopFromDistanceToTop)
) :
[];
var bottomnessScrolls = bottomnessWeight > 0 ?
_.map(otherInfos, thisDirection === 'down' ?
proportionalScroll(selfOldInfo, selfInfo, distanceToBottom, scrollTopFromDistanceToBottom) :
equivalentScroll(selfOldInfo, selfInfo, distanceToBottom, scrollTopFromDistanceToBottom)
) :
[];
return _.chain(otherInfos).zip(_.zip(linkedScrolls, topnessScrolls, bottomnessScrolls))
.map(fn.spread(function(scrollableInfo, scrollSuggestions) {
var shouldScrollHorizontally = scrollableInfo.scrollLeft !== selfInfo.scrollLeft;
if (!scrollableInfo.canScrollVertically) { // shortcut for horizontal scrolls.
return getScrollCommand(scrollableInfo,
null,
shouldScrollHorizontally && scrollableInfo.canScrollHorizontally ?
selfInfo.scrollLeft :
null);
}
var normalizedSuggestions = scrollSuggestions
.map(fn.defaultValue(0))
.map(math.clamp(0, scrollableInfo.scrollHeight));
var scrollTop = weightedAverage(normalizedSuggestions, weights);
var scrollTopChange = scrollTop - scrollableInfo.scrollTop;
// Don't scroll if nothing is changing.
// Don't scroll in the opposite direction of the current scroll. That's weird looking.
var shouldScrollVertically = 0 < (thisDirection === 'up' ? -1 : 1) * scrollTopChange;
if (!shouldScrollVertically && !shouldScrollHorizontally) {
return null;
}
return getScrollCommand(scrollableInfo,
shouldScrollVertically ? scrollTop : null,
shouldScrollHorizontally ? selfInfo.scrollLeft : null
);
}))
.filter(_.identity)
.value();
}
/**
* Given the weight for each algorithm's scrollTop values, balance them to add up to 1, and return
* them as an array of [ newLinkedWeight, newTopnessWeight, newBottomnessWeight ]
*
* @param {number} topnessWeight
* @param {number} bottomnessWeight
* @param {string} dir
* @returns {number[]} array of length 3 - [ newLinkedWeight, newTopnessWeight, newBottomnessWeight ]
*/
function balanceWeights(topnessWeight, bottomnessWeight, dir) {
// The greater of topness/bottomness weights takes precendence and will be weighted at face value.
// the lesser will split the remaining weight with linkedWeight at a ratio of x-to-1.
// e.g. if topnessWeight = 0.75, bottomnessWeight = 0.5, then linkedWeight and bottomnessWeight will share the 0.25 at a ratio of 1-to-0.5
// weights = [ 0.25/1.5 , 0.75, 0.25 * 0.5 / 1.5 ]
var maxEdgeWeight = Math.max(topnessWeight, bottomnessWeight);
var minEdgeWeight = Math.min(topnessWeight, bottomnessWeight);
var linkedWeight = (1 - maxEdgeWeight) / (1 + minEdgeWeight);
var rebalancedMinWeight = (1 - maxEdgeWeight) * minEdgeWeight / (1 + minEdgeWeight);
// the greater weight takes precendence. scroll direction is the tie-breaker.
var topTakesPrecedence = topnessWeight > bottomnessWeight || (topnessWeight === bottomnessWeight && dir === 'up');
return [
linkedWeight,
topTakesPrecedence ? maxEdgeWeight : rebalancedMinWeight,
topTakesPrecedence ? rebalancedMinWeight : maxEdgeWeight
];
}
/**
* Return a scroll command
* @param {Object} scrollableInfo
* @param {number|null} scrollTop
* @param {number|null} scrollLeft
* @returns {{scrollable: Scrollable, scrollTop: number|null, scrollLeft: number|null}}
*/
function getScrollCommand(scrollableInfo, scrollTop, scrollLeft) {
return {
scrollable : scrollableInfo.scrollable,
scrollTop : scrollTop,
scrollLeft : scrollLeft
};
}
/**
* Given information about a scrollable's old position and new position, this function will return a function that
* takes in a second scrollable and calculates a scrollTop value for it. The calculation is based on moving the second scrollable
* an equivalent proportion of the distance that the first scrollable moved, relative to a given edge (top or bottom).
*
* That is, if the first scrollable was at 8px from the edge, and is now at 2px from the edge, then it has moved a proportion
* of 6/8th (or 75%) of the distance to the edge. So if the second scrollable is currently 100px away from the edge,
* it will be moved 75px, or 75%, closer to the edge.
*
* This algorithm ensures that scrollbars will all reach the edge together, with no scrollbar left "hanging" in the middle.
*
* @param {Object} thisOldInfo - pre-scroll scrollable info for the reference Scrollable
* @param {Object} thisInfo - post-scroll scrollable info for the reference Scrollable
* @param {function(Object)} getDistanceToEdge - a function that takes in a scrollable info and returns the distance to the edge in pixels.
* @param {function(Object, number)} convertDistanceToScrollTop - a function that takes in scrollable info and a pixel distance and outputs a scrollTop value.
* @returns {function(Object) : number} a function that takes in a scrollableInfo and outputs a scrollTop
*/
function proportionalScroll(thisOldInfo, thisInfo, getDistanceToEdge, convertDistanceToScrollTop) {
// HACK: avoids getting n/(super-small-or-zero) === Infinity results
var notQuiteZero = 1;
var thisOldDistance = Math.max(getDistanceToEdge(thisOldInfo), notQuiteZero);
// new distance should be a proportional move from the current distance.
// (scrolledOldDistance - scrolledNewDistance) / scrolledOldDistance <=>
// scrollProportion <=> (otherOldDistance - otherNewDistance) / otherOldDistance
// otherNewDistance = otherOldDistance - scrollProportion * otherOldDistance
var scrollProportion = (thisOldDistance - getDistanceToEdge(thisInfo)) / thisOldDistance;
return function(info) {
// HACK: symmetry with the above HACK which avoids Infinity values
var otherDistance = Math.max(getDistanceToEdge(info), notQuiteZero);
var out = otherDistance - scrollProportion * otherDistance;
// avoid pixel jiggles as we reach the top or bottom due to our Infinity hack.
if (out < 1) {
out = 0;
}
return convertDistanceToScrollTop(info, out);
};
}
/**
* Scroll the others an equal distance to how far you've scrolled.
*
* @param {Object} thisOldInfo - pre-scroll scrollable info for the reference Scrollable
* @param {Object} thisInfo - post-scroll scrollable info for the reference Scrollable
* @param {function(Object)} getDistanceToEdge - a function that takes in a scrollable info and returns the distance to the edge in pixels.
* @param {function(Object, number)} convertDistanceToScrollTop - a function that takes in scrollable info and a pixel distance and outputs a scrollTop value.
* @returns {function(Object) : number} a function that takes in a scrollableInfo and outputs a scrollTop
*/
function equivalentScroll(thisOldInfo, thisInfo, getDistanceToEdge, convertDistanceToScrollTop) {
var thisOldDistance = getDistanceToEdge(thisOldInfo);
var change = getDistanceToEdge(thisInfo) - thisOldDistance;
return function(info) {
var out = getDistanceToEdge(info) + change;
return convertDistanceToScrollTop(info, out);
};
}
/**
* Determine the scrollTop for each dependent scrollable based on keeping their linked regions as closely aligned as possible.
*
* The algorithm is essentially:
* - find the pixel height "offset-line" for the given `focusHeightFraction`
* - find the region in the reference scrollable that contains that offset line.
* - given that region, find the linked region in each of the dependent scrollables
* - determine the fraction of the reference region's area that is above the offset line.
* - return the scrollTop for each dependent scrollable that would cause it's linked region to have the same fraction of area above the offset line.
*
* @param {number} focusHeightFraction - the fraction of the way down the visible frame that we should attemp to show linked regions for.
* @param {Object} selfInfo - scrollableInfo for the reference scrollable
* @param {Array} otherInfos - scrollableInfos for the dependent scrollables.
* @param {function(Scrollable)} getRegions - get the regions within a scrollable
* @param {function(Region, Scrollable)} getLinkedRegion - get the region within the given scrollable that is linked to the provided region.
* @returns {*}
*/
function getLinkedScrollTops(focusHeightFraction, selfInfo, otherInfos, getRegions, getLinkedRegion) {
// how many pixels from the top of the "window" visible frame is the imaginary
// line where we'll like things to align across files
var refPointOffset = selfInfo.clientHeight * focusHeightFraction;
var selfRegions = getRegions(selfInfo.scrollable);
// which el in the scrolled document is crossing the line
var refRegionInfo = getRegionInfoAtYOffset(refPointOffset, selfRegions);
var linkedRegionInfos = otherInfos.map(function(scrollableInfo) {
if (!scrollableInfo.canScrollVertically) {
return null;
}
return getRegionInfo(getLinkedRegion(refRegionInfo.region, scrollableInfo.scrollable));
});
// find out how far through the imaginary line our el in the scrolled document is
var fractionPastRef = getFractionThroughRefPoint(refPointOffset, refRegionInfo);
return _.zip(linkedRegionInfos, otherInfos).map(fn.spread(function(regionInfo, scrollableInfo) {
if (!scrollableInfo.canScrollVertically) {
return null;
}
var expectedOffsetRefPoint = scrollableInfo.clientHeight * focusHeightFraction;
// what position would we need to link up the matching region
var expectedPosition = positionForFractionThroughRefPoint(expectedOffsetRefPoint, fractionPastRef, regionInfo);
var oldScrollTop = scrollableInfo.scrollTop;
// get the scrollTop that would give us that position
return Math.max(0, oldScrollTop + regionInfo.offsetTop - expectedPosition);
}));
}
// fraction through ref point = (offset + height - refPointOffset) / height
// position = (fraction through ref point * height) + refPointOffset - height
// solve the above for fraction through ref point
function getFractionThroughRefPoint(refPointOffset, regionInfo) {
var bottom = regionInfo.offsetTop + regionInfo.height;
var fraction = (bottom - refPointOffset) / regionInfo.height;
return Math.max(0, Math.min(1, fraction));
}
// solve the above for position
function positionForFractionThroughRefPoint(refPointOffset, fraction, regionInfo) {
return (fraction * regionInfo.height) + refPointOffset - regionInfo.height;
}
/**
* Return the region that contains the given y-offset.
* @param {number} yOffset
* @param {Array} regionInfos
* @returns {*} region info
*/
function getRegionInfoAtYOffset(yOffset, regions) {
var regionInfo;
_.some(regions, function(region) {
// we call getRegionInfo in here, during the iteration so we can do it lazily.
// if we're at the top of the scroll, we don't have to get info for any regions further down.
var info = getRegionInfo(region);
var top = info.offsetTop;
var height = info.height;
if (yOffset > top && yOffset <= top + height) {
regionInfo = info;
return true;
}
return false;
});
return regionInfo;
}
/**
* Get all the metadata we need from a region in a cached form for internal use.
*
* @param region
* @returns {{region: Region, offsetTop: number, height: number}}
*/
function getRegionInfo(region) {
return {
region : region,
offsetTop : region.getOffsetTop(),
height : region.getHeight()
};
}
/**
* Get all the metadata we need from a scrollable in a cached form for internal use.
* @param scrollable
* @returns {{scrollable: Scrollable, clientHeight: number, scrollHeight: number, scrollTop: number}}
*/
function getScrollableInfo(scrollable) {
var info = scrollable.getScrollInfo();
info = {
scrollable : scrollable,
clientWidth : info.clientWidth,
clientHeight : info.clientHeight,
scrollWidth : info.width,
scrollHeight : info.height,
scrollTop : info.top,
scrollLeft : info.left,
canScrollHorizontally : info.clientWidth < info.width,
canScrollVertically : info.clientHeight < info.height
};
// how close to the top and bottom edge, as a fraction of 1/2 the viewable "window" height (arbitrary distance). 1 is "at the edge", 0 is "at least half a screen away"
// if our screen size is 0, we just use 0 as our topness and bottomness.
var halfAScreen = info.clientHeight/2;
info.topness = halfAScreen ? Math.max(0, (halfAScreen - info.scrollTop) / halfAScreen) : 0;
info.bottomness = halfAScreen ? Math.max(0, 1 - (info.scrollHeight - (info.scrollTop + info.clientHeight)) / halfAScreen) : 0;
return info;
}
/**
* Return a scroll handler that will scroll any dependent scrollables in unison.
*
* TODO: Try to eat-ERRRIMEAN...*ahem*..."code in"... Bacon. (...JS) FRP-style.
*
* @param {Object} options - {@see getScrollHandle.defaults} for details on what options should be passed in.
*/
function getScrollHandler(options) {
options = _.extend({}, exports.getScrollHandler.defaults, options);
var selfOldInfo = getScrollableInfo(options.self);
var enqueueScrollPropagation = performance.enqueueCapped(requestAnimationFrame, function propagateScroll() {
var selfInfo = getScrollableInfo(options.self);
var otherInfos = _.map(options.others, getScrollableInfo);
var scrollCommands = getSynchedScrollCommands(
selfOldInfo, selfInfo, otherInfos,
options.getRegions, options.getLinkedRegion, options.focusHeightFraction);
selfOldInfo = selfInfo;
if (scrollCommands.length) {
options.executeCommands(scrollCommands);
}
});
return {
handle: function() {
if (options.self.getAndUnsetProgrammaticScrollMarker()) {
selfOldInfo = getScrollableInfo(options.self); // update our cached data
return;
}
enqueueScrollPropagation();
},
reset: function() {
selfOldInfo = getScrollableInfo(options.self);
}
};
}
/*
* Returns a {@link Scrollable} object for use in {@link getScrollHandler}. You don't have to use this to generate a
* Scrollable - you are free to implement the methods on Scrollable yourself.
*/
exports.createScrollable = Scrollable;
/*
* Returns a {@link Region} object for use in {@link getScrollHandler}. You don't have to use this to generate a
* Region - you are free to implement the methods on Region yourself.
*/
exports.createRegion = Region;
/**
* Return a scroll handler that will scroll any dependent scrollables in unison.
* *
* @param {Object} options - {@see getScrollHandle.defaults} for details on what options should be passed in.
*/
exports.getScrollHandler = getScrollHandler;
/**
* Options for use in {@link getScrollHandler}
*/
exports.getScrollHandler.defaults = {
/**
* A scrollable representing the element to add a scroll handler for.
*
* @type {Scrollable}
*/
self : null,
/**
* An array of scrollables that should scroll in unison with this one
*
* @type {Scrollable[]}
*/
others : null,
/**
* A function that returns all the regions within a given Scrollable.
*
* @type {function(Scrollable): Region[]}
*/
getRegions : null,
/**
* A function that, given Region R and Scrollable S, returns the Region within Scrollable S that should be
* visible when R is visible.It can be assumed that R is not within S.
*
* @type {function(Region, Scrollable): Region}
*/
getLinkedRegion : null,
/**
* The fraction of the way down the visible "window" that we expect users to focus on. The region containing
* this imaginary 'focus' line will be used for vertical scroll alignment.
*
* @type {number}
*/
focusHeightFraction : 0.5,
/**
* A function that will execute an array of scroll commands in the form { scrollable : Scrollable, scrollTop: ?number, scrollLeft : ?number }
* useful if you need to execute them all together. Otherwise, the default works.
*
* @type { function}
*/
executeCommands : function(scrollCommands) {
_.forEach(scrollCommands, function(cmd) {
cmd.scrollable.scrollTo(cmd.scrollLeft, cmd.scrollTop);
});
}
};
});