Allow the collection tree rows to have custom row heights (#3460)

- The underlying changes are in windowed-list, which the item tree and
  all virtualized tables are based on, so if there are bugs they might
  show up outside of the collection tree.
- The expensive operation is adding/removing rows, since row offsets
  have to be recalculated (this includes collapsing/expanding rows).
- The cost on further drawing while scrolling is constant and shouldn't
  affect performance much.
This commit is contained in:
Adomas Ven 2023-10-24 10:56:07 +03:00 committed by GitHub
parent f012a348af
commit 3b9d0ac1bb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 91 additions and 34 deletions

View file

@ -86,6 +86,9 @@ var CollectionTree = class CollectionTree extends LibraryTree {
this._editingInput = null;
this._dropRow = null;
this._typingTimeout = null;
this._customRowHeights = [];
this._separatorHeight = 8;
this.onLoad = this.createEventBinding('load', true, true);
}
@ -1032,9 +1035,9 @@ var CollectionTree = class CollectionTree extends LibraryTree {
this.selection.selectEventsSuppressed = false;
this._rows[index].isOpen = true;
this.tree.invalidate(index);
this._refreshRowMap();
this._saveOpenStates();
this.tree.invalidate(index);
}
/**
@ -2493,6 +2496,19 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return beforeRow;
}
_refreshRowMap() {
super._refreshRowMap();
let customRowHeights = [];
for (var i = 0; i < this.rowCount; i++) {
let row = this.getRow(i);
if (row.isSeparator()) {
customRowHeights.push([i, this._separatorHeight]);
}
}
this._customRowHeights = customRowHeights;
this.tree.updateCustomRowHeights(this._customRowHeights);
}
_selectAfterRowRemoval(row) {
// If last row was selected, stay on the last row
if (row >= this._rows.length) {

View file

@ -1256,7 +1256,14 @@ class VirtualizedTable extends React.Component {
this._jsWindow.update(this._getWindowedListOptions());
this._setAlternatingRows();
this._jsWindow.invalidate();
}
};
/**
* @param customRowHeights an array of tuples specifying row index and row height: e.g. [[1, 10], [5, 10]]
*/
updateCustomRowHeights = (customRowHeights=[]) => {
return this._jsWindow.update({customRowHeights});
};
_getRowHeight() {
let rowHeight = this.props.linesPerRow * this._renderedTextHeight;

View file

@ -46,8 +46,14 @@ module.exports = class {
* - renderItem {Function} a function that returns a DOM element for an individual row to display
* - itemHeight {Integer}
* - targetElement {DOMElement} a container DOM element for the windowed-list
* - customRowHeights {Array|optional} a sorted array of tuples [itemIndex, rowHeight]
*/
constructor(options) {
this.getItemCount = () => 0;
this.renderItem = () => 0;
this.itemHeight = 0;
this.targetElement = null;
this.customRowHeights = [];
for (let option of requiredOptions) {
if (!options.hasOwnProperty(option)) {
throw new Error('Attempted to initialize windowed-list without a required option: ' + option);
@ -58,6 +64,7 @@ module.exports = class {
this.scrollOffset = 0;
this.overscanCount = 2;
this._lastItemCount = null;
this._rowOffsets = [[0, 0]];
Object.assign(this, options);
this._renderedRows = new Map();
@ -73,7 +80,7 @@ module.exports = class {
targetElement.appendChild(this.innerElem);
targetElement.addEventListener('scroll', this._handleScroll);
this.update();
}
@ -107,8 +114,8 @@ module.exports = class {
*/
invalidate() {
// Removes any items out of view and adds the ones not in view
this.render();
let oldRenderedRows = new Set(this._renderedRows.keys());
this.render();
// Rerender the rest
for (let index of Array.from(this._renderedRows.keys())) {
// Rerender only old rows, new ones got a fresh render in this.render() call
@ -152,19 +159,41 @@ module.exports = class {
Object.assign(this, options);
const { itemHeight, targetElement, innerElem } = this;
const itemCount = this._getItemCount();
const [offsetIdx, offset] = this._rowOffsets.at(-1);
const listHeight = offset + (itemCount - offsetIdx) * this.itemHeight;
innerElem.style.position = 'relative';
innerElem.style.height = `${itemHeight * itemCount}px`;
innerElem.style.height = `${listHeight}px`;
// Recalculate custom row height offsets
this._rowOffsets = [[0, 0]];
let previousRowOffset = 0;
let previousRowIndex = 0;
for (let [index, rowHeight] of this.customRowHeights) {
// Previous custom row offset + normal rows up to this custom row + this custom row
const offset = previousRowOffset + ((index - previousRowIndex) * itemHeight) + rowHeight;
this._rowOffsets.push([index + 1, offset]);
previousRowIndex = index + 1;
previousRowOffset = offset;
}
this.scrollDirection = 0;
this.scrollOffset = targetElement.scrollTop;
}
getWindowHeight() {
return this.targetElement.getBoundingClientRect().height;
}
getElementByIndex = index => this._renderedRows.get(index);
/**
* Scroll the top of the scrollbox to a specified location
* @param scrollOffset {Integer} offset for the top of the tree
*/
scrollTo(scrollOffset) {
const maxOffset = Math.max(0, this.itemHeight * this._getItemCount() - this.getWindowHeight());
const [offsetIdx, offset] = this._rowOffsets.at(-1);
const listHeight = offset + (this._getItemCount() - offsetIdx) * this.itemHeight;
const maxOffset = Math.max(0, listHeight - this.getWindowHeight());
scrollOffset = Math.min(Math.max(0, scrollOffset), maxOffset);
this.scrollOffset = scrollOffset;
this.targetElement.scrollTop = scrollOffset;
@ -176,63 +205,50 @@ module.exports = class {
* @param index
*/
scrollToRow(index) {
const { itemHeight, scrollOffset } = this;
const { scrollOffset } = this;
const itemCount = this._getItemCount();
const height = this.getWindowHeight();
index = Math.max(0, Math.min(index, itemCount - 1));
let startPosition = this._getItemPosition(index);
let endPosition = startPosition + itemHeight;
let endPosition = this._getItemPosition(index + 1);
if (startPosition < scrollOffset) {
this.scrollTo(startPosition);
}
else if (endPosition > scrollOffset + height) {
this.scrollTo(Math.min(endPosition - height, (itemCount * itemHeight) - height));
this.scrollTo(endPosition - height - 1);
}
}
getFirstVisibleRow() {
return Math.ceil(this.scrollOffset / this.itemHeight);
const idx = this._binarySearchOffsets(this._rowOffsets, this.scrollOffset, true);
const [offsetIdx, offset] = this._rowOffsets[idx];
return offsetIdx + Math.floor((this.scrollOffset - offset) / this.itemHeight);
}
getLastVisibleRow() {
const height = this.getWindowHeight();
return Math.max(1, Math.floor((this.scrollOffset + height + 1) / this.itemHeight)) - 1;
const idx = this._binarySearchOffsets(this._rowOffsets, this.scrollOffset + height + 1, true);
const [offsetIdx, offset] = this._rowOffsets[idx];
return Math.max(1, offsetIdx + Math.ceil(((this.scrollOffset + height + 1) - offset) / this.itemHeight)) - 1;
}
getWindowHeight() {
return this.targetElement.getBoundingClientRect().height;
}
getIndexByMouseEventPosition = (yOffset) => {
return Math.min(this._getItemCount()-1, Math.floor((yOffset - this.innerElem.getBoundingClientRect().top) / this.itemHeight));
}
getElementByIndex = index => this._renderedRows.get(index);
/**
* @returns {Integer} - the number of fully visible items in the scrollbox
*/
getPageLength() {
const height = this.getWindowHeight();
return Math.ceil(height / this.itemHeight);
}
_getItemPosition = (index) => {
return (this.itemHeight * index);
const idx = this._binarySearchOffsets(this._rowOffsets, index);
const [offsetIdx, offset] = this._rowOffsets[idx];
return offset + (this.itemHeight * (index - offsetIdx));
};
_getRangeToRender() {
const { itemHeight, overscanCount, scrollDirection, scrollOffset } = this;
const { overscanCount, scrollDirection } = this;
const itemCount = this._getItemCount();
const height = this.getWindowHeight();
if (itemCount === 0) {
return [0, 0, 0, 0];
}
const startIndex = Math.floor(scrollOffset / itemHeight);
const stopIndex = Math.ceil((scrollOffset + height) / itemHeight + 1);
const startIndex = this.getFirstVisibleRow();
const stopIndex = this.getLastVisibleRow();
// Overscan by one item in each direction so that tab/focus works.
// If there isn't at least one extra item, tab loops back around.
@ -285,6 +301,24 @@ module.exports = class {
this._resetScrollDirection();
this.render();
};
_binarySearchOffsets(array, searchValue, lookupByOffset=false) {
const idx = lookupByOffset ? 1 : 0;
const searchIdx = Math.floor(array.length / 2.0);
const inspectValue = array[searchIdx][idx];
if (searchValue === inspectValue) {
return searchIdx;
}
else if (array.length === 1) {
return (searchValue > inspectValue) ? searchIdx : -1;
}
else if (searchValue > inspectValue) {
return (searchIdx + 1) + this._binarySearchOffsets(array.slice(searchIdx + 1), searchValue, lookupByOffset);
}
else {
return this._binarySearchOffsets(array.slice(0, searchIdx), searchValue, lookupByOffset);
}
}
_resetScrollDirection = Zotero.Utilities.debounce(() => this.scrollDirection = 0, 150);
};