zotero/chrome/content/zotero/components/virtualized-table.jsx
Adomas Ven c49a05d486
Improve column resizing robustness (#2588)
Also:

* Add staticWidth and minWidth column properties
2022-05-03 03:00:23 -04:00

1650 lines
49 KiB
JavaScript

/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2019 Corporation for Digital Scholarship
Vienna, Virginia, USA
http://zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
'use strict';
const React = require('react');
const PropTypes = require('prop-types');
const cx = require('classnames');
const WindowedList = require('./windowed-list');
const Draggable = require('./draggable');
const { injectIntl } = require('react-intl');
const { IconDownChevron, getDOMElement } = require('components/icons');
const TYPING_TIMEOUT = 1000;
const MINIMUM_ROW_HEIGHT = 20; // px
const RESIZER_WIDTH = 5; // px
const COLUMN_MIN_WIDTH = 20;
const COLUMN_NORMALIZATION_WIDTH = 8192;
const COLUMN_PADDING = 10; // N.B. MUST BE INLINE WITH CSS!!!
const noop = () => 0;
/**
* Somewhat corresponds to nsITreeSelection
* https://udn.realityripple.com/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsITreeSelection
*
* @property pivot {Number} The selection "pivot". This is the first item the user selected as part of
* a ranged select (i.e. shift-select).
* @property focused {Number} The currently selected/focused item.
* @property count {Number} The number of selected items
* @property selected {Set} The set of currently selected items
* @property selectEventsSuppressed {Boolean} Controls whether select events are triggered on selection change.
*/
class TreeSelection {
/**
* @param tree {VirtualizedTable} The tree where selection occurs. Will be used to issue
* updates.
*/
constructor(tree) {
this._tree = tree;
Object.assign(this, {
pivot: 0,
focused: 0,
selected: new Set([]),
_selectEventsSuppressed: false
});
}
/**
* Returns whether the given index is selected.
* @param index {Number} The index is 0-clamped.
* @returns {boolean}
*/
isSelected(index) {
index = Math.max(0, index);
return this.selected.has(index);
}
/**
* Toggles an item's selection state, updates focused item to index.
* @param index {Number} The index is 0-clamped.
* @param shouldDebounce {Boolean} Whether the update to the tree should be debounced
*/
toggleSelect(index, shouldDebounce) {
if (!this._tree.props.isSelectable(index)) return;
index = Math.max(0, index);
if (this.selected.has(index)) {
this.selected.delete(index);
}
else {
this.selected.add(index);
}
if (this.selectEventsSuppressed) return;
let previousFocused = this.focused;
this.pivot = index;
this.focused = index;
if (this._tree.invalidate) {
this._tree.invalidateRow(index);
this._tree.invalidateRow(previousFocused);
}
this._updateTree(shouldDebounce);
}
clearSelection() {
this.selected = new Set();
if (this.selectEventsSuppressed) return;
if (this._tree.invalidate) {
this._tree.invalidate();
}
this._updateTree();
}
/**
* Selects an item, updates focused item to index.
* @param index {Number} The index is 0-clamped.
* @param shouldDebounce {Boolean} Whether the update to the tree should be debounced
* @returns {boolean} False if nothing to select and select handlers won't be called
*/
select(index, shouldDebounce) {
if (!this._tree.props.isSelectable(index)) return;
index = Math.max(0, index);
if (this.selected.size == 1 && this.isSelected(index)) {
return false;
}
let toInvalidate = new Set(this.selected);
toInvalidate.add(index);
toInvalidate.add(this.focused);
this.selected = new Set([index]);
this.focused = index;
this.pivot = index;
if (this.selectEventsSuppressed) return true;
this._tree.scrollToRow(index);
this._updateTree(shouldDebounce);
if (this._tree.invalidate) {
toInvalidate.forEach(this._tree.invalidateRow.bind(this._tree));
}
return true;
}
_rangedSelect(from, to, augment, isSelectAll) {
from = Math.max(0, from);
to = Math.max(0, to);
if (!augment) {
this.selected = new Set();
}
for (let i = from; i <= to; i++) {
if (this._tree.props.isSelectable(i, isSelectAll)) {
this.selected.add(i);
}
}
}
rangedSelect(from, to, augment, isSelectAll) {
this._rangedSelect(from, to, augment, isSelectAll);
if (this.selectEventsSuppressed) return;
if (this._tree.invalidate) {
if (augment) {
this._tree.invalidateRange(from, to);
}
else {
this._tree.invalidate();
}
}
this._updateTree();
}
/**
* Performs a shift-select from current pivot to provided index. Updates focused item to index.
* @param index {Number} The index is 0-clamped.
* @param augment {Boolean} Adds to existing selection if true
* @param shouldDebounce {Boolean} Whether the update to the tree should be debounced
*/
shiftSelect(index, augment, shouldDebounce) {
if (!this._tree.props.isSelectable(index)) return;
index = Math.max(0, index);
let from = Math.min(index, this.pivot);
let to = Math.max(index, this.pivot);
let oldFocused = this.focused;
this.focused = index;
let oldSelected = this.selected;
if (augment) {
oldSelected = new Set(oldSelected);
}
this._rangedSelect(from, to, augment);
if (this.selectEventsSuppressed) return;
if (this._tree.invalidate) {
for (let index of this.selected) {
if (oldSelected.has(index)) {
oldSelected.delete(index);
continue;
}
this._tree.invalidateRow(index);
}
for (let index of oldSelected) {
this._tree.invalidateRow(index);
}
this._tree.invalidateRow(oldFocused);
}
this._updateTree(shouldDebounce);
}
/**
* Calls the onSelectionChange prop on the tree
* @param shouldDebounce {Boolean} Whether the update to the tree should be debounced
* @private
*/
_updateTree(shouldDebounce) {
if (!this.selectEventsSuppressed && this._tree.props.onSelectionChange) {
this._tree.props.onSelectionChange(this, shouldDebounce);
}
}
get count() {
return this.selected.size;
}
get selectEventsSuppressed() {
return this._selectEventsSuppressed;
}
set selectEventsSuppressed(val) {
this._selectEventsSuppressed = val;
if (!val) {
this._updateTree();
if (this._tree.invalidate) {
this._tree.invalidate();
}
}
}
}
// Something to return on selection query before tree initialization
let TreeSelectionStub = {};
for (const key of Object.getOwnPropertyNames(TreeSelection.prototype)) {
TreeSelectionStub[key] = () => 0;
}
TreeSelectionStub = Object.assign(TreeSelectionStub, {
pivot: 0,
focused: 0,
count: 0,
selected: new Set([]),
selectEventsSuppressed: false
});
/**
* A virtualized-table, inspired by https://github.com/bvaughn/react-virtualized
*
* Uses a custom windowed-list for fast item rendering and
* CSS style injection for fast column resizing
*
* Exposes the windowed-list to the object creator via a ref
* and also includes a bunch of helper methods for invalidating
* rows, scrolling, etc.
*
* Any updates to actual rows being drawn have to be told about
* to the windowed-list instance. More fundamental changes like the number and
* type of columns, window resizes, etc, have to be told about to the
* VirtualizedTable instance via forceUpdate()
*
* Selection is controlled via the .selection property, which is an
* instance of TableSelection. Selection changes perform their own row invalidation
* on the windowed-list.
*/
class VirtualizedTable extends React.Component {
constructor(props) {
super(props);
this.state = {
resizing: null
};
this._typingString = "";
this._jsWindowID = `virtualized-table-list-${Zotero.Utilities.randomString(5)}`;
this._containerWidth = props.containerWidth || window.innerWidth;
this._columns = new Columns(this);
this._renderedTextHeight = this._getRenderedTextHeight();
this._rowHeight = this._getRowHeight();
this.selection = new TreeSelection(this);
// Due to how the Draggable element works dragging (for column dragging and for resizing)
// is not handled via React events but via native ones attached on `document`
// Since React attaches its event handlers on `document` as well
// there is no way to prevent bubbling. Thus we have to do custom
// handling to prevent header resorting when "mouseup" event is issued
// after dragging actions
this.isHeaderMouseUp = true;
this._isMouseDrag = false;
this.preventScrollKeys = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", " "]);
if (!Zotero.isMac) {
['PageUp', 'PageDown'].forEach(key => this.preventScrollKeys.add(key));
}
this.onSelection = oncePerAnimationFrame(this._onSelection);
}
static defaultProps = {
label: '',
role: 'grid',
linesPerRow: 1,
showHeader: false,
// Array of column objects like the ones in itemTreeColumns.js
columns: [],
onColumnSort: noop,
onColumnPickerMenu: noop,
getColumnPrefs: () => ({}),
storeColumnPrefs: noop,
staticColumns: false,
alternatingRowColors: Zotero.isMac ? ['-moz-OddTreeRow', '-moz-EvenTreeRow'] : null,
// Render with display: none
hide: false,
multiSelect: false,
onSelectionChange: noop,
// The below are for arrow-key navigation
isSelectable: () => true,
getParentIndex: noop,
isContainer: noop,
isContainerEmpty: noop,
isContainerOpen: noop,
toggleOpenState: noop,
// If you want to perform custom key handling it should be in this function
// if it returns false then virtualized-table's own key handler won't run
onKeyDown: () => true,
onKeyUp: noop,
onDragOver: noop,
onDrop: noop,
// Enter, double-clicking
onActivate: noop(),
onItemContextMenu: noop(),
};
static propTypes = {
id: PropTypes.string.isRequired,
getRowCount: PropTypes.func.isRequired,
renderItem: PropTypes.func,
// Row height specified as lines of text per row. Defaults to 1
linesPerRow: PropTypes.number,
// Do not adjust for Zotero-defined font scaling
disableFontSizeScaling: PropTypes.bool,
// An array of two elements for alternating row colors
alternatingRowColors: PropTypes.array,
// For screen-readers
label: PropTypes.string,
role: PropTypes.string,
showHeader: PropTypes.bool,
// Array of column objects like the ones in itemTreeColumns.js
columns: PropTypes.array,
onColumnPickerMenu: PropTypes.func,
onColumnSort: PropTypes.func,
getColumnPrefs: PropTypes.func,
storeColumnPrefs: PropTypes.func,
getDefaultColumnOrder: PropTypes.func,
// Makes columns unmovable, unsortable, etc
staticColumns: PropTypes.bool,
// Used for initial column widths calculation
containerWidth: PropTypes.number,
// Internal windowed-list ref
treeboxRef: PropTypes.func,
// Render with display: none
hide: PropTypes.bool,
multiSelect: PropTypes.bool,
onSelectionChange: PropTypes.func,
// The below are for arrow-key navigation
isSelectable: PropTypes.func,
getParentIndex: PropTypes.func,
isContainer: PropTypes.func,
isContainerEmpty: PropTypes.func,
isContainerOpen: PropTypes.func,
toggleOpenState: PropTypes.func,
// A function with signature (index:Number) => result:String which will be used
// for find-as-you-type navigation. Find-as-you-type is disabled if prop is undefined.
getRowString: PropTypes.func,
// If you want to perform custom key handling it should be in this function
// if it returns false then virtualized-table's own key handler won't run
onKeyDown: PropTypes.func,
onKeyUp: PropTypes.func,
onDragOver: PropTypes.func,
onDrop: PropTypes.func,
// Enter, double-clicking
onActivate: PropTypes.func,
onFocus: PropTypes.func,
onItemContextMenu: PropTypes.func,
};
// ------------------------ Selection Methods ------------------------- //
_preventKeyboardScrolling = (e) => {
if (this.preventScrollKeys.has(e.key)) {
e.preventDefault();
e.stopPropagation();
if (e.nativeEvent) {
if (e.nativeEvent.preventDefault) {
e.nativeEvent.preventDefault();
}
if (e.nativeEvent.stopPropagation) {
e.nativeEvent.stopPropagation();
}
}
}
}
/**
* Ensure the tree scrolls when dragging over top and bottom parts of it
*/
_onDragOver = (e) => {
let tree = e.currentTarget;
if (tree.id != this.props.id) return;
let { y, height } = tree.getBoundingClientRect();
let yBott = y + height;
let threshold = this._rowHeight / 3;
let scrollHeight = this._rowHeight * 3;
if (e.clientY - y <= threshold) {
// Already at top
if (tree.scrollTop === 0) return;
let scrollTo = Math.max(tree.scrollTop - scrollHeight, 0);
tree.scrollTop = scrollTo;
}
else if (yBott - e.clientY <= threshold) {
// Already at bottom
if (tree.scrollTop === tree.scrollHeight - tree.clientHeight) return;
let scrollTo = Math.min(
tree.scrollTop + scrollHeight,
tree.scrollHeight - tree.clientHeight
);
tree.scrollTop = scrollTo;
}
this.props.onDragOver && this.props.onDragOver(e)
}
/**
* Handles page up/down jumps
*
* @param {Integer} direction - -1 for up, 1 for down
* @param {Boolean} selectTo
*/
_onJumpSelect(direction, selectTo, toggleSelection) {
if (direction == 1) {
const lastVisible = this._jsWindow.getLastVisibleRow();
if (this.selection.focused != lastVisible) {
return this.onSelection(lastVisible, selectTo, toggleSelection);
}
}
else {
const firstVisible = this._jsWindow.getFirstVisibleRow();
if (this.selection.focused != firstVisible) {
return this.onSelection(firstVisible, selectTo, toggleSelection);
}
}
const height = document.getElementById(this._jsWindowID).clientHeight;
const numRows = Math.floor(height / this._rowHeight);
let destination = this.selection.focused + (direction * numRows);
const rowCount = this.props.getRowCount();
destination = Math.min(destination, rowCount - 1);
destination = Math.max(0, destination);
return this.onSelection(destination, selectTo, toggleSelection);
}
/**
* Handles key down events in the tree's container.
*
* @param {Event} e
*/
_onKeyDown = (e) => {
if (this.props.onKeyDown && this.props.onKeyDown(e) === false) return;
this._preventKeyboardScrolling(e);
if (e.altKey) return;
const shiftSelect = e.shiftKey;
const moveFocused = Zotero.isMac ? e.metaKey : e.ctrlKey;
const toggleSelection = shiftSelect && moveFocused;
const rowCount = this.props.getRowCount();
switch (e.key) {
case "ArrowUp":
let prevSelect = this.selection.focused - 1;
while (prevSelect > 0 && !this.props.isSelectable(prevSelect)) {
prevSelect--;
}
prevSelect = Math.max(0, prevSelect);
this.onSelection(prevSelect, shiftSelect, toggleSelection, moveFocused, e.repeat);
break;
case "ArrowDown":
let nextSelect = this.selection.focused + 1;
while (nextSelect < rowCount && !this.props.isSelectable(nextSelect)) {
nextSelect++;
}
nextSelect = Math.min(nextSelect, rowCount - 1);
this.onSelection(nextSelect, shiftSelect, toggleSelection, moveFocused, e.repeat);
break;
case "Home":
this.onSelection(0, shiftSelect, toggleSelection, moveFocused);
break;
case "End":
this.onSelection(rowCount - 1, shiftSelect, toggleSelection, moveFocused);
break;
case "PageUp":
if (!Zotero.isMac) {
this._onJumpSelect(-1, shiftSelect, toggleSelection, e.repeat);
}
else {
this._jsWindow.scrollTo(this._jsWindow.scrollOffset - this._jsWindow.getWindowHeight() + this._rowHeight);
}
break;
case "PageDown":
if (!Zotero.isMac) {
this._onJumpSelect(1, shiftSelect, toggleSelection, e.repeat);
}
else {
this._jsWindow.scrollTo(this._jsWindow.scrollOffset + this._jsWindow.getWindowHeight() - this._rowHeight);
}
break;
// Select All
case "a":
if (this.props.multiSelect
&& !e.shiftKey
&& (Zotero.isMac ? (e.metaKey && !e.ctrlKey) : e.ctrlKey)) {
this.selection.rangedSelect(0, this.props.getRowCount()-1, false, true);
}
break;
case " ":
if (this._typingString.length <= 0) {
this.onSelection(this.selection.focused, false, true);
return;
}
break;
case "Enter":
this._activateNode(e);
return;
}
if (e.key == 'ContextMenu' || (e.key == 'F10' && e.shiftKey)) {
let selectedElem = document.querySelector(`#${this._jsWindowID} [aria-selected=true]`);
let boundingRect = selectedElem.getBoundingClientRect();
this.props.onItemContextMenu(e, boundingRect.left + 50, boundingRect.bottom);
return;
}
if (this.props.getRowString && !(e.ctrlKey || e.metaKey) && e.key.length == 1) {
this._handleTyping(e.key);
}
if (shiftSelect || moveFocused) return;
switch (e.key) {
case "ArrowLeft":
const parentIndex = this.props.getParentIndex(this.selection.focused);
if (this.props.isContainer(this.selection.focused)
&& !this.props.isContainerEmpty(this.selection.focused)
&& this.props.isContainerOpen(this.selection.focused)) {
this.props.toggleOpenState(this.selection.focused);
}
else if (parentIndex != -1) {
this.onSelection(parentIndex);
}
break;
case "ArrowRight":
if (this.props.isContainer(this.selection.focused)
&& !this.props.isContainerEmpty(this.selection.focused)) {
if (!this.props.isContainerOpen(this.selection.focused)) {
this.props.toggleOpenState(this.selection.focused);
}
else {
this.onSelection(this.selection.focused + 1);
}
}
break;
}
}
_handleTyping = (char) => {
char = char.toLowerCase();
this._typingString += char;
let allSameChar = true;
for (let i = this._typingString.length - 1; i >= 0; i--) {
if (char != this._typingString[i]) {
allSameChar = false;
break;
}
}
const rowCount = this.props.getRowCount();
if (allSameChar) {
for (let i = this.selection.focused + 1, checked = 0; checked < rowCount; i++, checked++) {
i %= rowCount;
let rowString = this.props.getRowString(i);
if (rowString.toLowerCase().indexOf(char) == 0) {
if (i != this.selection.focused) {
this.scrollToRow(i);
this.onSelection(i);
}
break;
}
}
}
else {
for (let i = (this.selection.focused + 1) % rowCount; i != this.selection.focused; i = (i + 1) % rowCount) {
let rowString = this.props.getRowString(i);
if (rowString.toLowerCase().indexOf(this._typingString) == 0) {
if (i != this.selection.focused) {
this.scrollToRow(i);
this.onSelection(i);
}
break;
}
}
}
clearTimeout(this._typingTimeout);
this._typingTimeout = setTimeout(() => {
this._typingString = "";
}, TYPING_TIMEOUT);
}
_onDragStart = () => {
this._isMouseDrag = true;
}
_onDragEnd = () => {
this._isMouseDrag = false;
}
_handleMouseDown = async (e, index) => {
const modifierClick = e.shiftKey || e.ctrlKey || e.metaKey;
if (e.button == 2) {
if (!modifierClick && !this.selection.isSelected(index)) {
this._onSelection(index, false, false);
}
this.props.onItemContextMenu(e, e.clientX, e.clientY);
}
// All modifier clicks handled in mouseUp per mozilla itemtree convention
if (!modifierClick && !this.selection.isSelected(index)) {
this._onSelection(index, false, false);
}
this.focus();
}
_handleMouseUp = async (e, index) => {
const shiftSelect = e.shiftKey;
const augment = e.ctrlKey || e.metaKey;
if (this._isMouseDrag || e.button != 0) {
// other mouse buttons are ignored
this._isMouseDrag = false;
return;
}
this._onSelection(index, shiftSelect, augment);
this.focus();
}
_activateNode = (event, indices) => {
indices = indices || Array.from(this.selection.selected);
if (!indices.length) return;
if (this.props.onActivate) {
this.props.onActivate(event, indices);
}
}
/**
* Scroll the row into view. Delegates to windowed-list
*
* @param index {Number}
*/
scrollToRow(index) {
this._jsWindow && this._jsWindow.scrollToRow(index);
}
/**
* Updates the selection object
*
* @param {Number} index
* The index of the item in a full DFS traversal (ignoring collapsed
* nodes). Ignored if `item` is undefined.
* @param {Boolean} shiftSelect
* If true will select from focused up to index (does not update pivot)
* @param {Boolean} toggleSelection
* If true will add to selection
* @param {Boolean} moveFocused
* Will move focus without adding anything to the selection
*/
_onSelection = (index, shiftSelect, toggleSelection, moveFocused, shouldDebounce) => {
if (this.selection.selectEventsSuppressed) return;
if (!this.props.multiSelect && (shiftSelect || toggleSelection || moveFocused)) {
return;
}
else if (shiftSelect) {
this.selection.shiftSelect(index, toggleSelection, shouldDebounce);
}
else if (toggleSelection) {
this.selection.toggleSelect(index, shouldDebounce);
}
else if (moveFocused) {
let previousFocused = this.selection.focused;
this.selection.focused = index;
this.selection.pivot = index;
this.invalidateRow(previousFocused);
this.invalidateRow(index);
}
// Normal selection
else if (!toggleSelection) {
if (index > 0 && !this.props.isSelectable(index)) {
return;
}
this.selection.select(index, shouldDebounce);
}
// If index is not selectable and this is not normal selection we return
else if (!this.props.isSelectable(index)) {
return;
}
// None of the previous conditions were satisfied, so nothing changes
else {
return;
}
this.scrollToRow(index);
}
// ------------------------ Column Methods ------------------------- //
_handleResizerDragStart = (index, event) => {
if (event.button !== 0) return false;
event.stopPropagation();
this.isHeaderMouseUp = false;
const result = this._getResizeColumns(index);
// No resizable columns on the left/right
if (!result) return false;
this.setState({ resizing: index });
let onResizeData = {};
const columns = this._getVisibleColumns();
for (let i = 0; i < columns.length; i++) {
let elem = event.target.parentNode.parentNode.children[i];
onResizeData[columns[i].dataKey] = elem.getBoundingClientRect().width;
}
this._columns.onResize(onResizeData);
this._isMouseDrag = true;
}
_handleResizerDrag = (event) => {
event.stopPropagation();
const result = this._getResizeColumns();
if (!result) return;
const [aColumn, bColumn, resizingColumn] = result;
const a = document.querySelector(`#${this.props.id} .virtualized-table-header .cell.${aColumn.dataKey}`);
const b = document.querySelector(`#${this.props.id} .virtualized-table-header .cell.${bColumn.dataKey}`);
const resizing = document.querySelector(`#${this.props.id} .virtualized-table-header .cell.${resizingColumn.dataKey}`);
const aRect = a.getBoundingClientRect();
const bRect = b.getBoundingClientRect();
const resizingRect = resizing.getBoundingClientRect();
let offset = aRect.x;
if (aColumn.dataKey != resizingColumn.dataKey) {
offset += resizingRect.width;
}
const widthSum = aRect.width + bRect.width;
const aSpacingOffset = (aColumn.minWidth ? aColumn.minWidth : COLUMN_MIN_WIDTH) + COLUMN_PADDING;
const bSpacingOffset = (bColumn.minWidth ? bColumn.minWidth : COLUMN_MIN_WIDTH) + COLUMN_PADDING;
const aColumnWidth = Math.min(widthSum - bSpacingOffset, Math.max(aSpacingOffset, event.clientX - (RESIZER_WIDTH / 2) - offset));
const bColumnWidth = widthSum - aColumnWidth;
let onResizeData = {};
onResizeData[aColumn.dataKey] = aColumnWidth;
onResizeData[bColumn.dataKey] = bColumnWidth;
this._columns.onResize(onResizeData);
}
/**
* Get all columns including hidden ones
*/
_getColumns() {
return this._columns.getAsArray();
}
_getVisibleColumns() {
return this._getColumns().filter(col => !col.hidden);
}
_getResizeColumns(index) {
index = typeof index != "undefined" ? index : this.state.resizing;
let resizingColumn, aColumn, bColumn;
const columns = this._getVisibleColumns().sort((a, b) => a.ordinal - b.ordinal);
aColumn = resizingColumn = columns[index - 1];
bColumn = columns[index];
if (aColumn.fixedWidth) {
for (let i = index - 2; i >= 0; i--) {
aColumn = columns[i];
if (!aColumn.fixedWidth) break;
}
if (aColumn.fixedWidth) {
// All previous columns are fixed width
return;
}
}
if (bColumn.fixedWidth) {
for (let i = index + 1; i < columns.length; i++) {
bColumn = columns[i];
if (!bColumn.fixedWidth) break;
}
if (bColumn.fixedWidth) {
// All following columns are fixed width
return;
}
}
if (Zotero.rtl) {
return [bColumn, aColumn, resizingColumn];
}
return [aColumn, bColumn, resizingColumn];
}
/**
* Toggle [title] attribute on cells when text is truncated
* so that a tooltip gets displayed on hover.
* @param event
*/
_handleMouseOver = (event) => {
let elem = event.target;
if (!elem.classList.contains('cell') || elem.classList.contains('cell-icon')) return;
let textElem = elem.querySelector('.label, .cell-text');
// .label is used in the header, .cell-text on primary cells,
// otherwise the .cell element if its immediate child is a text node
// should be used.
if (!textElem) {
if (!elem.childNodes.length || elem.childNodes[0].nodeType != window.Node.TEXT_NODE) return;
textElem = elem;
}
// We need to set the [title] attribute on the .label element in the header
if (textElem.classList.contains('label')) elem = textElem;
if (textElem.offsetWidth < textElem.scrollWidth) {
elem.setAttribute('title', textElem.textContent);
}
else {
elem.removeAttribute('title');
}
}
_handleResizerDragStop = (event) => {
event.stopPropagation();
const result = this._getResizeColumns();
if (!result) return;
let resizeData = {};
for (const column of result) {
const elem = document.querySelector(`#${this.props.id} .virtualized-table-header .cell.${column.dataKey}`)
resizeData[column.dataKey] = elem.getBoundingClientRect().width;
}
this._columns.onResize(resizeData, true);
this.setState({ resizing: null });
}
_handleColumnDragStart = (index, event) => {
if (event.button !== 0) return false;
// Remember for sorting
this._headerMouseDownIndex = index;
this.setState({ draggingColumn: index });
this._isMouseDrag = true;
}
_handleColumnDragStop = (event, cancelled) => {
if (!cancelled && typeof this.state.draggingColumn == "number") {
const { index } = this._findColumnDragPosition(event.clientX);
// If inserting before the column that was being dragged
// there is nothing to do
if (this.state.draggingColumn != index) {
const visibleColumns = this._getVisibleColumns();
const dragColumn = this._getColumns().findIndex(
col => col == visibleColumns[this.state.draggingColumn]);
// Insert as final column (before end of list)
let insertBeforeColumn = this._getColumns().length;
// index == visibleColumns.length if dragged to the end of the view to be ordered
// as the final column
if (index < visibleColumns.length) {
insertBeforeColumn = this._getColumns().findIndex(col => col == visibleColumns[index]);
}
this._columns.setOrder(dragColumn, insertBeforeColumn);
}
}
this.setState({ draggingColumn: null, dragColumnX: null });
}
_handleColumnDrag = (event) => {
const { offsetX } = this._findColumnDragPosition(event.clientX);
this.isHeaderMouseUp = false;
this.setState({ dragColumnX: offsetX });
}
_handleHeaderMouseUp = (event, dataKey, index) => {
if (!this.isHeaderMouseUp || event.button !== 0) {
this.isHeaderMouseUp = true;
return;
}
// The mousedown event occurred on a different column so we shouldn't sort
if (this._headerMouseDownIndex != index) return;
this._columns.toggleSort(this._getColumns().findIndex(column => column.dataKey == dataKey));
}
_findColumnDragPosition(x) {
const headerRect = document.querySelector(`#${this.props.id} .virtualized-table-header`).getBoundingClientRect();
let coords = Array.from(document.querySelectorAll(`#${this.props.id} .virtualized-table-header .resizer`))
.map((elem) => {
const rect = elem.getBoundingClientRect();
// accounting for resizer offset
return rect.x + rect.width/2;
});
// Adding leftmost position, since there's no left resizer
coords.splice(0, 0, headerRect.x);
// and the rightmost position for the same reason
coords.push(headerRect.x + headerRect.width);
let index = 0;
let closestVal = Math.abs(coords[index] - x);
for (let i = 1; i < coords.length; i++) {
let distance = Math.abs(coords[i] - x);
if (distance < closestVal) {
closestVal = distance;
index = i;
}
}
return {index, offsetX: coords[index] - headerRect.x};
}
componentDidMount() {
this._jsWindow = new WindowedList(this._getWindowedListOptions());
this._jsWindow.initialize();
this._jsWindow.render();
this._updateWidth();
this.props.treeboxRef && this.props.treeboxRef(this._jsWindow);
this._setAlternatingRows();
this._setXulTooltip();
window.addEventListener("resize", () => {
this._debouncedRerender();
});
}
componentWillUnmount() {
this._jsWindow.destroy();
}
componentDidUpdate(prevProps) {
if (this.props.id !== prevProps.id) {
this._columns = new Columns(this);
this.forceUpdate();
}
}
/**
* Make HTML [title] attribute display a tooltip. Without this
* HTML [title] attribute when embedded in a XUL window does not
* trigger a tooltip to be displayed
* @private
*/
_setXulTooltip() {
// Make sure container xul element has a tooltip set
let xulElem = this._topDiv;
while (xulElem && xulElem.namespaceURI !== "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul") {
xulElem = xulElem.parentElement;
}
if (!xulElem) return;
if (xulElem.getAttribute('tooltip') != 'html-tooltip') {
xulElem.setAttribute('tooltip', 'html-tooltip');
}
if (document.querySelector('tooltip#html-tooltip')) return;
let tooltip = document.createElement('tooltip');
tooltip.id = 'html-tooltip';
tooltip.addEventListener('popupshowing', function(e) {
let tooltipTitleNode = document.tooltipNode.closest('div *[title], iframe *[title], browser *[title]');
if (document.tooltipNode && tooltipTitleNode) {
this.setAttribute('label', tooltipTitleNode.getAttribute('title'));
return;
}
e.preventDefault();
});
document.documentElement.appendChild(tooltip);
}
_setAlternatingRows() {
if (this.props.alternatingRowColors) {
this._jsWindow.innerElem.style.background = `
repeating-linear-gradient(
180deg,
${this.props.alternatingRowColors[1]},
${this.props.alternatingRowColors[1]} ${this._rowHeight}px,
${this.props.alternatingRowColors[0]} ${this._rowHeight}px,
${this.props.alternatingRowColors[0]} ${this._rowHeight * 2}px
)
`;
}
}
_getWindowedListOptions() {
return {
getItemCount: this.props.getRowCount,
itemHeight: this._rowHeight,
renderItem: this._renderItem,
targetElement: document.getElementById(this._jsWindowID),
};
}
_renderItem = (index, oldElem = null) => {
let node = this.props.renderItem(index, this.selection, oldElem, this._getColumns());
if (!node.dataset.eventHandlersAttached) {
node.dataset.eventHandlersAttached = true;
node.addEventListener('dragstart', e => this._onDragStart(e, index), { passive: true });
node.addEventListener('dragend', e => this._onDragEnd(e, index), { passive: true });
node.addEventListener('mousedown', e => this._handleMouseDown(e, index), { passive: true });
node.addEventListener('mouseup', e => this._handleMouseUp(e, index), { passive: true });
node.addEventListener('dblclick', e => this._activateNode(e, [index]), { passive: true });
}
node.style.height = this._rowHeight + 'px';
node.style.lineHeight = this._rowHeight + 'px';
node.id = this.props.id + "-row-" + index;
if (!node.hasAttribute('role')) {
node.setAttribute('role', 'row');
}
if (this.selection.isSelected(index)) {
node.setAttribute('aria-selected', true);
}
else {
node.removeAttribute('aria-selected');
}
return node;
}
_renderHeaderCells = () => {
return this._getVisibleColumns().map((column, index) => {
let columnName = formatColumnName(column);
let label = columnName;
if (column.iconLabel) {
label = column.iconLabel;
}
let resizer = (<Draggable
onDragStart={this._handleResizerDragStart.bind(this, index)}
onDrag={this._handleResizerDrag}
onDragStop={this._handleResizerDragStop}
className={`resizer ${column.dataKey}`}
key={column.label + '-resizer'}>
<div/>
</Draggable>);
if (index == 0) {
resizer = "";
}
let sortIndicator = "";
if (!column.iconLabel && column.sortDirection) {
if (!Zotero.isNode && Zotero.isLinux) {
sortIndicator = <span className={"sort-indicator " + (column.sortDirection === 1 ? "ascending" : "descending")}/>;
} else {
sortIndicator = <IconDownChevron className={"sort-indicator " + (column.sortDirection === 1 ? "ascending" : "descending")}/>;
}
}
const className = cx("cell", column.className, { dragging: this.state.draggingColumn == index },
{ "cell-icon": !!column.iconLabel });
return (<Draggable
onDragStart={this._handleColumnDragStart.bind(this, index)}
onDrag={this._handleColumnDrag}
onDragStop={this._handleColumnDragStop}
className={className}
delay={500}
key={columnName + '-draggable'}>
<div
key={columnName}
onMouseUp={e => this._handleHeaderMouseUp(e, column.dataKey, index)}>
{resizer}
<span
key={columnName + '-label'}
className={`label ${column.dataKey}`}
title={column.iconLabel ? columnName : ""}>
{label}
</span>
{sortIndicator}
</div>
</Draggable>);
});
}
render() {
let header = "";
let columnDragMarker = "";
if (this.props.columns.length && this.props.showHeader) {
const headerCells = this._renderHeaderCells();
const headerClassName = cx("virtualized-table-header", { "static-columns": this.props.staticColumns });
header = (<div
className={headerClassName}
onContextMenu={this.props.onColumnPickerMenu}>
{headerCells}
</div>);
if (typeof this.state.dragColumnX == 'number') {
columnDragMarker = <div className="column-drag-marker" style={{ left: this.state.dragColumnX }} />;
}
}
let props = {
onKeyDown: this._onKeyDown,
onKeyUp: e => this.props.onKeyUp && this.props.onKeyUp(e),
onDragOver: this._onDragOver,
onDrop: e => this.props.onDrop && this.props.onDrop(e),
onFocus: e => this.props.onFocus && this.props.onFocus(e),
onMouseOver: e => this._handleMouseOver(e),
className: cx(["virtualized-table", {
resizing: this.state.resizing,
'multi-select': this.props.multiSelect
}]),
id: this.props.id,
ref: ref => this._topDiv = ref,
tabIndex: 0,
role: this.props.role,
};
if (this.props.hide) {
props.style = { display: "none" };
}
if (this.props.label) {
props['aria-label'] = this.props.label;
}
if (this.props.columns.length && this.props.showHeader) {
props['aria-multiselectable'] = this.props.multiSelect;
props['aria-colcount'] = this._getVisibleColumns().length;
}
if (this.props.role == 'treegrid') {
props['aria-readonly'] = true;
}
if (this.selection.count > 0) {
const elem = this._jsWindow && this._jsWindow.getElementByIndex(this.selection.focused);
if (elem) {
props['aria-activedescendant'] = elem.id;
}
}
let jsWindowProps = {
id: this._jsWindowID,
className: "virtualized-table-body",
onFocus: (e) => {
if (e.target.id == this._jsWindowID) {
// Focus should always remain on the list itself.
this._topDiv.focus();
}
},
tabIndex: -1,
};
return (
<div {...props}>
{columnDragMarker}
{header}
<div {...jsWindowProps} />
</div>
);
}
/**
* Invalidates the underlying windowed-list
*/
invalidate() {
if (!this._jsWindow) return;
this._jsWindow.invalidate();
this._updateWidth();
}
/**
* Rerenders/renders the underlying windowed-list. Use for container size changes
* to render missing items and update widths
*/
rerender = () => {
if (!this._jsWindow) return;
this._jsWindow.render();
this._updateWidth();
}
updateFontSize = () => {
if (this.props.disableFontSizeScaling) {
Zotero.warn("Attempting to update font size on a VirtualizedTable with a font scaling "
+ "disabled. Change the prop instead.");
return;
}
this._rowHeight = this._getRowHeight();
if (!this._jsWindow) return;
this._jsWindow.update(this._getWindowedListOptions());
this._setAlternatingRows();
this._jsWindow.invalidate();
}
_getRowHeight() {
let rowHeight = this.props.linesPerRow * this._renderedTextHeight;
if (!this.props.disableFontSizeScaling) {
rowHeight *= Zotero.Prefs.get('fontSize');
}
// padding
// This is weird, but Firefox trees always had different amount of padding on
// different OSes
if (Zotero.isMac) {
rowHeight *= 1.4;
}
else if (Zotero.isWin) {
rowHeight *= 1.2;
}
else {
rowHeight *= 1.1;
}
rowHeight = Math.round(Math.max(MINIMUM_ROW_HEIGHT, rowHeight));
return rowHeight;
}
_getRenderedTextHeight() {
let div = document.createElementNS("http://www.w3.org/1999/xhtml", 'div');
div.style.visibility = "hidden";
div.textContent = "Zotero";
document.documentElement.appendChild(div);
let height = window.getComputedStyle(div).height;
document.documentElement.removeChild(div);
return parseFloat(height.split('px')[0]);
}
_debouncedRerender = Zotero.Utilities.debounce(this.rerender, 200);
_updateWidth() {
if (!this.props.showHeader) return;
const jsWindow = document.querySelector(`#${this._jsWindowID} .windowed-list`);
if (!jsWindow) return;
const tree = document.querySelector(`#${this.props.id}`);
const header = document.querySelector(`#${this.props.id} .virtualized-table-header`);
const scrollbarWidth = Math.max(0,
tree.getBoundingClientRect().width - jsWindow.getBoundingClientRect().width);
let paddingWidth = 0;
if (Zotero.isLinux) {
paddingWidth = 2; // from the border
}
// Should be kept up to date with the _virtualized-table.scss value
// for .virtualized-table-header
header.style.width = `calc(100% - ${scrollbarWidth-paddingWidth}px)`;
}
/**
* Rerender a row in the underlying windowed-list
* @param index
*/
invalidateRow(index) {
if (!this._jsWindow) return;
this._jsWindow.rerenderItem(index);
}
/**
* Rerender a row range in the underlying windowed-list
* @param startIndex
* @param endIndex
*/
invalidateRange(startIndex, endIndex) {
if (!this._jsWindow) return;
for (; startIndex <= endIndex; startIndex++) {
this._jsWindow.rerenderItem(startIndex);
}
}
/**
* When performing custom event handling on rendered rows this allows to ensure that the
* focus returns to the virtualized table for kb selection and other event handling
*/
focus() {
setTimeout(() => this._topDiv.focus());
}
rowIsVisible(row) {
if (!this._jsWindow) return false;
return row >= this._jsWindow.getFirstVisibleRow()
&& row <= this._jsWindow.getLastVisibleRow();
}
}
/**
* Create a function that calls the given function `fn` only once per animation
* frame.
*
* @param {Function} fn
* @returns {Function}
*/
function oncePerAnimationFrame(fn) {
let animationId = null;
let argsToPass = null;
return function(...args) {
argsToPass = args;
if (animationId !== null) {
return;
}
let debouncedFn = () => {
fn.call(this, ...argsToPass);
animationId = null;
argsToPass = null;
};
if (typeof requestAnimationFrame == 'undefined') {
animationId = setTimeout(debouncedFn, 20);
} else {
animationId = requestAnimationFrame(debouncedFn);
}
};
}
var Columns = class {
constructor(virtualizedTable) {
this._virtualizedTable = virtualizedTable;
this._styleKey = virtualizedTable.props.id;
this._initializeStyleMap();
let columnsSettings = this._getPrefs();
let columns = this._columns = [];
for (let column of virtualizedTable.props.columns) {
// Fixed width columns can sometimes somehow obtain a width property
// this fixes it for users that may have run into the bug
if (column.fixedWidth && typeof columnsSettings[column.dataKey] == "object") {
delete columnsSettings[column.dataKey].width;;
}
column = Object.assign({}, column, columnsSettings[column.dataKey]);
column.className = cx(column.className, column.dataKey, column.dataKey + this._cssSuffix,
{ 'fixed-width': column.fixedWidth });
if (column.type) {
column.className += ` cell-${column.type}`;
}
columns.push(column);
}
// Sort columns by their `ordinal` field
columns.sort((a, b) => a.ordinal - b.ordinal);
// And then reset `ordinal` fields since there might be duplicates
// if new columns got added recently
columns.forEach((column, index) => column.ordinal = index);
// Setting column widths
const visibleColumns =
columns.reduce((accumulator, column) => accumulator += column.hidden ? 0 : 1, 0);
const containerWidth = this._virtualizedTable._containerWidth;
let columnWidths = {};
for (let i = 0; i < columns.length; i++) {
let column = columns[i];
if (!column.hidden) {
if (column.width) {
columnWidths[column.dataKey] = column.width;
}
else {
column.flex = column.flex || 1;
columnWidths[column.dataKey] = column.width = containerWidth / visibleColumns * (column.flex || 1);
}
}
// Serializing back column settings for storage
columnsSettings[column.dataKey] = this._getColumnPrefsToPersist(column);
}
// Storing back persist settings to account for legacy upgrades
this._storePrefs(columnsSettings);
// Set column width CSS rules
this.onResize(columnWidths);
// Whew, all this just to get a list of columns
}
_initializeStyleMap() {
const stylesheetClass = this._styleKey + "-style";
this._cssSuffix = '-' + this._styleKey;
this._stylesheet = document.querySelector(`.${stylesheetClass}`);
if (this._stylesheet) {
this._columnStyleMap = {};
for (let i = 0; i < this._stylesheet.sheet.cssRules.length; i++) {
const cssText = this._stylesheet.sheet.cssRules[i].cssText;
const dataKey = cssText.slice(1, cssText.indexOf('-'));
this._columnStyleMap[dataKey] = i;
}
for (let i = 0; i < this._virtualizedTable.props.columns.length; i++) {
let column = this._virtualizedTable.props.columns[i];
if (column.dataKey in this._columnStyleMap) continue;
const ruleIndex = Object.keys(this._columnStyleMap).length;
this._stylesheet.sheet.insertRule(`.${column.dataKey + this._cssSuffix} {flex-basis: 100px}`, ruleIndex);
this._columnStyleMap[column.dataKey] = ruleIndex;
}
} else {
this._stylesheet = document.createElementNS("http://www.w3.org/1999/xhtml", 'style');
this._stylesheet.className = stylesheetClass;
document.children[0].appendChild(this._stylesheet);
this._columnStyleMap = {};
for (let i = 0; i < this._virtualizedTable.props.columns.length; i++) {
let column = this._virtualizedTable.props.columns[i];
this._stylesheet.sheet.insertRule(`.${column.dataKey + this._cssSuffix} {flex-basis: 100px}`, i);
this._columnStyleMap[column.dataKey] = i;
}
}
}
_getColumnPrefsToPersist(column) {
let persistKeys = column.zoteroPersist;
if (!persistKeys) persistKeys = new Set();
// Always persist
['ordinal', 'hidden', 'sortDirection'].forEach(k => persistKeys.add(k));
let persistSettings = {};
for (const key in column) {
if (persistKeys.has(key) || key == 'dataKey') {
persistSettings[key] = column[key];
}
}
return persistSettings;
}
_updateVirtualizedTable() {
this._virtualizedTable.forceUpdate(() => {
this._virtualizedTable._jsWindow.invalidate();
});
}
_getPrefs() {
return this._virtualizedTable.props.getColumnPrefs();
}
_storePrefs(prefs) {
this._virtualizedTable.props.storeColumnPrefs(prefs);
}
/**
* Programatically sets the injected CSS width rules for each column.
* This is necessary for performance reasons
*
* @param columnWidths {Object} dictionary of columnId: width (px)
*/
onResize = (columnWidths, storePrefs=false) => {
if (storePrefs) {
var prefs = this._getPrefs();
}
const header = document.querySelector(`#${this._styleKey} .virtualized-table-header`);
const headerWidth = header ? header.getBoundingClientRect().width : 300;
for (let [dataKey, width] of Object.entries(columnWidths)) {
if (typeof dataKey == "number") {
dataKey = this._columns[dataKey].dataKey;
}
const column = this._columns.find(column => column.dataKey == dataKey);
const styleIndex = this._columnStyleMap[dataKey];
if (storePrefs && !column.fixedWidth) {
column.width = width;
prefs[dataKey] = this._getColumnPrefsToPersist(column);
}
if (column.fixedWidth) {
width = column.width;
}
if (column.fixedWidth && column.width || column.staticWidth) {
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('flex', `0 0`, `important`);
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('max-width', `${width}px`, 'important');
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('min-width', `${width}px`, 'important');
} else {
width = (width - COLUMN_PADDING) * COLUMN_NORMALIZATION_WIDTH / headerWidth;
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('flex-basis', `${width}px`);
}
}
if (storePrefs) {
this._storePrefs(prefs);
}
}
setOrder = (index, insertBefore) => {
const column = this._columns[index];
if (column.ordinal == insertBefore) return;
column.ordinal = insertBefore;
this._columns.sort((a, b) => {
// newly inserted column goes before the existing column with same `ordinal` value
if (a.ordinal == b.ordinal) return a == column ? -1 : 1;
return a.ordinal - b.ordinal;
});
let prefs = this._getPrefs();
// reassign columns their ordinal values and set the prefs
this._columns.forEach((column, index) => {
prefs[column.dataKey] = prefs[column.dataKey] || {};
prefs[column.dataKey].ordinal = column.ordinal = index;
});
this._storePrefs(prefs);
this._updateVirtualizedTable();
}
restoreDefaultOrder = () => {
let prefs = this._getPrefs();
if (this._virtualizedTable.props.getDefaultColumnOrder) {
let defaultOrder = this._virtualizedTable.props.getDefaultColumnOrder();
for (const column of this._columns) {
column.ordinal = defaultOrder[column.dataKey];
prefs[column.dataKey].ordinal = defaultOrder[column.dataKey];
}
}
else {
for (const column of this._columns) {
column.ordinal = this._virtualizedTable.props.columns.findIndex(
col => col.dataKey == column.dataKey);
prefs[column.dataKey].ordinal = column.ordinal;
}
}
this._columns.sort((a, b) => a.ordinal - b.ordinal);
this._storePrefs(prefs);
this._updateVirtualizedTable();
}
toggleHidden(index) {
const column = this._columns[index];
column.hidden = !column.hidden;
let prefs = this._getPrefs();
if (prefs[column.dataKey]) {
prefs[column.dataKey].hidden = column.hidden;
}
this._storePrefs(prefs);
this._updateVirtualizedTable();
}
toggleSort(sortIndex) {
if (!this._virtualizedTable.props.onColumnSort) return;
var sortedColumn;
this._columns.forEach((column, index) => {
if (index != sortIndex) {
delete column.sortDirection;
}
else {
sortedColumn = column;
if (column.sortDirection) {
column.sortDirection *= -1;
}
else {
column.sortDirection = column.defaultSort || 1;
}
}
});
this._virtualizedTable.props.onColumnSort(sortIndex, sortedColumn.sortDirection);
this._virtualizedTable.forceUpdate();
}
getAsArray() {
return this._columns;
}
};
function renderCell(index, data, column) {
let span = document.createElementNS("http://www.w3.org/1999/xhtml", 'span');
span.className = `cell ${column.className}`;
span.innerText = data;
return span;
}
function renderCheckboxCell(index, data, column) {
let span = document.createElementNS("http://www.w3.org/1999/xhtml", 'span');
span.className = `cell checkbox ${column.className}`;
span.setAttribute('role', 'checkbox');
span.setAttribute('aria-checked', data);
if (data) {
span.appendChild(getDOMElement('IconTick'));
}
return span;
}
function makeRowRenderer(getRowData) {
return function (index, selection, oldDiv, columns) {
let div;
if (oldDiv) {
div = oldDiv;
div.innerHTML = "";
}
else {
div = document.createElementNS("http://www.w3.org/1999/xhtml", 'div');
div.className = "row";
}
div.classList.toggle('selected', selection.isSelected(index));
div.classList.toggle('focused', selection.focused == index);
const rowData = getRowData(index);
for (let column of columns) {
if (column.hidden) continue;
if (column.type === 'checkbox') {
div.appendChild(renderCheckboxCell(index, rowData[column.dataKey], column));
}
else {
div.appendChild(renderCell(index, rowData[column.dataKey], column));
}
}
return div;
};
}
function formatColumnName(column) {
if (column.label in Zotero.Intl.strings) {
return Zotero.Intl.strings[column.label];
}
else if (/^[^\s]+\w\.\w[^\s]+$/.test(column.label)) {
try {
let labelString = Zotero.getString(column.label);
if (labelString !== column.label) {
return labelString;
}
}
catch (e) {
// ignore missing string
}
}
return column.label;
}
module.exports = injectIntl(VirtualizedTable, { forwardRef: true });
module.exports.TreeSelection = TreeSelection;
module.exports.TreeSelectionStub = TreeSelectionStub;
module.exports.renderCell = renderCell;
module.exports.renderCheckboxCell = renderCheckboxCell;
module.exports.makeRowRenderer = makeRowRenderer;
module.exports.formatColumnName = formatColumnName;