@jupyterlab/ui-components
Version:
JupyterLab - UI components written in React
1,198 lines • 62.1 kB
JavaScript
/*
* 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