blob: 0cf8ef5067cca5df82e5aaf830c4f4b67776e822 [file] [log] [blame]
/*******************************************************************************
* Copyright (c) 2014-2018 BSI Business Systems Integration AG.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* BSI Business Systems Integration AG - initial API and implementation
******************************************************************************/
scout.scrollbars = {
/**
* Static function to install a scrollbar on a container.
* When the client supports pretty native scrollbars, we use them by default.
* Otherwise we install JS-based scrollbars. In that case the install function
* creates a new scrollbar.js. For native scrollbars we
* must set some additional CSS styles.
*
* @memberOf scout.scrollbars
*/
_$scrollables: {},
getScrollables: function(session) {
// return scrollables for given session
if (session) {
return this._$scrollables[session] || [];
}
// return all scrollables, no matter to which session they belong
var $scrollables = [];
scout.objects.values(this._$scrollables).forEach(function($scrollablesPerSession) {
scout.arrays.pushAll($scrollables, $scrollablesPerSession);
});
return $scrollables;
},
pushScrollable: function(session, $container) {
if (this._$scrollables[session]) {
if (this._$scrollables[session].indexOf($container) > -1) {
// already pushed
return;
}
this._$scrollables[session].push($container);
} else {
this._$scrollables[session] = [$container];
}
$.log.isTraceEnabled() && $.log.trace('Scrollable added: ' + $container.attr('class') + '. New length: ' + this._$scrollables[session].length);
},
removeScrollable: function(session, $container) {
var initLength = 0;
if (this._$scrollables[session]) {
initLength = this._$scrollables[session].length;
scout.arrays.$remove(this._$scrollables[session], $container);
$.log.isTraceEnabled() && $.log.trace('Scrollable removed: ' + $container.attr('class') + '. New length: ' + this._$scrollables[session].length);
if (initLength === this._$scrollables[session].length) {
throw new Error('scrollable could not be removed. Potential memory leak. ' + $container.attr('class'));
}
} else {
throw new Error('scrollable could not be removed. Potential memory leak. ' + $container.attr('class'));
}
},
install: function($container, options) {
options = this._createDefaultScrollToOptions(options);
options.axis = options.axis || 'both';
// Don't use native as variable name because it will break minifying (reserved keyword)
var nativeScrollbars = scout.nvl(options.nativeScrollbars, scout.device.hasPrettyScrollbars());
var hybridScrollbars = scout.nvl(options.hybridScrollbars, scout.device.canHideScrollbars());
if (nativeScrollbars) {
this._installNative($container, options);
} else if (hybridScrollbars) {
$container.addClass('hybrid-scrollable');
this._installNative($container, options);
this._installJs($container, options);
} else {
$container.css('overflow', 'hidden');
this._installJs($container, options);
}
var htmlContainer = scout.HtmlComponent.optGet($container);
if (htmlContainer) {
htmlContainer.scrollable = true;
}
$container.data('scrollable', true);
var session = options.session || options.parent.session;
this.pushScrollable(session, $container);
return $container;
},
_installNative: function($container, options) {
if (scout.device.isIos()) {
// On ios, container sometimes is not scrollable when installing too early
// Happens often with nested scrollable containers (e.g. scrollable table inside a form inside a scrollable tree data)
setTimeout(this._installNativeInternal.bind(this, $container, options));
} else {
this._installNativeInternal($container, options);
}
},
_installNativeInternal: function($container, options) {
$.log.isTraceEnabled() && $.log.trace('use native scrollbars for container ' + scout.graphics.debugOutput($container));
if (options.axis === 'x') {
$container
.css('overflow-x', 'auto')
.css('overflow-y', 'hidden');
} else if (options.axis === 'y') {
$container
.css('overflow-x', 'hidden')
.css('overflow-y', 'auto');
} else {
$container.css('overflow', 'auto');
}
$container.css('-webkit-overflow-scrolling', 'touch');
},
_installJs: function($container, options) {
$.log.isTraceEnabled() && $.log.trace('installing JS-scrollbars for container ' + scout.graphics.debugOutput($container));
var scrollbars = scout.arrays.ensure($container.data('scrollbars'));
scrollbars.forEach(function(scrollbar) {
scrollbar.destroy();
});
scrollbars = [];
var scrollbar;
if (options.axis === 'both') {
var scrollOptions = $.extend({}, options);
scrollOptions.axis = 'y';
scrollbar = scout.create('Scrollbar', $.extend({}, scrollOptions));
scrollbars.push(scrollbar);
scrollOptions.axis = 'x';
scrollOptions.mouseWheelNeedsShift = true;
scrollbar = scout.create('Scrollbar', $.extend({}, scrollOptions));
scrollbars.push(scrollbar);
} else {
scrollbar = scout.create('Scrollbar', $.extend({}, options));
scrollbars.push(scrollbar);
}
$container.data('scrollbars', scrollbars);
// Container with JS scrollbars must have either relative or absolute position
// otherwise we cannot determine the correct dimension of the scrollbars
// var cssPosition = $container.css('position');
// if (!scout.isOneOf(cssPosition, 'relative', 'absolute')) {
// $container.css('position', 'relative');
// }
// scrollbars.forEach(function(scrollbar) {
// scrollbar.render($container);
// scrollbar.update();
// });
},
/**
* Removes the js scrollbars for the $container, if there are any.<p>
*/
uninstall: function($container, session) {
if (!$container.data('scrollable')) {
// was not installed previously -> uninstalling not necessary
return;
}
var scrollbars = $container.data('scrollbars');
if (scrollbars) {
scrollbars.forEach(function(scrollbar) {
scrollbar.destroy();
});
}
this.removeScrollable(session, $container);
$container.removeData('scrollable');
$container.css('overflow', '');
$container.removeData('scrollbars');
var htmlContainer = scout.HtmlComponent.optGet($container);
if (htmlContainer) {
htmlContainer.scrollable = false;
}
},
/**
* Recalculates the scrollbar size and position.
* @param $scrollable JQuery element that has .data('scrollbars'), when $scrollable is falsy the function returns immediately
* @param immediate set to true to immediately update the scrollbar, If set to false,
* it will be queued in order to prevent unnecessary updates.
*/
update: function($scrollable, immediate) {
if (!$scrollable || !$scrollable.data('scrollable')) {
return;
}
var scrollbars = $scrollable.data('scrollbars');
if (!scrollbars) {
if (scout.device.isIos()) {
this._handleIosPaintBug($scrollable);
}
return;
}
if (immediate) {
this._update(scrollbars);
return;
}
if ($scrollable.data('scrollbarUpdatePending')) {
return;
}
// Executes the update later to prevent unnecessary updates
setTimeout(function() {
this._update(scrollbars);
$scrollable.removeData('scrollbarUpdatePending');
}.bind(this), 0);
$scrollable.data('scrollbarUpdatePending', true);
},
_update: function(scrollbars) {
// Reset the scrollbars first to make sure they don't extend the scrollSize
// scrollbars.forEach(function(scrollbar) {
// if (scrollbar.rendered) {
// scrollbar.reset();
// }
// });
// scrollbars.forEach(function(scrollbar) {
// if (scrollbar.rendered) {
// scrollbar.update();
// }
// });
},
/**
* IOS has problems with nested scrollable containers. Sometimes the outer container goes completely white hiding the elements behind.
* This happens with the following case: Main box is scrollable but there are no scrollbars because content is smaller than container.
* In the main box there is a tab box with a scrollable table. This table has scrollbars.
* If the width of the tab box is adjusted (which may happen if the tab item is selected and eventually prefSize called), the main box will go white.
* <p>
* This happens only if -webkit-overflow-scrolling is set to touch.
* To workaround this bug the flag -webkit-overflow-scrolling will be removed if the scrollable component won't display any scrollbars
*/
_handleIosPaintBug: function($scrollable) {
if ($scrollable.data('scrollbarUpdatePending')) {
return;
}
setTimeout(function() {
workaround();
$scrollable.removeData('scrollbarUpdatePending');
});
$scrollable.data('scrollbarUpdatePending', true);
function workaround() {
var size = scout.graphics.size($scrollable).subtract(scout.graphics.insets($scrollable, {
includePadding: false,
includeBorder: true
}));
if ($scrollable[0].scrollHeight === size.height && $scrollable[0].scrollWidth === size.width) {
$scrollable.css('-webkit-overflow-scrolling', '');
} else {
$scrollable.css('-webkit-overflow-scrolling', 'touch');
}
}
},
reset: function($scrollable) {
var scrollbars = $scrollable.data('scrollbars');
if (!scrollbars) {
return;
}
scrollbars.forEach(function(scrollbar) {
scrollbar.reset();
});
},
/**
* Scrolls the $scrollable to the given $element (must be a child of $scrollable)
*
* OPTION DEFAULT VALUE DESCRIPTION
* ------------------------------------------------------------------------------------------------------
* align undefined Specifies where the element should be positioned in the view port. Can either be 'top', 'center' or 'bottom'.
* If unspecified, the following rules apply:
* - If the element is above the visible area it will be aligned to top.
* - If the element is below the visible area it will be aligned to bottom.
* - If the element is already in the visible area no scrolling is done.
*
* animate false If true, the scroll position will be animated so that the element moves smoothly to its new position
* stop true If true, all running animations are stopped before executing the current scroll request.
*
* @param {$} $scrollable
* the scrollable object
* @param {$} $element
* the element to scroll to
* @param [options]
* an optional options object, see table above. Short-hand version: If a string is passed instead
* of an object, the value is automatically converted to the option "align".
*/
scrollTo: function($scrollable, $element, options) {
var scrollTo,
scrollOffsetUp = 4,
scrollOffsetDown = 8,
scrollableH = $scrollable.height(),
elementBounds = scout.graphics.offsetBounds($element),
scrollableBounds = scout.graphics.offsetBounds($scrollable),
elementTop = elementBounds.y - scrollableBounds.y - scrollOffsetUp, // relative to scrollable y
elementTopNew = 0,
elementH = elementBounds.height + scrollOffsetDown,
elementBottom = elementTop + elementH;
if (typeof options === 'string') {
options = {
align: options
};
} else {
options = this._createDefaultScrollToOptions(options);
}
var align = options.align;
if (!align) {
// If the element is above the visible area it will be aligned to top.
// If the element is below the visible area it will be aligned to bottom.
// If the element is already in the visible area no scrolling is done.
align = (elementTop < 0) ? 'top' : (elementBottom > scrollableH ? 'bottom' : undefined);
} else {
align = align.toLowerCase();
}
if (align === 'center') {
// align center
scrollTo = $scrollable.scrollTop() + elementTop - Math.max(0, (scrollableH - elementH) / 2);
} else if (align === 'top') {
// align top
// Element is on the top of the view port -> scroll up
scrollTo = $scrollable.scrollTop() + elementTop;
} else if (align === 'bottom') {
// align bottom
// Element is on the Bottom of the view port -> scroll down
// On IE, a fractional position gets truncated when using scrollTop -> ceil to make sure the full element is visible
scrollTo = Math.ceil($scrollable.scrollTop() + elementBottom - scrollableH);
// If the viewport is very small, make sure the element is not moved outside on top
// Otherwise when calling this function again, since the element is on the top of the view port, the scroll pane would scroll down which results in flickering
elementTopNew = elementTop - (scrollTo - $scrollable.scrollTop());
if (elementTopNew < 0) {
scrollTo = scrollTo + elementTopNew;
}
}
if (scrollTo) {
scout.scrollbars.scrollTop($scrollable, scrollTo, options);
}
},
_createDefaultScrollToOptions: function(options) {
var defaults = {
anmiate: false,
stop: true
};
return $.extend({}, defaults, options);
},
/**
* Horizontally scrolls the $scrollable to the given $element (must be a child of $scrollable)
*/
scrollHorizontalTo: function($scrollable, $element, options) {
var scrollTo,
scrollableW = $scrollable.width(),
elementBounds = scout.graphics.bounds($element, true),
elementLeft = elementBounds.x,
elementW = elementBounds.width;
if (elementLeft < 0) {
scout.scrollbars.scrollLeft($scrollable, $scrollable.scrollLeft() + elementLeft, options);
} else if (elementLeft + elementW > scrollableW) {
// On IE, a fractional position gets truncated when using scrollTop -> ceil to make sure the full element is visible
scrollTo = Math.ceil($scrollable.scrollLeft() + elementLeft + elementW - scrollableW);
scout.scrollbars.scrollLeft($scrollable, scrollTo, options);
}
},
scrollTop: function($scrollable, scrollTop, options) {
options = this._createDefaultScrollToOptions(options);
var scrollbar = scout.scrollbars.scrollbar($scrollable, 'y');
if (scrollbar) {
scrollbar.notifyBeforeScroll();
}
// Not animated
if (!options.animate) {
if (options.stop) {
$scrollable.stop('scroll');
}
$scrollable.scrollTop(scrollTop);
if (scrollbar) {
scrollbar.notifyAfterScroll();
}
return;
}
// Animated
this.animateScrollTop($scrollable, scrollTop, options);
$scrollable.promise('scroll').always(function() {
if (scrollbar) {
scrollbar.notifyAfterScroll();
}
});
},
scrollLeft: function($scrollable, scrollLeft, options) {
options = this._createDefaultScrollToOptions(options);
var scrollbar = scout.scrollbars.scrollbar($scrollable, 'x');
if (scrollbar) {
scrollbar.notifyBeforeScroll();
}
// Not animated
if (!options.animate) {
if (options.stop) {
$scrollable.stop('scroll');
}
$scrollable.scrollLeft(scrollLeft);
if (scrollbar) {
scrollbar.notifyAfterScroll();
}
return;
}
// Animated
this.animateScrollLeft($scrollable, scrollLeft, options);
$scrollable.promise('scroll').always(function() {
if (scrollbar) {
scrollbar.notifyAfterScroll();
}
});
},
animateScrollTop: function($scrollable, scrollTop, options) {
if (options.stop) {
$scrollable.stop('scroll');
}
$scrollable.animate({
scrollTop: scrollTop
}, {
queue: 'scroll'
})
.dequeue('scroll');
},
animateScrollLeft: function($scrollable, scrollLeft, options) {
if (options.stop) {
$scrollable.stop('scroll');
}
$scrollable.animate({
scrollLeft: scrollLeft
}, {
queue: 'scroll'
})
.dequeue('scroll');
},
scrollbar: function($scrollable, axis) {
var scrollbars = $scrollable.data('scrollbars') || [];
return scout.arrays.find(scrollbars, function(scrollbar) {
return scrollbar.axis === axis;
});
},
scrollToBottom: function($scrollable) {
scout.scrollbars.scrollTop($scrollable, $scrollable[0].scrollHeight - $scrollable[0].offsetHeight);
},
/**
* Returns true if the location is visible in the current viewport of the $scrollable, or if $scrollable is null
* @param location object with x and y properties
*
*/
isLocationInView: function(location, $scrollable) {
if (!$scrollable || $scrollable.length === 0) {
return true;
}
var scrollableOffsetBounds = scout.graphics.offsetBounds($scrollable);
return scrollableOffsetBounds.contains(location.x, location.y);
},
/**
* Attaches the given handler to each scrollable parent, including $anchor if it is scrollable as well.<p>
* Make sure you remove the handlers when not needed anymore using offScroll.
*/
onScroll: function($anchor, handler) {
handler.$scrollParents = [];
$anchor.scrollParents().each(function() {
var $scrollParent = $(this);
$scrollParent.on('scroll', handler);
handler.$scrollParents.push($scrollParent);
});
},
offScroll: function(handler) {
var $scrollParents = handler.$scrollParents;
if (!$scrollParents) {
throw new Error('$scrollParents are not defined');
}
for (var i = 0; i < $scrollParents.length; i++) {
var $elem = $scrollParents[i];
$elem.off('scroll', handler);
}
},
/**
* Sets the position to fixed and updates left and top position.
* This is necessary to prevent flickering in IE.
*/
fix: function($elem) {
if (!$elem.isVisible() || $elem.css('position') === 'fixed') {
return;
}
// getBoundingClientRect used by purpose instead of scout.graphics.offsetBounds to get exact values
// Also important: offset() of jquery returns getBoundingClientRect().top + window.pageYOffset.
// In case of IE and zoom = 125%, the pageYOffset is 1 because the height of the navigation is bigger than the height of the desktop which may be fractional.
var bounds = $elem[0].getBoundingClientRect();
$elem
.css('position', 'fixed')
.cssLeft(bounds.left - $elem.cssMarginLeft())
.cssTop(bounds.top - $elem.cssMarginTop())
.cssWidth(bounds.width)
.cssHeight(bounds.height);
},
/**
* Reverts the changes made by fix().
*/
unfix: function($elem, timeoutId, immediate) {
clearTimeout(timeoutId);
if (immediate) {
this._unfix($elem);
return;
}
return setTimeout(function() {
this._unfix($elem);
}.bind(this), 50);
},
_unfix: function($elem) {
$elem.css({
position: 'absolute',
left: '',
top: '',
width: '',
height: ''
});
},
/**
* Stores the position of all scrollables that belong to an optional session.
* @param session (optional) when no session is given, scrollables from all sessions are stored
*/
storeScrollPositions: function($container, session) {
var $scrollables = this.getScrollables(session);
if (!$scrollables) {
return;
}
var scrollTop, scrollLeft;
$scrollables.forEach(function($scrollable) {
if ($container.isOrHas($scrollable[0])) {
scrollTop = $scrollable.scrollTop();
$scrollable.data('scrollTop', scrollTop);
scrollLeft = $scrollable.scrollLeft();
$scrollable.data('scrollLeft', $scrollable.scrollLeft());
$.log.isTraceEnabled() && $.log.trace('Stored scroll position for ' + $scrollable.attr('class') + '. Top: ' + scrollTop + '. Left: ' + scrollLeft);
}
});
},
/**
* Restores the position of all scrollables that belong to an optional session.
* @param session (optional) when no session is given, scrollables from all sessions are restored
*/
restoreScrollPositions: function($container, session) {
var $scrollables = this.getScrollables(this.session);
if (!$scrollables) {
return;
}
var scrollTop, scrollLeft;
$scrollables.forEach(function($scrollable) {
if ($container.isOrHas($scrollable[0])) {
scrollTop = $scrollable.data('scrollTop');
if (scrollTop) {
$scrollable.scrollTop(scrollTop);
$scrollable.removeData('scrollTop');
}
scrollLeft = $scrollable.data('scrollLeft');
if (scrollLeft) {
$scrollable.scrollLeft(scrollLeft);
$scrollable.removeData('scrollLeft');
}
// Also make sure that scroll bar is up to date
// Introduced for use case: Open large table page, edit entry, press f5
// -> outline tab gets rendered, scrollbar gets updated with set timeout, outline tab gets detached
// -> update event never had any effect because it executed after detaching (due to set timeout)
scout.scrollbars.update($scrollable);
$.log.isTraceEnabled() && $.log.trace('Restored scroll position for ' + $scrollable.attr('class') + '. Top: ' + scrollTop + '. Left: ' + scrollLeft);
}
});
},
setVisible: function($scrollable, visible) {
if (!$scrollable || !$scrollable.data('scrollable')) {
return;
}
var scrollbars = $scrollable.data('scrollbars');
if (!scrollbars) {
return;
}
scrollbars.forEach(function(scrollbar) {
if (scrollbar.rendered) {
scrollbar.$container.setVisible(visible);
}
});
},
opacity: function($scrollable, opacity) {
if (!$scrollable || !$scrollable.data('scrollable')) {
return;
}
var scrollbars = $scrollable.data('scrollbars');
if (!scrollbars) {
return;
}
scrollbars.forEach(function(scrollbar) {
if (scrollbar.rendered) {
scrollbar.$container.css('opacity', opacity);
}
});
},
_getCompleteChildRowsHeightRecursive: function(children, getChildren, isExpanded, defaultChildHeight) {
var height = 0;
children.forEach(function(child) {
if (child.height) {
height += child.height;
} else {
// fallback for children with unset height
height += defaultChildHeight;
}
if (isExpanded(child) && getChildren(child).length > 0) {
height += this._getCompleteChildRowsHeightRecursive(getChildren(child), getChildren, isExpanded, defaultChildHeight);
}
}.bind(this));
return height;
},
ensureExpansionVisible: function(parent) {
var isParentExpanded = parent.isExpanded(parent.element);
var children = parent.getChildren(parent.element);
var parentPositionTop = parent.$element.position().top;
var parentHeight = parent.element.height;
var scrollTop = parent.$scrollable.scrollTop();
// vertical scrolling
if (!isParentExpanded) {
// parent is not expanded, make sure that at least one node above the parent is visible
if (parentPositionTop < parentHeight) {
var minScrollTop = Math.max(scrollTop - (parentHeight - parentPositionTop), 0);
this.scrollTop(parent.$scrollable, minScrollTop, {
animate: true
});
}
} else if (isParentExpanded && children.length > 0) {
// parent is expanded and has children, best effort approach to show the expansion
var fullDataHeight = parent.$scrollable.height();
// get childRowCount considering already expanded rows
var childRowsHeight = this._getCompleteChildRowsHeightRecursive(children, parent.getChildren, parent.isExpanded, parent.defaultChildHeight);
// + 1.5 since its the parent's top position and we want to scroll half a row further to show that there's something after the expansion
var additionalHeight = childRowsHeight + (1.5 * parentHeight);
var scrollTo = parentPositionTop + additionalHeight;
// scroll as much as needed to show the expansion but make sure that the parent row (plus one more) is still visible
var newScrollTop = scrollTop + Math.min(scrollTo - fullDataHeight, parentPositionTop - parentHeight);
// only scroll down
if (newScrollTop > scrollTop) {
this.scrollTop(parent.$scrollable, newScrollTop, {
animate: true,
stop: false
});
}
}
if (children.length > 0) {
// horizontal scrolling: at least 3 levels of hierarchy should be visible (only relevant for small fields)
var minLevelLeft = Math.max(parent.element.level - 3, 0) * parent.nodePaddingLevel;
this.scrollLeft(parent.$scrollable, minLevelLeft, {
animate: true,
stop: false
});
}
}
};