@eclipse-scout/core
Version:
Eclipse Scout runtime
530 lines (460 loc) • 19.9 kB
text/typescript
/*
* Copyright (c) 2010, 2024 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 {events, graphics, Insets, JQueryWheelEvent, scout, ScrollbarEventMap, ScrollbarModel, scrollbars, Widget} from '../index';
import $ from 'jquery';
export class Scrollbar extends Widget implements ScrollbarModel {
declare model: ScrollbarModel;
declare eventMap: ScrollbarEventMap;
declare self: Scrollbar;
axis: 'x' | 'y';
borderless: boolean;
mouseWheelNeedsShift: boolean;
/** thumb body for layout purposes */
$thumb: JQuery;
/** thumb handle */
$thumbHandle: JQuery;
protected _scrollSize: number;
protected _offsetSize: number;
protected _dim: 'Width' | 'Height';
protected _dir: 'left' | 'top';
protected _dirReverse: 'right' | 'bottom';
protected _scrollDir: 'scrollLeft' | 'scrollTop';
protected _thumbClipping: Insets;
protected _onScrollHandler: (event: JQuery.ScrollEvent) => void;
protected _onScrollWheelHandler: (event: JQueryWheelEvent) => boolean;
protected _onScrollbarMouseDownHandler: (event: JQuery.MouseDownEvent) => void;
protected _onTouchStartHandler: (event: JQuery.TouchStartEvent) => void;
protected _onThumbMouseDownHandler: (event: JQuery.MouseDownEvent) => boolean;
protected _onDocumentMousemoveHandler: (event: JQuery.MouseMoveEvent<Document>) => void;
protected _onDocumentMouseUpHandler: (event: JQuery.MouseUpEvent<Document>) => boolean;
protected _onAncestorScrollOrResizeHandler: (event: JQuery.TriggeredEvent) => void;
protected _fixScrollbarHandler: () => void;
protected _unfixScrollbarHandler: () => void;
protected _$thumb: JQuery;
protected _$thumbHandle: JQuery;
protected _$ancestors: JQuery;
constructor() {
super();
this.$container = null;
this.$thumb = null;
this.$thumbHandle = null;
this.axis = 'y';
this.borderless = false;
this.mouseWheelNeedsShift = false;
this._scrollSize = null;
this._offsetSize = null;
this._dim = 'Height';
this._dir = 'top';
this._dirReverse = 'bottom';
this._scrollDir = 'scrollTop';
this._thumbClipping = new Insets(0, 0, 0, 0);
this._onScrollHandler = this._onScroll.bind(this);
this._onScrollWheelHandler = this._onScrollWheel.bind(this);
this._onScrollbarMouseDownHandler = this._onScrollbarMouseDown.bind(this);
this._onTouchStartHandler = this._onTouchStart.bind(this);
this._onThumbMouseDownHandler = this._onThumbMouseDown.bind(this);
this._onDocumentMousemoveHandler = this._onDocumentMousemove.bind(this);
this._onDocumentMouseUpHandler = this._onDocumentMouseUp.bind(this);
this._onAncestorScrollOrResizeHandler = this.update.bind(this);
this._fixScrollbarHandler = this._fixScrollbar.bind(this);
this._unfixScrollbarHandler = this._unfixScrollbar.bind(this);
}
protected override _render() {
this._ensureParentPosition();
// Create scrollbar and thumb
this.$container = this.$parent
.appendDiv('scrollbar')
.addClass(this.axis + '-axis');
this._$thumb = this.$container
.appendDiv('scrollbar-thumb')
.addClass(this.axis + '-axis');
this._$thumbHandle = this._$thumb
.appendDiv('scrollbar-thumb-handle')
.addClass(this.axis + '-axis');
if (this.borderless) {
this.$container.addClass('borderless');
}
// Init helper variables based on axis (x/y)
this._dim = this.axis === 'x' ? 'Width' : 'Height';
this._dir = this.axis === 'x' ? 'left' : 'top';
this._dirReverse = this.axis === 'x' ? 'right' : 'bottom';
this._scrollDir = this.axis === 'x' ? 'scrollLeft' : 'scrollTop';
// Install listeners
let scrollbars = this.$parent.data('scrollbars');
if (!scrollbars) {
throw new Error('Data "scrollbars" missing in ' + graphics.debugOutput(this.$parent) + '\nAncestors: ' + this.ancestorsToString(1));
}
this.$parent
.on('wheel', this._onScrollWheelHandler)
.on('scroll', this._onScrollHandler)
.onPassive('touchstart', this._onTouchStartHandler);
scrollbars.forEach(scrollbar => {
scrollbar.on('scrollStart', this._fixScrollbarHandler);
scrollbar.on('scrollEnd', this._unfixScrollbarHandler);
});
this.$container.on('mousedown', this._onScrollbarMouseDownHandler);
this._$thumb.on('mousedown', this._onThumbMouseDownHandler);
// Scrollbar might be clipped to prevent overlapping an ancestor. In order to reset this clipping the scrollbar needs
// an update whenever a parent div is scrolled ore resized.
this._$ancestors = this.$container.parents('div')
.on('scroll resize', this._onAncestorScrollOrResizeHandler);
}
protected override _remove() {
// Uninstall listeners
this.$parent
.off('wheel', this._onScrollWheelHandler)
.off('scroll', this._onScrollHandler)
.offPassive('touchstart', this._onTouchStartHandler);
let scrollbars = this.$parent.data('scrollbars');
if (scrollbars) {
// Scrollbars may be undefined if this.$parent is detached -> don't fail
scrollbars.forEach(scrollbar => {
scrollbar.off('scrollStart', this._fixScrollbarHandler);
scrollbar.off('scrollEnd', this._unfixScrollbarHandler);
});
}
this.$container.off('mousedown', this._onScrollbarMouseDownHandler);
this._$thumb.off('mousedown', '', this._onThumbMouseDownHandler);
this._$ancestors.off('scroll resize', this._onAncestorScrollOrResizeHandler);
this._$ancestors = null;
super._remove();
}
protected override _renderOnAttach() {
super._renderOnAttach();
this._ensureParentPosition();
}
protected _ensureParentPosition() {
// Container with JS scrollbars must have either relative or absolute position
// otherwise we cannot determine the correct dimension of the scrollbars
if (this.$parent && this.$parent.isAttached()) {
let cssPosition = this.$parent.css('position');
if (!scout.isOneOf(cssPosition, 'relative', 'absolute')) {
this.$parent.css('position', 'relative');
}
}
}
/**
* scroll by "diff" in px (positive and negative)
*/
scroll(diff: number) {
let posOld = Math.max(0, this.$parent[this._scrollDir]());
this._scrollToAbsolutePoint(posOld + diff);
}
/**
* scroll to absolute point (expressed as absolute point in px)
*/
protected _scrollToAbsolutePoint(absolutePoint: number) {
let scrollPos = Math.min(
(this._scrollSize - this._offsetSize + 1), // scrollPos can't be larger than the start of last page. Add +1 because at least chrome has issues to scroll to the very bottom if scrollTop is fractional
Math.max(0, Math.round(absolutePoint))); // scrollPos can't be negative
this.$parent[this._scrollDir](scrollPos);
}
/**
* do not use this internal method (triggered by scroll event)
*/
update() {
if (!this.rendered) {
return;
}
let margin = this.$container['cssMargin' + this.axis.toUpperCase()]();
let border = this.$parent['cssBorderWidth' + this.axis.toUpperCase()]();
let scrollPos = this.$parent[this._scrollDir]();
let scrollLeft = this.$parent.scrollLeft();
let scrollTop = this.$parent.scrollTop();
this.reset();
// parent dimensions
// The browser reports these including the border width, but because child elements are always
// positioned inside the border box, we have to subtract it manually. Otherwise, the scrollbar
// will be positioned too far down/right and will be cut off at the end.
this._offsetSize = this.$parent[0]['offset' + this._dim] - border;
this._scrollSize = this.$parent[0]['scroll' + this._dim] - border;
// calc size and range of thumb
let thumbSize = Math.max(this._offsetSize * this._offsetSize / this._scrollSize - margin, 25);
let thumbRange = this._offsetSize - thumbSize - margin;
// set size of thumb
this._$thumb.css(this._dim.toLowerCase(), thumbSize);
// set location of thumb
let posNew = scrollPos / (this._scrollSize - this._offsetSize) * thumbRange;
this._$thumb.css(this._dir, posNew);
// Add 1px to make sure scroll bar is not shown if width is a floating point value.
// Even if we were using getBoundingClientRect().width to get an exact width,
// it would not help because scroll size is always an integer
let offsetFix = 1;
// show scrollbar
if (this._offsetSize + offsetFix >= this._scrollSize) {
this.$container.css('display', 'none');
} else {
this.$container.css('display', '');
// indicate that thumb movement is not possible
if (this._isContainerTooSmallForThumb()) {
this._$thumb.addClass('container-too-small-for-thumb');
} else {
this._$thumb.removeClass('container-too-small-for-thumb');
}
}
this._clipWhenOverlappingAncestor();
// Position the scrollbar(s)
// Always update both to make sure every scrollbar (x and y) is positioned correctly
this.$container.cssRight(-1 * scrollLeft);
this.$container.cssBottom(-1 * scrollTop);
}
protected _resetClipping() {
// Only reset dimension and position for the secondary axis,
// for the scroll-axis these properties are set during update()
if (this.axis === 'y') {
this._$thumb
.css('width', '')
.css('left', '');
} else {
this._$thumb
.css('height', '')
.css('top', '');
}
this._$thumb.removeClass('clipped-left clipped-right clipped-top clipped-bottom');
this._thumbClipping = new Insets(0, 0, 0, 0);
}
/**
* Make sure scrollbar does not appear outside an ancestor when fixed
*/
protected _clipWhenOverlappingAncestor() {
this._resetClipping();
// Clipping is only needed when scrollbar has a fixed position.
// Otherwise the over-size is handled by 'overflow: hidden;'.
if (this.$container.css('position') === 'fixed') {
let thumbBounds = graphics.offsetBounds(this._$thumb);
let thumbWidth = thumbBounds.width;
let thumbHeight = thumbBounds.height;
let thumbEndX = thumbBounds.x + thumbBounds.width;
let thumbEndY = thumbBounds.y + thumbBounds.height;
let biggestAncestorBeginX = 0;
let biggestAncestorBeginY = 0;
let smallestAncestorEndX = thumbEndX;
let smallestAncestorEndY = thumbEndY;
// Find nearest clip boundaries: It is not necessarily the boundary of the closest ancestor-div in the DOM,
// because ancestor-divs themselves may be scrolled.
this.$container.parents('div').each(function() {
let $ancestor = $(this);
let ancestorBounds = graphics.offsetBounds($ancestor);
if ($ancestor.css('overflow-x') !== 'visible') {
if (ancestorBounds.x > biggestAncestorBeginX) {
biggestAncestorBeginX = ancestorBounds.x;
}
let ancestorEndX = ancestorBounds.x + ancestorBounds.width;
if (ancestorEndX < smallestAncestorEndX) {
smallestAncestorEndX = ancestorEndX;
}
}
if ($ancestor.css('overflow-y') !== 'visible') {
if (ancestorBounds.y > biggestAncestorBeginY) {
biggestAncestorBeginY = ancestorBounds.y;
}
let ancestorEndY = ancestorBounds.y + ancestorBounds.height;
if (ancestorEndY < smallestAncestorEndY) {
smallestAncestorEndY = ancestorEndY;
}
}
});
let clipLeft = 0;
let clipRight = 0;
let clipTop = 0;
let clipBottom = 0;
// clip left
if (biggestAncestorBeginX > thumbBounds.x) {
clipLeft = biggestAncestorBeginX - thumbBounds.x;
thumbWidth -= clipLeft;
this._$thumb
.css('width', thumbWidth)
.css('left', graphics.bounds(this._$thumb).x + clipLeft)
.addClass('clipped-left');
}
// clip top
if (biggestAncestorBeginY > thumbBounds.y) {
clipTop = biggestAncestorBeginY - thumbBounds.y;
thumbHeight -= clipTop;
this._$thumb
.css('height', thumbHeight)
.css('top', graphics.bounds(this._$thumb).y + clipTop)
.addClass('clipped-top');
}
// clip right
if (thumbEndX > smallestAncestorEndX) {
clipRight = thumbEndX - smallestAncestorEndX;
this._$thumb
.css('width', thumbWidth - clipRight)
.addClass('clipped-right');
}
// clip bottom
if (thumbEndY > smallestAncestorEndY) {
clipBottom = thumbEndY - smallestAncestorEndY;
this._$thumb
.css('height', thumbHeight - clipBottom)
.addClass('clipped-bottom');
}
this._thumbClipping = new Insets(clipTop, clipRight, clipBottom, clipLeft);
}
}
/**
* Resets thumb size and scrollbar position to make sure it does not extend the scrollSize
*/
reset() {
this._$thumb.css(this._dim.toLowerCase(), 0);
this.$container.cssRight(0);
this.$container.cssBottom(0);
}
/*
* EVENT HANDLING
*/
protected override _onScroll(event: JQuery.ScrollEvent) {
this.update();
}
protected _onTouchStart(event: JQuery.TouchStartEvent) {
// In hybrid mode scroll bar is moved by the scroll event.
// On a mobile device scroll events are fired delayed so the update will be delayed as well.
// This will lead to flickering and could be prevented by calling fixScrollbar. But unfortunately calling fix will stop the scroll pane from scrolling immediately, at least in Edge.
// In order to reduce the flickering the current approach is to hide the scrollbars while scrolling (only in this specific hybrid touch scrolling)
events.onScrollStartEndDuringTouch(this.$parent, () => {
if (!this.rendered) {
return;
}
this.$container.css('opacity', 0);
}, () => {
if (!this.rendered) {
return;
}
this.$container.css('opacity', '');
});
}
protected _onScrollWheel(event: JQueryWheelEvent): boolean {
if (!this.$container.isVisible()) {
return true; // ignore scroll wheel event if there is no scroll bar visible
}
if (event.ctrlKey) {
return true; // allow ctrl + mousewheel to zoom the page
}
if (this.mouseWheelNeedsShift !== event.shiftKey) {
return true; // only scroll if shift modifier matches
}
let originalEvent = event.originalEvent;
let delta = 0;
if (this.axis === 'y') {
delta = originalEvent.deltaY;
} else if (this.axis === 'x') {
delta = originalEvent.deltaX | originalEvent.deltaY;
}
this.notifyBeforeScroll();
this.scroll(delta);
this.notifyAfterScroll();
return false;
}
protected _onScrollbarMouseDown(event: JQuery.MouseDownEvent) {
this.notifyBeforeScroll();
let clickableAreaSize = this.$container[this._dim.toLowerCase()]();
let offset = this.$container.offset()[this._dir];
let clicked = (this.axis === 'x' ? event.pageX : event.pageY) - offset;
let percentage;
if (this._isContainerTooSmallForThumb()) {
percentage = Math.min(1, Math.max(0, (clicked / clickableAreaSize))); // percentage can't be larger than 1, nor negative
this._scrollToAbsolutePoint((percentage * this._scrollSize) - Math.round(this._offsetSize / 2));
} else { // move the thumb center to clicked point
let thumbSize = this._$thumb['outer' + this._dim](true);
let minPossible = Math.round(thumbSize / 2);
let maxPossible = clickableAreaSize - Math.round(thumbSize / 2);
let rawPercentage = ((clicked - minPossible) * (1 / (maxPossible - minPossible)));
percentage = Math.min(1, Math.max(0, rawPercentage)); // percentage can't be larger than 1, nor negative
this._scrollToAbsolutePoint(percentage * (this._scrollSize - this._offsetSize));
}
this.notifyAfterScroll();
}
protected _onThumbMouseDown(event: JQuery.MouseDownEvent): boolean {
// ignore event if container is too small for thumb movement
if (this._isContainerTooSmallForThumb()) {
return true; // let _onScrollbarMouseDown handle the click event
}
this.notifyBeforeScroll();
// calculate thumbCenterOffset in px (offset from clicked point to thumb center)
let clipped = (this.axis === 'x' ? this._thumbClipping.horizontal() : this._thumbClipping.vertical());
let thumbSize = clipped + this._$thumb['outer' + this._dim](true); // including border, margin and padding
let thumbClippingOffset = (this.axis === 'x' ? this._thumbClipping.left : this._thumbClipping.top);
let thumbCenter = this._$thumb.offset()[this._dir] + Math.floor(thumbSize / 2) - thumbClippingOffset;
let thumbCenterOffset = Math.round((this.axis === 'x' ? event.pageX : event.pageY) - thumbCenter);
this._$thumb.addClass('scrollbar-thumb-move');
this._$thumb.document()
.on('mousemove', {thumbCenterOffset: thumbCenterOffset}, this._onDocumentMousemoveHandler)
.one('mouseup', this._onDocumentMouseUpHandler);
return false;
}
protected _onDocumentMousemove(event: JQuery.MouseMoveEvent<Document>) {
// Scrollbar may be removed in the meantime
if (!this.rendered) {
return;
}
// represents offset in px of clicked point in thumb to the center of the thumb (positive and negative)
let thumbCenterOffset = event.data.thumbCenterOffset as number;
let clipped = (this.axis === 'x' ? this._thumbClipping.horizontal() : this._thumbClipping.vertical());
let thumbSize = clipped + this._$thumb['outer' + this._dim](true); // including border, margin and padding
let size = this.$container[this._dim.toLowerCase()]() - thumbSize; // size of div excluding margin/padding/border
let offset = this.$container.offset()[this._dir] + (thumbSize / 2);
let movedTo = Math.min(
size,
Math.max(0, (this.axis === 'x' ? event.pageX : event.pageY) - offset - thumbCenterOffset));
let percentage = Math.min(
1, // percentage can't be larger than 1
Math.max(0, (movedTo / size))); // percentage can't be negative
let posNew = (percentage * (this._scrollSize - this._offsetSize));
this._scrollToAbsolutePoint(posNew);
}
protected _onDocumentMouseUp(event: JQuery.MouseUpEvent<Document>): boolean {
let $document = $(event.currentTarget);
$document.off('mousemove', this._onDocumentMousemoveHandler);
if (this.rendered) {
this._$thumb.removeClass('scrollbar-thumb-move');
}
this.notifyAfterScroll();
return false;
}
notifyBeforeScroll() {
this.trigger('scrollStart');
}
notifyAfterScroll() {
this.trigger('scrollEnd');
}
/*
* Fix Scrollbar
*/
/**
* Sets the position to fixed and updates left and top position
* (This is necessary to prevent flickering in IE)
*/
protected _fixScrollbar() {
scrollbars.fix(this.$container);
this.update();
}
/**
* Reverts the changes made by _fixScrollbar
*/
protected _unfixScrollbar() {
// true = do it immediately without a timeout.
// This is important because scrollTop may be set during layout but before the element is positioned correctly (e.g. popup)
// which could have the effect that the scroll bar is drown outside the widget
scrollbars.unfix(this.$container, null, true);
this.update();
}
/*
* INTERNAL METHODS
*/
/**
* If the thumb gets bigger than its container this method will return true, otherwise false
*/
protected _isContainerTooSmallForThumb(): boolean {
let thumbSize: number = this._$thumb['outer' + this._dim](true);
let thumbMovableAreaSize: number = this.$container[this._dim.toLowerCase()]();
return thumbSize >= thumbMovableAreaSize;
}
}