UNPKG

@eclipse-scout/core

Version:
530 lines (460 loc) 19.9 kB
/* * 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; } }