/* * Copyright (c) 2010, 2025 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ * * SPDX-License-Identifier: EPL-2.0 */ import {arrays, Device, graphics, HtmlComponent, InitModelOf, Insets, objects, Rectangle, scout, Scrollbar, Session, SomeRequired, WidgetModel} from '../index'; import $ from 'jquery'; export type ScrollDirection = 'x' | 'y' | 'both'; export interface ScrollbarInstallOptions extends WidgetModel { /** * Default is both */ axis?: ScrollDirection; borderless?: boolean; mouseWheelNeedsShift?: boolean; nativeScrollbars?: boolean; hybridScrollbars?: boolean; /** * Controls the scroll shadow behavior: *
*/ uninstall($container: JQuery, session: Session) { if (!$container.data('scrollable')) { // was not installed previously -> uninstalling not necessary return; } let scrollbarArr = $container.data('scrollbars'); if (scrollbarArr) { scrollbarArr.forEach(scrollbar => { scrollbar.destroy(); }); } scrollbars.removeScrollable(session, $container); $container.removeData('scrollable'); $container.removeData('scroll-axis'); $container.css('overflow', ''); $container.removeClass('hybrid-scrollable'); $container.removeData('scrollbars'); let htmlContainer = HtmlComponent.optGet($container); if (htmlContainer) { htmlContainer.scrollable = false; } scrollbars.uninstallScrollShadow($container, session); }, /** * 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($scrollable: JQuery, immediate?: boolean) { if (!$scrollable || !$scrollable.data('scrollable')) { return; } scrollbars.updateScrollShadow($scrollable); let scrollbarArr: Scrollbar[] = $scrollable.data('scrollbars'); if (!scrollbarArr) { if (Device.get().isIos()) { scrollbars._handleIosPaintBug($scrollable); } return; } if (immediate) { scrollbars._update(scrollbarArr); return; } if ($scrollable.data('scrollbarUpdatePending')) { return; } // Executes the update later to prevent unnecessary updates setTimeout(() => { scrollbars._update(scrollbarArr); $scrollable.removeData('scrollbarUpdatePending'); }, 0); $scrollable.data('scrollbarUpdatePending', true); }, /** @internal */ _update(scrollbarArr: Scrollbar[]) { // Reset the scrollbars first to make sure they don't extend the scrollSize scrollbarArr.forEach(scrollbar => { if (scrollbar.rendered) { scrollbar.reset(); } }); scrollbarArr.forEach(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. *
* This happens only if -webkit-overflow-scrolling is set to touch.
* To work around this bug the flag -webkit-overflow-scrolling will be removed if the scrollable component won't display any scrollbars
* @internal
*/
_handleIosPaintBug($scrollable: JQuery) {
if ($scrollable.data('scrollbarUpdatePending')) {
return;
}
setTimeout(() => {
workaround();
$scrollable.removeData('scrollbarUpdatePending');
});
$scrollable.data('scrollbarUpdatePending', true);
function workaround() {
let size = graphics.size($scrollable).subtract(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($scrollable: JQuery) {
let scrollbarArr: Scrollbar[] = $scrollable.data('scrollbars');
if (!scrollbarArr) {
return;
}
scrollbarArr.forEach(scrollbar => scrollbar.reset());
},
reveal($element: JQuery, options?: ScrollToOptions | ScrollToAlignment) {
let $scrollParent = $element.scrollParent();
if ($scrollParent.length === 0) {
// No scrollable parent found -> scrolling is not possible
return;
}
scrollbars.scrollTo($scrollParent, $element, options);
},
/**
* Scrolls the $scrollable to the given $element.
*
* @param $scrollable
* the scrollable object
* @param $element
* the element to scroll to
* @param opts
* Shorthand version: If a string is passed instead
* of an object, the value is automatically converted to the option {@link ScrollToOptions.align}.
*/
scrollTo($scrollable: JQuery, $element: JQuery, opts?: ScrollToOptions | ScrollToAlignment) {
let options: ScrollToOptions;
if (typeof opts === 'string') {
options = {
align: opts
};
} else {
options = scrollbars._createDefaultScrollToOptions(opts);
}
let align = (options.align ? options.align.toLowerCase() : undefined);
let scrollOffsetUp = scout.nvl(options.scrollOffsetUp, align === 'center' ? 0 : 4);
let scrollOffsetDown = scout.nvl(options.scrollOffsetDown, align === 'center' ? 0 : 8);
let scrollableBounds = graphics.offsetBounds($scrollable);
let scrollableH = scrollableBounds.height;
let elementBounds = graphics.offsetBounds($element);
let elementMargins = graphics.margins($element);
let elementY = elementBounds.y - scrollableBounds.y;
let elementTop = elementY - elementMargins.top - scrollOffsetUp; // relative to scrollable y
let elementH = elementBounds.height;
let elementBottom = elementY + elementH + elementMargins.bottom + scrollOffsetDown;
// --- ^ <-- elementTop
// | | marginTop + scrollOffsetUp
// | v
// +------------+ ^ <-- elementY
// | element | | elementH
// +------------+ v
// | ^
// | | marginBottom + scrollOffsetDown
// --- v <-- elementBottom
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);
}
let scrollTo;
switch (align) {
case 'top':
scrollTo = $scrollable.scrollTop() + elementTop;
break;
case 'center':
scrollTo = $scrollable.scrollTop() + elementTop - Math.max(0, (scrollableH - elementH) / 2);
break;
case 'bottom': {
// 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
let elementTopNew = elementTop - (scrollTo - $scrollable.scrollTop());
if (elementTopNew < 0) {
scrollTo = scrollTo + elementTopNew;
}
break;
}
}
if (scrollTo !== undefined) {
scrollbars.scrollTop($scrollable, scrollTo, options);
}
},
/** @internal */
_createDefaultScrollToOptions(options?: ScrollbarInstallOptions): ScrollToOptions {
let defaults: ScrollToOptions = {
animate: false,
stop: true
};
return $.extend({}, defaults, options);
},
/**
* Horizontally scrolls the $scrollable to the given $element.
*
* @param $scrollable
* the scrollable object
* @param $element
* the element to scroll to
*/
scrollHorizontalTo($scrollable: JQuery, $element: JQuery, options?: ScrollHorizontalToOptions) {
let scrollOffsetLeft = scout.nvl(options?.scrollOffsetLeft, 0);
let scrollOffsetRight = scout.nvl(options?.scrollOffsetRight, 0);
let scrollableBounds = graphics.offsetBounds($scrollable);
let scrollableW = scrollableBounds.width;
let elementBounds = graphics.offsetBounds($element);
let elementMargins = graphics.margins($element);
let elementX = elementBounds.x - scrollableBounds.x;
let elementLeft = elementX - elementMargins.left - scrollOffsetLeft;
let elementW = elementBounds.width;
let elementRight = elementX + elementW + elementMargins.right + scrollOffsetRight;
let align = options?.align;
if (!align) {
// If the element is above the visible area it will be aligned to left.
// If the element is below the visible area it will be aligned to right.
// If the element is already in the visible area no scrolling is done.
align = (elementLeft < 0) ? 'left' : (elementRight > scrollableW ? 'right' : undefined);
}
let scrollTo;
switch (align) {
case 'left':
scrollTo = Math.floor($scrollable.scrollLeft() + elementLeft);
break;
case 'center':
scrollTo = $scrollable.scrollLeft() + elementLeft - Math.max(0, (scrollableW - elementW) / 2);
break;
case 'right':
// On IE, a fractional position gets truncated when using scrollTop -> ceil to make sure the full element is visible
scrollTo = Math.ceil($scrollable.scrollLeft() + elementRight - scrollableW);
break;
}
if (scrollTo !== undefined) {
scrollbars.scrollLeft($scrollable, scrollTo, options);
}
},
/**
* @param $scrollable the scrollable object
* @param scrollTop the new scroll position
*/
scrollTop($scrollable: JQuery, scrollTop: number, options?: ScrollOptions) {
options = scrollbars._createDefaultScrollToOptions(options);
let scrollbarElement = scrollbars.scrollbar($scrollable, 'y');
if (scrollbarElement) {
scrollbarElement.notifyBeforeScroll();
}
if (options.stop) {
$scrollable.stop('scroll');
}
// Not animated
if (!options.animate) {
$scrollable.scrollTop(scrollTop);
if (scrollbarElement) {
scrollbarElement.notifyAfterScroll();
}
return;
}
// Animated
scrollbars.animateScrollTop($scrollable, scrollTop);
$scrollable.promise('scroll').always(() => {
if (scrollbarElement) {
scrollbarElement.notifyAfterScroll();
}
});
},
/**
* @param $scrollable the scrollable object
* @param scrollLeft the new scroll position
*/
scrollLeft($scrollable: JQuery, scrollLeft: number, options?: ScrollOptions) {
options = scrollbars._createDefaultScrollToOptions(options);
let scrollbarElement = scrollbars.scrollbar($scrollable, 'x');
if (scrollbarElement) {
scrollbarElement.notifyBeforeScroll();
}
if (options.stop) {
$scrollable.stop('scroll');
}
// Not animated
if (!options.animate) {
$scrollable.scrollLeft(scrollLeft);
if (scrollbarElement) {
scrollbarElement.notifyAfterScroll();
}
return;
}
// Animated
scrollbars.animateScrollLeft($scrollable, scrollLeft);
$scrollable.promise('scroll').always(() => {
if (scrollbarElement) {
scrollbarElement.notifyAfterScroll();
}
});
},
animateScrollTop($scrollable: JQuery, scrollTop: number) {
$scrollable.animate({
scrollTop: scrollTop
}, {
queue: 'scroll'
})
.dequeue('scroll');
},
animateScrollLeft($scrollable: JQuery, scrollLeft: number) {
$scrollable.animate({
scrollLeft: scrollLeft
}, {
queue: 'scroll'
})
.dequeue('scroll');
},
scrollbar($scrollable: JQuery, axis: 'x' | 'y'): Scrollbar {
let scrollbarArr: Scrollbar[] = $scrollable.data('scrollbars') || [];
return arrays.find(scrollbarArr, scrollbar => scrollbar.axis === axis);
},
scrollToBottom($scrollable: JQuery, options?: ScrollOptions) {
scrollbars.scrollTop($scrollable, $scrollable[0].scrollHeight - $scrollable[0].offsetHeight, options);
},
/**
* Computes whether the given location is in the viewport of the given $scrollables.
*
* @param $scrollables one or more scrollables to check against
* @returns true if the location is visible in the current viewport of the $scrollables or if $scrollables is null.
*/
isLocationInView(location: { x: number; y: number }, $scrollables: JQuery): boolean {
if (!$scrollables || $scrollables.length === 0) {
return true;
}
return $scrollables.toArray().every(scrollable => {
let scrollableOffsetBounds = graphics.offsetBounds($(scrollable));
return scrollableOffsetBounds.contains(location.x, location.y);
});
},
/**
* Clips the given bounds and removes the parts that are not in the current viewport of the given $scrollables.
*
* @param $scrollables one or more scrollables to check against
* @returns the intersection between the bounds and the viewports of the $scrollables.
* If $scrollables is null or empty, the given bounds are returned without clipping.
*/
intersectViewport(bounds: Rectangle, $scrollables: JQuery): Rectangle {
if (!$scrollables || $scrollables.length === 0) {
return bounds;
}
return $scrollables.toArray().reduce((prevBounds, scrollable) => {
let scrollableOffsetBounds = graphics.offsetBounds($(scrollable));
return prevBounds.intersect(scrollableOffsetBounds);
}, bounds);
},
/**
* Attaches the given handler to each scrollable parent, including $anchor if it is scrollable as well.
* Make sure you remove the handlers when not needed anymore using offScroll.
*/
onScroll($anchor: JQuery, handler: (event: JQuery.ScrollEvent) => void) {
handler['$scrollParents'] = [];
$anchor.scrollParents().each(function() {
let $scrollParent = $(this);
$scrollParent.on('scroll', handler);
handler['$scrollParents'].push($scrollParent);
});
},
offScroll(handler: (event: JQuery.ScrollEvent) => void) {
let $scrollParents: JQuery[] = handler['$scrollParents'];
if (!$scrollParents) {
throw new Error('$scrollParents are not defined');
}
for (let i = 0; i < $scrollParents.length; i++) {
let $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($elem: JQuery) {
if (!$elem.isVisible() || $elem.css('position') === 'fixed') {
return;
}
// getBoundingClientRect used by purpose instead of 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.
let 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($elem: JQuery, timeoutId: number, immediate?: boolean): number {
clearTimeout(timeoutId);
if (immediate) {
scrollbars._unfix($elem);
return;
}
return setTimeout(() => {
scrollbars._unfix($elem);
}, 50);
},
/** @internal */
_unfix($elem: JQuery) {
$elem.css({
position: 'absolute',
left: '',
top: '',
width: '',
height: ''
});
},
/**
* Stores the position of all scrollables that belong to an optional session.
* @param [session] when no session is given, scrollables from all sessions are stored
*/
storeScrollPositions($container: JQuery, session?: Session) {
let $scrollables = scrollbars.getScrollables(session);
if (!$scrollables) {
return;
}
let scrollTop, scrollLeft;
$scrollables.forEach($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 when no session is given, scrollables from all sessions are restored
*/
restoreScrollPositions($container: JQuery, session?: Session) {
let $scrollables = scrollbars.getScrollables(session);
if (!$scrollables) {
return;
}
let scrollTop, scrollLeft;
$scrollables.forEach($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)
scrollbars.update($scrollable);
$.log.isTraceEnabled() && $.log.trace('Restored scroll position for ' + $scrollable.attr('class') + '. Top: ' + scrollTop + '. Left: ' + scrollLeft);
}
});
},
setVisible($scrollable: JQuery, visible: boolean) {
if (!$scrollable || !$scrollable.data('scrollable')) {
return;
}
let scrollbarArr = $scrollable.data('scrollbars');
if (!scrollbarArr) {
return;
}
scrollbarArr.forEach(scrollbar => {
if (scrollbar.rendered) {
scrollbar.$container.setVisible(visible);
}
});
},
opacity($scrollable: JQuery, opacity: number) {
if (!$scrollable || !$scrollable.data('scrollable')) {
return;
}
let scrollbarArr = $scrollable.data('scrollbars');
if (!scrollbarArr) {
return;
}
scrollbarArr.forEach(scrollbar => {
if (scrollbar.rendered) {
scrollbar.$container.css('opacity', opacity);
}
});
},
/** @internal */
_getCompleteChildRowsHeightRecursive(children: ExpandableElement[], getChildren: (element: ExpandableElement) => ExpandableElement[], isExpanded: (element: ExpandableElement) => boolean, defaultChildHeight: number): number {
let height = 0;
children.forEach(child => {
if (child.height) {
height += child.height;
} else {
// fallback for children with unset height
height += defaultChildHeight;
}
if (isExpanded(child) && getChildren(child).length > 0) {
height += scrollbars._getCompleteChildRowsHeightRecursive(getChildren(child), getChildren, isExpanded, defaultChildHeight);
}
});
return height;
},
ensureExpansionVisible