UNPKG

@jupyterlab/ui-components

Version:

JupyterLab - UI components written in React

1,198 lines 62.1 kB
/* * Copyright (c) Jupyter Development Team. * Distributed under the terms of the Modified BSD License. */ import { ArrayExt } from '@lumino/algorithm'; import { PromiseDelegate } from '@lumino/coreutils'; import { MessageLoop } from '@lumino/messaging'; import { Throttler } from '@lumino/polling'; import { Signal } from '@lumino/signaling'; import { PanelLayout, Widget } from '@lumino/widgets'; /** * For how long after the scroll request should the target position * be corrected to account for resize of other widgets? * * The time is given in milliseconds. */ const MAXIMUM_TIME_REMAINING = 100; /* * Feature detection * * Ref: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners */ let passiveIfSupported = false; try { // @ts-expect-error unknown signature window.addEventListener('test', null, Object.defineProperty({}, 'passive', { get: function () { passiveIfSupported = { passive: true }; } })); } catch (err) { // pass no-op } /** * Windowed list abstract model. */ export class WindowedListModel { /** * Constructor * * @param options Constructor options */ constructor(options = {}) { var _a, _b, _c, _d, _e, _f; /** * The overlap threshold used to decide whether to scroll down to an item * below the viewport (smart mode). If the item overlap with the viewport * is greater or equal this threshold the item is considered sufficiently * visible and will not be scrolled to. The value is the number of pixels * in overlap if greater than one, or otherwise a fraction of item height. * By default the item is scrolled to if not full visible in the viewport. */ this.scrollDownThreshold = 1; /** * The underlap threshold used to decide whether to scroll up to an item * above the viewport (smart mode). If the item part outside the viewport * (underlap) is greater than this threshold then the item is considered * not sufficiently visible and will be scrolled to. * The value is the number of pixels in underlap if greater than one, or * otherwise a fraction of the item height. * By default the item is scrolled to if not full visible in the viewport. */ this.scrollUpThreshold = 0; /** * Top padding of the the outer window node. */ this.paddingTop = 0; /** * Default widget size estimation * * @deprecated we always use {@link estimateWidgetSize} */ this._estimatedWidgetSize = WindowedList.DEFAULT_WIDGET_SIZE; this._stateChanged = new Signal(this); this._currentWindow = [-1, -1, -1, -1]; this._height = 0; this._isDisposed = false; this._itemsList = null; this._measuredAllUntilIndex = -1; this._overscanCount = 1; this._scrollOffset = 0; this._widgetCount = 0; this._widgetSizers = []; this._windowingActive = true; this._widgetCount = (_c = (_b = (_a = options.itemsList) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : options.count) !== null && _c !== void 0 ? _c : 0; this._overscanCount = (_d = options.overscanCount) !== null && _d !== void 0 ? _d : 1; this._windowingActive = (_e = options.windowingActive) !== null && _e !== void 0 ? _e : true; this.itemsList = (_f = options.itemsList) !== null && _f !== void 0 ? _f : null; } /** * List widget height */ get height() { return this._height; } set height(h) { this._height = h; } /** * Test whether the model is disposed. */ get isDisposed() { return this._isDisposed; } /** * Items list to be rendered */ get itemsList() { return this._itemsList; } set itemsList(v) { var _a, _b, _c; if (this._itemsList !== v) { if (this._itemsList) { this._itemsList.changed.disconnect(this.onListChanged, this); } const oldValue = this._itemsList; this._itemsList = v; if (this._itemsList) { this._itemsList.changed.connect(this.onListChanged, this); } else { this._widgetCount = 0; } this._stateChanged.emit({ name: 'list', newValue: this._itemsList, oldValue }); this._stateChanged.emit({ name: 'count', newValue: (_b = (_a = this._itemsList) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0, oldValue: (_c = oldValue === null || oldValue === void 0 ? void 0 : oldValue.length) !== null && _c !== void 0 ? _c : 0 }); } } /** * Number of widgets to render in addition to those * visible in the viewport. */ get overscanCount() { return this._overscanCount; } set overscanCount(newValue) { if (newValue >= 1) { if (this._overscanCount !== newValue) { const oldValue = this._overscanCount; this._overscanCount = newValue; this._stateChanged.emit({ name: 'overscanCount', newValue, oldValue }); } } else { console.error(`Forbidden non-positive overscan count: got ${newValue}`); } } /** * Viewport scroll offset. */ get scrollOffset() { return this._scrollOffset; } set scrollOffset(offset) { this._scrollOffset = offset; } /** * Total number of widgets in the list */ get widgetCount() { return this._itemsList ? this._itemsList.length : this._widgetCount; } set widgetCount(newValue) { if (this.itemsList) { console.error('It is not allow to change the widgets count of a windowed list if a items list is used.'); return; } if (newValue >= 0) { if (this._widgetCount !== newValue) { const oldValue = this._widgetCount; this._widgetCount = newValue; this._stateChanged.emit({ name: 'count', newValue, oldValue }); } } else { console.error(`Forbidden negative widget count: got ${newValue}`); } } /** * Whether windowing is active or not. * * This is true by default. */ get windowingActive() { return this._windowingActive; } set windowingActive(newValue) { if (newValue !== this._windowingActive) { const oldValue = this._windowingActive; this._windowingActive = newValue; this._currentWindow = [-1, -1, -1, -1]; this._measuredAllUntilIndex = -1; this._widgetSizers = []; this._stateChanged.emit({ name: 'windowingActive', newValue, oldValue }); } } /** * A signal emitted when any model state changes. */ get stateChanged() { return this._stateChanged; } /** * Dispose the model. */ dispose() { if (this.isDisposed) { return; } this._isDisposed = true; Signal.clearData(this); } /** * Get the total list size. * * @returns Total estimated size */ getEstimatedTotalSize() { let totalSizeOfInitialItems = 0; if (this._measuredAllUntilIndex >= this.widgetCount) { this._measuredAllUntilIndex = this.widgetCount - 1; } // These items are all measured already if (this._measuredAllUntilIndex >= 0) { const itemMetadata = this._widgetSizers[this._measuredAllUntilIndex]; totalSizeOfInitialItems = itemMetadata.offset + itemMetadata.size; } // These items might have measurements, but some will be missing let totalSizeOfRemainingItems = 0; for (let i = this._measuredAllUntilIndex + 1; i < this.widgetCount; i++) { const sizer = this._widgetSizers[i]; totalSizeOfRemainingItems += (sizer === null || sizer === void 0 ? void 0 : sizer.measured) ? sizer.size : this.estimateWidgetSize(i); } return totalSizeOfInitialItems + totalSizeOfRemainingItems; } /** * Get the scroll offset to display an item in the viewport. * * By default, the list will scroll as little as possible to ensure the item is fully visible (`auto`). * You can control the alignment of the item though by specifying a second alignment parameter. * Acceptable values are: * * auto - Automatically align with the top or bottom minimising the amount scrolled, * If `alignPreference` is given, follow such preferred alignment. * If item is smaller than the viewport and fully visible, do not scroll at all. * smart - If the item is significantly visible, don't scroll at all (regardless of whether it fits in the viewport). * If the item is less than one viewport away, scroll so that it becomes fully visible (following the `auto` heuristics). * If the item is more than one viewport away, scroll so that it is centered within the viewport (`center` if smaller than viewport, `top-center` otherwise). * center - Align the middle of the item with the middle of the viewport (it only works well for items smaller than the viewport). * top-center - Align the top of the item with the middle of the viewport (works well for items larger than the viewport). * end - Align the bottom of the item to the bottom of the list. * start - Align the top of item to the top of the list. * * An item is considered significantly visible if: * - it overlaps with the viewport by the amount specified by `scrollDownThreshold` when below the viewport * - it exceeds the viewport by the amount less than specified by `scrollUpThreshold` when above the viewport. * * @param index Item index * @param align Where to align the item in the viewport * @param margin The proportion of viewport to add when aligning with the top/bottom of the list. * @param precomputed Precomputed values to use when windowing is disabled. * @param alignPreference Allows to override the alignment of item when the `auto` heuristic decides that the item needs to be scrolled into view. * @returns The needed scroll offset */ getOffsetForIndexAndAlignment(index, align = 'auto', margin = 0, precomputed, alignPreference) { const boundedMargin = Math.min(Math.max(0.0, margin), 1.0); const size = this._height; const itemMetadata = precomputed ? precomputed.itemMetadata : this._getItemMetadata(index); const scrollDownThreshold = this.scrollDownThreshold <= 1 ? itemMetadata.size * this.scrollDownThreshold : this.scrollDownThreshold; const scrollUpThreshold = this.scrollUpThreshold <= 1 ? itemMetadata.size * this.scrollUpThreshold : this.scrollUpThreshold; // When pre-computed values are not available (we are in windowing mode), // `getEstimatedTotalSize` is called after ItemMetadata is computed // to ensure it reflects actual measurements instead of just estimates. const estimatedTotalSize = precomputed ? precomputed.totalSize : this.getEstimatedTotalSize(); const topOffset = Math.max(0, Math.min(estimatedTotalSize - size, itemMetadata.offset)); const bottomOffset = Math.max(0, itemMetadata.offset - size + itemMetadata.size); // Current offset (+/- padding) is the top edge of the viewport. const currentOffset = precomputed ? precomputed.currentOffset : this._scrollOffset; const viewportPadding = this._windowingActive ? this.paddingTop : 0; const itemTop = itemMetadata.offset; const itemBottom = itemMetadata.offset + itemMetadata.size; const bottomEdge = currentOffset - viewportPadding + size; const topEdge = currentOffset - viewportPadding; const crossingBottomEdge = bottomEdge > itemTop && bottomEdge < itemBottom; const crossingTopEdge = topEdge > itemTop && topEdge < itemBottom; const isFullyWithinViewport = bottomEdge > itemBottom && topEdge < itemTop; if (align === 'smart') { const edgeLessThanOneViewportAway = currentOffset >= bottomOffset - size && currentOffset <= topOffset + size; const visiblePartBottom = bottomEdge - itemTop; const hiddenPartTop = topEdge - itemTop; if (isFullyWithinViewport || (crossingBottomEdge && visiblePartBottom >= scrollDownThreshold) || (crossingTopEdge && hiddenPartTop < scrollUpThreshold)) { return currentOffset; } else if (edgeLessThanOneViewportAway) { // Possibly less than one viewport away, scroll so that it becomes visible (including the margin) align = 'auto'; } else { // More than one viewport away, scroll so that it is centered within the list: // - if the item is smaller than viewport align the middle of the item with the middle of the viewport // - if the item is larger than viewport align the top of the item with the middle of the viewport if (itemMetadata.size > size) { align = 'top-center'; } else { align = 'center'; } } } if (align === 'auto') { if (isFullyWithinViewport) { // No need to change the position, return the current offset. return currentOffset; } else if (alignPreference !== undefined) { align = alignPreference; } else if (crossingBottomEdge || bottomEdge <= itemBottom) { align = 'end'; } else { align = 'start'; } } switch (align) { case 'start': // Align item top to the top edge. return Math.max(0, topOffset - boundedMargin * size) + viewportPadding; case 'end': // Align item bottom to the bottom edge. return bottomOffset + boundedMargin * size + viewportPadding; case 'center': // Align item centre to the middle of the viewport return bottomOffset + (topOffset - bottomOffset) / 2; case 'top-center': // Align item top to the middle of the viewport return topOffset - size / 2; } } /** * Compute the items range to display. * * It returns ``null`` if the range does not need to be updated. * * @returns The current items range to display */ getRangeToRender() { let newWindowIndex = [ 0, Math.max(this.widgetCount - 1, -1), 0, Math.max(this.widgetCount - 1, -1) ]; const previousLastMeasuredIndex = this._measuredAllUntilIndex; if (this.windowingActive) { newWindowIndex = this._getRangeToRender(); } const [startIndex, stopIndex] = newWindowIndex; if (previousLastMeasuredIndex <= stopIndex || this._currentWindow[0] !== startIndex || this._currentWindow[1] !== stopIndex) { this._currentWindow = newWindowIndex; return newWindowIndex; } return null; } /** * Return the viewport top position and height for range spanning from * ``startIndex`` to ``stopIndex``. * * @param startIndex First item in viewport index * @param stopIndex Last item in viewport index * @returns The viewport top position and its height */ getSpan(startIndex, stopIndex) { const startSizer = this._getItemMetadata(startIndex); const top = startSizer.offset; const stopSizer = this._getItemMetadata(stopIndex); const height = stopSizer.offset - startSizer.offset + stopSizer.size; return [top, height]; } /** * WindowedListModel caches offsets and measurements for each index for performance purposes. * This method clears that cached data for all items after (and including) the specified index. * * The list will automatically re-render after the index is reset. * * @param index */ resetAfterIndex(index) { const oldValue = this._measuredAllUntilIndex; this._measuredAllUntilIndex = Math.min(index, this._measuredAllUntilIndex); // Because `resetAfterIndex` is always called after an operation modifying // the list of widget sizers, we need to ensure that offsets are correct; // e.g. removing a cell would make the offsets of all following cells too high. // The simplest way to "heal" the offsets is to recompute them all. for (const [i, sizer] of this._widgetSizers.entries()) { if (i === 0) { continue; } const previous = this._widgetSizers[i - 1]; sizer.offset = previous.offset + previous.size; } if (this._measuredAllUntilIndex !== oldValue) { this._stateChanged.emit({ name: 'index', newValue: index, oldValue }); } } /** * Update item sizes. * * This should be called when the real item sizes has been * measured. * * @param sizes New sizes per item index * @returns Whether some sizes changed or not */ setWidgetSize(sizes) { if (this._windowingActive || this._currentWindow[0] >= 0) { let minIndex = Infinity; let measuredAllItemsUntil = -1; let offsetDelta = 0; let allPreviousMeasured = true; const sizesMap = new Map(sizes.map(i => [i.index, i.size])); const highestIndex = Math.max(...sizesMap.keys()); // add sizers at the end if needed const entries = [ ...this._widgetSizers.entries() ]; for (let i = this._widgetSizers.length; i <= highestIndex; i++) { entries.push([i, null]); } for (let [index, sizer] of entries) { const measuredSize = sizesMap.get(index); let itemDelta = 0; const hadSizer = !!sizer; if (!sizer) { const previous = this._widgetSizers[index - 1]; const newSizer = { offset: previous ? previous.offset + previous.size : 0, size: measuredSize !== undefined ? measuredSize : this.estimateWidgetSize(index), measured: measuredSize !== undefined }; this._widgetSizers[index] = newSizer; sizer = newSizer; } if (measuredSize !== undefined) { // If the we learned about the size, update it and compute atomic offset for following item (`itemDelta`) if (sizer.size != measuredSize) { itemDelta = measuredSize - sizer.size; sizer.size = measuredSize; minIndex = Math.min(minIndex, index); } // Always set the flag in case the size estimator provides perfect result sizer.measured = true; } // If all items so far have actual size measurements... if (allPreviousMeasured) { if (sizer.measured) { // and this item has a size measurement, we can say that // all items until now have measurements: measuredAllItemsUntil = index; } else { allPreviousMeasured = false; } } if (hadSizer && offsetDelta !== 0) { // Adjust offsets for all items where it is needed sizer.offset += offsetDelta; } // Keep track of the overall change in offset that will need to be applied to following items offsetDelta += itemDelta; } if (measuredAllItemsUntil !== -1) { this._measuredAllUntilIndex = measuredAllItemsUntil; } // If some sizes changed if (minIndex !== Infinity) { return true; } } return false; } /** * Callback on list changes * * @param list List items * @param changes List change */ onListChanged(list, changes) { switch (changes.type) { case 'add': this._widgetSizers.splice(changes.newIndex, 0, ...new Array(changes.newValues.length).fill(undefined).map((_, i) => { return { offset: 0, size: this.estimateWidgetSize(i) }; })); this.resetAfterIndex(changes.newIndex - 1); break; case 'move': ArrayExt.move(this._widgetSizers, changes.oldIndex, changes.newIndex); this.resetAfterIndex(Math.min(changes.newIndex, changes.oldIndex) - 1); break; case 'remove': this._widgetSizers.splice(changes.oldIndex, changes.oldValues.length); this.resetAfterIndex(changes.oldIndex - 1); break; case 'set': this.resetAfterIndex(changes.newIndex - 1); break; case 'clear': this._widgetSizers.length = 0; this.resetAfterIndex(-1); break; } } _getItemMetadata(index) { var _a, _b; if (index > this._measuredAllUntilIndex) { let offset = 0; if (this._measuredAllUntilIndex >= 0) { const itemMetadata = this._widgetSizers[this._measuredAllUntilIndex]; offset = itemMetadata.offset + itemMetadata.size; } for (let i = this._measuredAllUntilIndex + 1; i <= index; i++) { let size = ((_a = this._widgetSizers[i]) === null || _a === void 0 ? void 0 : _a.measured) ? this._widgetSizers[i].size : this.estimateWidgetSize(i); this._widgetSizers[i] = { offset, size, measured: (_b = this._widgetSizers[i]) === null || _b === void 0 ? void 0 : _b.measured }; offset += size; } // Because the loop above updates estimated sizes, // we need to fix (heal) offsets of the remaining items. for (let i = index + 1; i < this._widgetSizers.length; i++) { const sizer = this._widgetSizers[i]; const previous = this._widgetSizers[i - 1]; sizer.offset = previous.offset + previous.size; } } for (let i = 0; i <= this._measuredAllUntilIndex; i++) { const sizer = this._widgetSizers[i]; if (i === 0) { if (sizer.offset !== 0) { throw new Error('First offset is not null'); } } else { const previous = this._widgetSizers[i - 1]; if (sizer.offset !== previous.offset + previous.size) { throw new Error(`Sizer ${i} has incorrect offset.`); } } } return this._widgetSizers[index]; } _findNearestItem(offset) { const lastContinouslyMeasuredItemOffset = this._measuredAllUntilIndex > 0 ? this._widgetSizers[this._measuredAllUntilIndex].offset : 0; if (lastContinouslyMeasuredItemOffset >= offset) { // If we've already measured items within this range just use a binary search as it's faster. return this._findNearestItemBinarySearch(this._measuredAllUntilIndex, 0, offset); } else { // If we haven't yet measured this high, fallback to an exponential search with an inner binary search. // The exponential search avoids pre-computing sizes for the full set of items as a binary search would. // The overall complexity for this approach is O(log n). return this._findNearestItemExponentialSearch(Math.max(0, this._measuredAllUntilIndex), offset); } } _findNearestItemBinarySearch(high, low, offset) { while (low <= high) { const middle = low + Math.floor((high - low) / 2); const currentOffset = this._getItemMetadata(middle).offset; if (currentOffset === offset) { return middle; } else if (currentOffset < offset) { low = middle + 1; } else if (currentOffset > offset) { high = middle - 1; } } if (low > 0) { return low - 1; } else { return 0; } } _findNearestItemExponentialSearch(index, offset) { let interval = 1; while (index < this.widgetCount && this._getItemMetadata(index).offset < offset) { index += interval; interval *= 2; } return this._findNearestItemBinarySearch(Math.min(index, this.widgetCount - 1), Math.floor(index / 2), offset); } _getRangeToRender() { const widgetCount = this.widgetCount; if (widgetCount === 0) { return [-1, -1, -1, -1]; } const startIndex = this._getStartIndexForOffset(this._scrollOffset); const stopIndex = this._getStopIndexForStartIndex(startIndex, this._scrollOffset); const overscanBackward = Math.max(1, this.overscanCount); const overscanForward = Math.max(1, this.overscanCount); return [ Math.max(0, startIndex - overscanBackward), Math.max(0, Math.min(widgetCount - 1, stopIndex + overscanForward)), startIndex, stopIndex ]; } _getStartIndexForOffset(offset) { return this._findNearestItem(offset); } _getStopIndexForStartIndex(startIndex, scrollOffset) { const size = this._height; const itemMetadata = this._getItemMetadata(startIndex); const maxOffset = scrollOffset + size; let offset = itemMetadata.offset + itemMetadata.size; let stopIndex = startIndex; while (stopIndex < this.widgetCount - 1 && offset < maxOffset) { stopIndex++; offset += this._getItemMetadata(stopIndex).size; } return stopIndex; } } /** * Windowed list widget */ export class WindowedList extends Widget { /** * Constructor * * @param options Constructor options */ constructor(options) { var _a, _b; const renderer = (_a = options.renderer) !== null && _a !== void 0 ? _a : WindowedList.defaultRenderer; const node = document.createElement('div'); node.className = 'jp-WindowedPanel'; const scrollbarElement = node.appendChild(document.createElement('div')); scrollbarElement.classList.add('jp-WindowedPanel-scrollbar'); const indicator = scrollbarElement.appendChild(renderer.createScrollbarViewportIndicator ? renderer.createScrollbarViewportIndicator() : WindowedList.defaultRenderer.createScrollbarViewportIndicator()); indicator.classList.add('jp-WindowedPanel-scrollbar-viewportIndicator'); const list = scrollbarElement.appendChild(renderer.createScrollbar()); list.classList.add('jp-WindowedPanel-scrollbar-content'); const outerElement = node.appendChild(renderer.createOuter()); outerElement.classList.add('jp-WindowedPanel-outer'); const innerElement = outerElement.appendChild(document.createElement('div')); innerElement.className = 'jp-WindowedPanel-inner'; const viewport = innerElement.appendChild(renderer.createViewport()); viewport.classList.add('jp-WindowedPanel-viewport'); super({ node }); /** * A signal that emits the index when the virtual scrollbar jumps to an item. */ this.jumped = new Signal(this); this._scrollbarItems = {}; this._viewportPaddingTop = 0; this._viewportPaddingBottom = 0; this._needsUpdate = false; this._requiresTotalSizeUpdate = false; this._timerToClearScrollStatus = null; super.layout = (_b = options.layout) !== null && _b !== void 0 ? _b : new WindowedLayout(); this.renderer = renderer; this._viewportIndicator = indicator; this._innerElement = innerElement; this._isScrolling = null; this._outerElement = outerElement; this._itemsResizeObserver = null; this._scrollbarElement = scrollbarElement; this._scrollToItem = null; this._scrollRepaint = null; this._scrollUpdateWasRequested = false; this._updater = new Throttler(() => this.update(), 50); this._viewModel = options.model; this._viewport = viewport; if (options.scrollbar) { node.classList.add('jp-mod-virtual-scrollbar'); } this.viewModel.stateChanged.connect(this.onStateChanged, this); } /** * Whether the parent is hidden or not. * * This should be set externally if a container is hidden to * stop updating the widget size when hidden. */ get isParentHidden() { return this._isParentHidden; } set isParentHidden(v) { this._isParentHidden = v; } /** * Widget layout */ get layout() { return super.layout; } /** * The outer container of the windowed list. */ get outerNode() { return this._outerElement; } /** * Viewport */ get viewportNode() { return this._viewport; } /** * Flag to enable virtual scrollbar. */ get scrollbar() { return this.node.classList.contains('jp-mod-virtual-scrollbar'); } set scrollbar(enabled) { if (enabled) { this.node.classList.add('jp-mod-virtual-scrollbar'); } else { this.node.classList.remove('jp-mod-virtual-scrollbar'); } this._adjustDimensionsForScrollbar(); this.update(); } /** * Windowed list view model */ get viewModel() { return this._viewModel; } /** * Dispose the windowed list. */ dispose() { this._updater.dispose(); super.dispose(); } /** * Callback on event. * * @param event Event */ handleEvent(event) { switch (event.type) { case 'pointerdown': this._evtPointerDown(event); // Stop propagation of this event; a `mousedown` event will still // be automatically dispatched and handled by the parent notebook // (which will close any open context menu, etc.) event.stopPropagation(); break; case 'scrollend': this._onScrollEnd(); break; case 'scroll': this.onScroll(event); break; } } /** * Scroll to the specified offset `scrollTop`. * * @param scrollOffset Offset to scroll * * @deprecated since v4 This is an internal helper. Prefer calling `scrollToItem`. */ scrollTo(scrollOffset) { if (!this.viewModel.windowingActive) { this._outerElement.scrollTo({ top: scrollOffset }); return; } scrollOffset = Math.max(0, scrollOffset); if (scrollOffset !== this.viewModel.scrollOffset) { this.viewModel.scrollOffset = scrollOffset; this._scrollUpdateWasRequested = true; this.update(); } } /** * Scroll to the specified item. * * By default, the list will scroll as little as possible to ensure the item is fully visible (`auto`). * You can control the alignment of the item though by specifying a second alignment parameter. * Acceptable values are: * * auto - Automatically align with the top or bottom minimising the amount scrolled, * If `alignPreference` is given, follow such preferred alignment. * If item is smaller than the viewport and fully visible, do not scroll at all. * smart - If the item is significantly visible, don't scroll at all (regardless of whether it fits in the viewport). * If the item is less than one viewport away, scroll so that it becomes fully visible (following the `auto` heuristics). * If the item is more than one viewport away, scroll so that it is centered within the viewport (`center` if smaller than viewport, `top-center` otherwise). * center - Align the middle of the item with the middle of the viewport (it only works well for items smaller than the viewport). * top-center - Align the top of the item with the middle of the viewport (works well for items larger than the viewport). * end - Align the bottom of the item to the bottom of the list. * start - Align the top of item to the top of the list. * * @param index Item index to scroll to * @param align Type of alignment * @param margin In 'smart' mode the viewport proportion to add * @param alignPreference Allows to override the alignment of item when the `auto` heuristic decides that the item needs to be scrolled into view. */ scrollToItem(index, align = 'auto', margin = 0.25, alignPreference) { if (!this._isScrolling || this._scrollToItem === null || this._scrollToItem[0] !== index || this._scrollToItem[1] !== align) { if (this._isScrolling) { this._isScrolling.reject('Scrolling to a new item is requested.'); } this._isScrolling = new PromiseDelegate(); // Catch the internal reject, as otherwise this will // result in an unhandled promise rejection in test. this._isScrolling.promise.catch(console.debug); } this._scrollToItem = [index, align, margin, alignPreference]; this._resetScrollToItem(); let precomputed = undefined; if (!this.viewModel.windowingActive) { const item = this._innerElement.querySelector(`[data-windowed-list-index="${index}"]`); if (!item || !(item instanceof HTMLElement)) { // Note: this can happen when scroll is requested when a cell is getting added console.debug(`Element with index ${index} not found`); return Promise.resolve(); } precomputed = { totalSize: this._outerElement.scrollHeight, itemMetadata: { offset: item.offsetTop, size: item.clientHeight }, currentOffset: this._outerElement.scrollTop }; } this.scrollTo(this.viewModel.getOffsetForIndexAndAlignment(Math.max(0, Math.min(index, this.viewModel.widgetCount - 1)), align, margin, precomputed, alignPreference)); return this._isScrolling.promise; } /** * A message handler invoked on an `'after-attach'` message. */ onAfterAttach(msg) { super.onAfterAttach(msg); if (this.viewModel.windowingActive) { this._applyWindowingStyles(); } else { this._applyNoWindowingStyles(); } this._addListeners(); this.viewModel.height = this.node.getBoundingClientRect().height; const viewportStyle = window.getComputedStyle(this._viewport); this.viewModel.paddingTop = parseFloat(viewportStyle.paddingTop); this._viewportPaddingTop = this.viewModel.paddingTop; this._viewportPaddingBottom = parseFloat(viewportStyle.paddingBottom); this._scrollbarElement.addEventListener('pointerdown', this); this._outerElement.addEventListener('scrollend', this); } /** * A message handler invoked on an `'before-detach'` message. */ onBeforeDetach(msg) { this._removeListeners(); this._scrollbarElement.removeEventListener('pointerdown', this); this._outerElement.removeEventListener('scrollend', this); super.onBeforeDetach(msg); } /** * Callback on scroll event * * @param event Scroll event */ onScroll(event) { const { clientHeight, scrollHeight, scrollTop } = event.currentTarget; // TBC Firefox is emitting two events one with 1px diff then the _real_ scroll if (!this._scrollUpdateWasRequested && Math.abs(this.viewModel.scrollOffset - scrollTop) > 1) { // Test if the scroll event is jumping to the list bottom // if (Math.abs(scrollHeight - clientHeight - scrollTop) < 1) { // // FIXME Does not work because it happens in multiple segments in between which the sizing is changing // // due to widget resizing. A possible fix would be to keep track of the "old" scrollHeight - clientHeight // // up to some quiet activity. // this.scrollToItem(this.widgetCount, 'end'); // } else { const scrollOffset = Math.max(0, Math.min(scrollTop, scrollHeight - clientHeight)); this.viewModel.scrollOffset = scrollOffset; this._scrollUpdateWasRequested = false; if (this._viewport.dataset.isScrolling != 'true') { this._viewport.dataset.isScrolling = 'true'; } if (this._timerToClearScrollStatus) { window.clearTimeout(this._timerToClearScrollStatus); } // TODO: remove once `scrollend` event is supported by Safari this._timerToClearScrollStatus = window.setTimeout(() => { this._onScrollEnd(); }, 750); this.update(); // } } } /** * A message handler invoked on an `'resize-request'` message. */ onResize(msg) { const previousHeight = this.viewModel.height; this.viewModel.height = msg.height >= 0 ? msg.height : this.node.getBoundingClientRect().height; if (this.viewModel.height !== previousHeight) { void this._updater.invoke(); } super.onResize(msg); void this._updater.invoke(); } /** * Callback on view model change * * @param model Windowed list model * @param changes Change */ onStateChanged(model, changes) { switch (changes.name) { case 'windowingActive': this._removeListeners(); if (this.viewModel.windowingActive) { this._applyWindowingStyles(); this.onScroll({ currentTarget: this.node }); this._addListeners(); // Bail as onScroll will trigger update return; } else { this._applyNoWindowingStyles(); this._addListeners(); } break; case 'estimatedWidgetSize': // This only impact the container height and should not // impact the elements in the viewport. this._updateTotalSize(); return; } this.update(); } /** * A message handler invoked on an `'update-request'` message. * * #### Notes * The default implementation of this handler is a no-op. */ onUpdateRequest(msg) { if (this.viewModel.windowingActive) { // Throttle update request if (this._scrollRepaint === null) { this._needsUpdate = false; this._scrollRepaint = window.requestAnimationFrame(() => { this._scrollRepaint = null; this._update(); if (this._needsUpdate) { this.update(); } }); } else { // Force re rendering if some changes happen during rendering. this._needsUpdate = true; } } else { this._update(); } } /* * Hide the native scrollbar if necessary and update dimensions */ _adjustDimensionsForScrollbar() { const outer = this._outerElement; const scrollbar = this._scrollbarElement; if (this.scrollbar) { // Query DOM let outerScrollbarWidth = outer.offsetWidth - outer.clientWidth; // Update DOM // 1) The native scrollbar is hidden by shifting it out of view. if (outerScrollbarWidth == 0) { // If the scrollbar width is zero, one of the following is true: // - (a) the content is not overflowing // - (b) the browser uses overlay scrollbars // In (b) the overlay scrollbars could show up even even if // occluded by a child element; to prevent this resulting in // double scrollbar we shift the content by an arbitrary offset. outerScrollbarWidth = 1000; outer.style.paddingRight = `${outerScrollbarWidth}px`; outer.style.boxSizing = 'border-box'; } else { outer.style.paddingRight = '0'; } outer.style.width = `calc(100% + ${outerScrollbarWidth}px)`; // 2) The inner window is shrank to accommodate the virtual scrollbar this._innerElement.style.marginRight = `${scrollbar.offsetWidth}px`; } else { // Reset all styles that may have been touched. outer.style.width = '100%'; this._innerElement.style.marginRight = ''; outer.style.paddingRight = '0'; outer.style.boxSizing = ''; } } /** * Add listeners for viewport, contents and the virtual scrollbar. */ _addListeners() { if (this.viewModel.windowingActive) { if (!this._itemsResizeObserver) { this._itemsResizeObserver = new ResizeObserver(this._onItemResize.bind(this)); } for (const widget of this.layout.widgets) { this._itemsResizeObserver.observe(widget.node); widget.disposed.connect(() => { var _a; return (_a = this._itemsResizeObserver) === null || _a === void 0 ? void 0 : _a.unobserve(widget.node); }); } this._outerElement.addEventListener('scroll', this, passiveIfSupported); this._scrollbarResizeObserver = new ResizeObserver(this._adjustDimensionsForScrollbar.bind(this)); this._scrollbarResizeObserver.observe(this._outerElement); this._scrollbarResizeObserver.observe(this._scrollbarElement); } else { if (!this._areaResizeObserver) { this._areaResizeObserver = new ResizeObserver(this._onAreaResize.bind(this)); this._areaResizeObserver.observe(this._innerElement); } } } /** * Turn off windowing related styles in the viewport. */ _applyNoWindowingStyles() { this._viewport.style.position = 'relative'; this._viewport.style.contain = ''; this._viewport.style.top = '0px'; this._viewport.style.minHeight = ''; this._innerElement.style.height = ''; } /** * Turn on windowing related styles in the viewport. */ _applyWindowingStyles() { this._viewport.style.position = 'absolute'; this._viewport.style.contain = 'layout'; } /** * Remove listeners for viewport and contents (but not the virtual scrollbar). */ _removeListeners() { var _a, _b, _c; this._outerElement.removeEventListener('scroll', this); (_a = this._areaResizeObserver) === null || _a === void 0 ? void 0 : _a.disconnect(); this._areaResizeObserver = null; (_b = this._itemsResizeObserver) === null || _b === void 0 ? void 0 : _b.disconnect(); this._itemsResizeObserver = null; (_c = this._scrollbarResizeObserver) === null || _c === void 0 ? void 0 : _c.disconnect(); this._scrollbarResizeObserver = null; } /** * Update viewport and DOM state. */ _update() { var _a; if (this.isDisposed || !this.layout) { return; } const newWindowIndex = this.viewModel.getRangeToRender(); if (newWindowIndex !== null) { const [startIndex, stopIndex, firstVisibleIndex, lastVisibleIndex] = newWindowIndex; if (this.scrollbar) { const scrollbarItems = this._renderScrollbar(); const first = scrollbarItems[firstVisibleIndex]; const last = scrollbarItems[lastVisibleIndex]; this._viewportIndicator.style.top = first.offsetTop - 1 + 'px'; this._viewportIndicator.style.height = last.offsetTop - first.offsetTop + last.offsetHeight + 'px'; } const toAdd = []; if (stopIndex >= 0) { for (let index = startIndex; index <= stopIndex; index++) { const widget = this.viewModel.widgetRenderer(index); widget.dataset.windowedListIndex = `${index}`; toAdd.push(widget); } } const nWidgets = this.layout.widgets.length; // Remove not needed widgets for (let itemIdx = nWidgets - 1; itemIdx >= 0; itemIdx--) { if (!toAdd.includes(this.layout.widgets[itemIdx])) { (_a = this._itemsResizeObserver) === null || _a === void 0 ? void 0 : _a.unobserve(this.layout.widgets[itemIdx].node); this.layout.removeWidget(this.layout.widgets[itemIdx]); } } for (let index = 0; index < toAdd.length; index++) { const item = toAdd[index]; if (this._itemsResizeObserver && !this.layout.widgets.includes(item)) { this._itemsResizeObserver.observe(item.node); item.disposed.connect(() => { var _a; return (_a = this._itemsResizeObserver) === null || _a === void 0 ? void 0 : _a.unobserve(item.node); }); } // The widget may have moved due to drag-and-drop this.layout.insertWidget(index, item); } if (this.viewModel.windowingActive) { if (stopIndex >= 0) { // Read this value after creating the cells. // So their actual sizes are taken into account this._updateTotalSize(); // Update position of window container let [top, _minHeight] = this.viewModel.getSpan(startIndex, stopIndex); this._viewport.style.transform = `translateY(${top}px)`; } else { // Update inner container height this._innerElement.style.height = `0px`; // Update position of viewport node this._viewport.style.top = `0px`; this._viewport.style.minHeight = `0px`; } // Update scroll if (this._scrollUpdateWasRequested) { this._outerElement.scrollTop = this.viewModel.scrollOffset; this._scrollUpdateWasRequested = false; } } } let index2 = -1; for (const w of this._viewport.children) { const currentIdx = parseInt(w.dataset.windowedListIndex, 10); if (currentIdx < index2) { throw new Error('Inconsistent dataset index'); } else { index2 = currentIdx; } } } /** * Handle viewport area resize. */ _onAreaResize(_entries) { this._scrollBackToItemOnResize(); } /** * Handle viewport content (i.e. items) resize. */ _onItemResize(entries) { this._resetScrollToItem(); if (this.isHidden || this.isParentHidden) { return; } const newSizes = []; for (let entry of entries) { // Update size only if item is attached to the DOM if (entry.target.isConnected) { // Rely on the data attribute as some nodes may be hidden