mirror of
https://github.com/zotero/zotero.git
synced 2026-08-28 05:25:31 +00:00
Item tree refactor megacommit
Split ItemTree megaclass into: - ItemTree - concerned with drawing the virtualized table container and column interaction - ItemTreeRowProvider - provides rows and issues notifications for render updates - ItemTreeRow and subclasses - contains row-specific data and rendering logic - CollectionViewItemTree and its accompanying classes - a version of ItemTree that renders items attached to a given Collection or CollectionView (CollectionTreeRow). Various improvements in logic and rendering, separation of concerns.
This commit is contained in:
parent
6b42f9f9c0
commit
5ca1fbb167
28 changed files with 5238 additions and 3208 deletions
|
|
@ -24,7 +24,7 @@
|
|||
*/
|
||||
|
||||
|
||||
import ItemTree from 'zotero/itemTree';
|
||||
import CollectionViewItemTree from 'zotero/collectionViewItemTree';
|
||||
import { COLUMNS } from 'zotero/itemTreeColumns';
|
||||
|
||||
|
||||
|
|
@ -39,6 +39,7 @@ var ZoteroAdvancedSearch = new function () {
|
|||
|
||||
var _searchBox;
|
||||
var _libraryID;
|
||||
var _searchCounter = 0;
|
||||
|
||||
async function onLoad() {
|
||||
_searchBox = document.getElementById('zotero-search-box');
|
||||
|
|
@ -61,16 +62,16 @@ var ZoteroAdvancedSearch = new function () {
|
|||
column.hidden = !['title', 'firstCreator', 'year', 'hasAttachment'].includes(column.dataKey);
|
||||
return column;
|
||||
});
|
||||
this.itemsView = await ItemTree.init(elem, {
|
||||
this.itemsView = await CollectionViewItemTree.init(elem, {
|
||||
id: "advanced-search",
|
||||
dragAndDrop: true,
|
||||
persistColumns: true,
|
||||
columnPicker: true,
|
||||
onActivate: this.onItemActivate.bind(this),
|
||||
columns,
|
||||
});
|
||||
|
||||
await this.itemsView.changeCollectionTreeRow({
|
||||
id: 'advanced-search-' + _searchCounter++,
|
||||
ref: _searchBox.search,
|
||||
visibilityGroup: 'default',
|
||||
isSearchMode: () => true,
|
||||
|
|
@ -103,7 +104,8 @@ var ZoteroAdvancedSearch = new function () {
|
|||
_searchBox.updateSearch();
|
||||
_searchBox.active = true;
|
||||
|
||||
var collectionTreeRow = {
|
||||
return this.itemsView.changeCollectionTreeRow({
|
||||
id: 'advanced-search-' + _searchCounter++,
|
||||
ref: _searchBox.search,
|
||||
visibilityGroup: 'default',
|
||||
isSearchMode: () => true,
|
||||
|
|
@ -115,21 +117,8 @@ var ZoteroAdvancedSearch = new function () {
|
|||
search.libraryID = _libraryID;
|
||||
var ids = await search.search();
|
||||
return Zotero.Items.get(ids);
|
||||
},
|
||||
isLibrary: () => false,
|
||||
isCollection: () => false,
|
||||
isPublications: () => false,
|
||||
isDuplicates: () => false,
|
||||
isFeed: () => false,
|
||||
isFeeds: () => false,
|
||||
isFeedsOrFeed: () => false,
|
||||
isRecentlyRead: () => false,
|
||||
isSortable: () => true,
|
||||
isShare: () => false,
|
||||
isTrash: () => false
|
||||
};
|
||||
|
||||
return this.itemsView.changeCollectionTreeRow(collectionTreeRow);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
const React = require('react');
|
||||
const ReactDOM = require('react-dom');
|
||||
const LibraryTree = require('./libraryTree');
|
||||
const VirtualizedTable = require('components/virtualized-table');
|
||||
const VirtualizedTree = require('components/virtualized-table').VirtualizedTree;
|
||||
const { getCSSIcon } = require('components/icons');
|
||||
const { getDragTargetOrient } = require('components/utils');
|
||||
const { noop } = require("./components/utils");
|
||||
|
|
@ -70,6 +70,8 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
this.type = 'collection';
|
||||
this.name = "CollectionTree";
|
||||
this.id = "collection-tree";
|
||||
this._rows = [];
|
||||
this._rowMap = {};
|
||||
this._highlightedRows = new Set();
|
||||
this._unregisterID = Zotero.Notifier.registerObserver(
|
||||
this,
|
||||
|
|
@ -300,12 +302,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
// Div creation and content
|
||||
let div = oldDiv || document.createElement('div');
|
||||
div.innerHTML = "";
|
||||
// When a hidden focused row is added last during filtering, it
|
||||
// is removed on focus change, which can happen at the same time as rendering.
|
||||
// In this case, just return empty div.
|
||||
if (index >= this._rows.length) {
|
||||
return div;
|
||||
}
|
||||
|
||||
// Classes
|
||||
div.className = "row";
|
||||
|
|
@ -463,7 +459,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
}
|
||||
|
||||
render() {
|
||||
return React.createElement(VirtualizedTable,
|
||||
return React.createElement(VirtualizedTree,
|
||||
{
|
||||
getRowCount: () => this._rows.length,
|
||||
id: this.id,
|
||||
|
|
@ -478,7 +474,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
isContainer: this.isContainer,
|
||||
isContainerEmpty: this.isContainerEmpty,
|
||||
isContainerOpen: this.isContainerOpen,
|
||||
toggleOpenState: this.toggleOpenState,
|
||||
onToggleOpenState: this.toggleOpenState,
|
||||
getRowString: this.getRowString.bind(this),
|
||||
|
||||
onItemContextMenu: (...args) => this.props.onContextMenu && this.props.onContextMenu(...args),
|
||||
|
|
@ -486,7 +482,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
onKeyDown: this.handleKeyDown,
|
||||
onActivate: (...args) => (this.props.onActivate ? this.props.onActivate(...args) : this.handleActivate(...args)),
|
||||
|
||||
role: 'tree',
|
||||
label: Zotero.getString('pane.collections.title')
|
||||
}
|
||||
);
|
||||
|
|
|
|||
1918
chrome/content/zotero/collectionViewItemTree.jsx
Normal file
1918
chrome/content/zotero/collectionViewItemTree.jsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -143,9 +143,10 @@ class TreeSelection {
|
|||
* @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._tree.props.isSelectable(index)) return;
|
||||
if (this.selected.size == 1 && this.isSelected(index)) {
|
||||
this._updateTree(shouldDebounce);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +162,12 @@ class TreeSelection {
|
|||
this._tree.scrollToRow(index);
|
||||
this._updateTree(shouldDebounce);
|
||||
if (this._tree.invalidate) {
|
||||
toInvalidate.forEach(this._tree.invalidateRow.bind(this._tree));
|
||||
const rowCount = this._tree.props.getRowCount();
|
||||
toInvalidate.forEach((idx) => {
|
||||
// this._updateTree() may change row count
|
||||
if (idx >= rowCount) return;
|
||||
this._tree.invalidateRow(idx);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -272,8 +278,9 @@ class TreeSelection {
|
|||
}
|
||||
|
||||
set selectEventsSuppressed(val) {
|
||||
let valChanged = val !== this._selectEventsSuppressed;
|
||||
this._selectEventsSuppressed = val;
|
||||
if (!val) {
|
||||
if (!val && valChanged) {
|
||||
this._updateTree();
|
||||
if (this._tree.invalidate) {
|
||||
this._tree.invalidate();
|
||||
|
|
@ -323,6 +330,8 @@ class VirtualizedTable extends React.Component {
|
|||
this._typingString = "";
|
||||
this._jsWindowID = `virtualized-table-list-${Zotero.Utilities.randomString(5)}`;
|
||||
this._containerWidth = props.containerWidth || window.innerWidth;
|
||||
this.className = props.className || "";
|
||||
this.firstColumnExtraWidth = props.firstColumnExtraWidth || 0;
|
||||
|
||||
this._columns = new Columns(this);
|
||||
|
||||
|
|
@ -362,6 +371,8 @@ class VirtualizedTable extends React.Component {
|
|||
staticColumns: false,
|
||||
alternatingRowColors: Zotero.isMac ? ['-moz-OddTreeRow', '-moz-EvenTreeRow'] : null,
|
||||
|
||||
firstColumnExtraWidth: 0,
|
||||
|
||||
// Render with display: none
|
||||
hide: false,
|
||||
|
||||
|
|
@ -419,6 +430,8 @@ class VirtualizedTable extends React.Component {
|
|||
staticColumns: PropTypes.bool,
|
||||
// Used for initial column widths calculation
|
||||
containerWidth: PropTypes.number,
|
||||
// If first column is injected with extra stuff, like an item icon
|
||||
// and we need to reserve extra min-width for it, set this prop
|
||||
firstColumnExtraWidth: PropTypes.number,
|
||||
|
||||
// Internal windowed-list ref
|
||||
|
|
@ -640,7 +653,7 @@ class VirtualizedTable extends React.Component {
|
|||
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);
|
||||
this.toggleOpenState(this.selection.focused);
|
||||
}
|
||||
else if (parentIndex != -1) {
|
||||
this.onSelection(parentIndex);
|
||||
|
|
@ -651,7 +664,7 @@ class VirtualizedTable extends React.Component {
|
|||
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);
|
||||
this.toggleOpenState(this.selection.focused);
|
||||
}
|
||||
else {
|
||||
this.onSelection(this.selection.focused + 1);
|
||||
|
|
@ -864,10 +877,7 @@ class VirtualizedTable extends React.Component {
|
|||
event.stopPropagation();
|
||||
const result = this._getResizeColumns();
|
||||
if (!result) return;
|
||||
const columns = this._getVisibleColumns();
|
||||
const [aColumn, bColumn, resizingColumn] = result;
|
||||
const isFirstColumn = columns[0].dataKey === aColumn.dataKey;
|
||||
const firstColumnExtraWidth = isFirstColumn ? (this.props.firstColumnExtraWidth || 0) : 0;
|
||||
const a = document.querySelector(`#${this.props.id} .virtualized-table-header .cell.${window.CSS.escape(aColumn.dataKey)}`);
|
||||
const b = document.querySelector(`#${this.props.id} .virtualized-table-header .cell.${window.CSS.escape(bColumn.dataKey)}`);
|
||||
const resizing = document.querySelector(`#${this.props.id} .virtualized-table-header .cell.${window.CSS.escape(resizingColumn.dataKey)}`);
|
||||
|
|
@ -881,9 +891,12 @@ class VirtualizedTable extends React.Component {
|
|||
const widthSum = aRect.width + bRect.width;
|
||||
const aColumnPadding = aColumn.iconLabel ? 0 : COLUMN_PADDING;
|
||||
const bColumnPadding = bColumn.iconLabel ? 0 : COLUMN_PADDING;
|
||||
const aSpacingOffset = (aColumn.minWidth ? aColumn.minWidth : COLUMN_MIN_WIDTH) + aColumnPadding + firstColumnExtraWidth;
|
||||
const bSpacingOffset = (bColumn.minWidth ? bColumn.minWidth : COLUMN_MIN_WIDTH) + bColumnPadding;
|
||||
const aColumnWidth = Math.min(widthSum - bSpacingOffset, Math.max(aSpacingOffset, event.clientX - (RESIZER_WIDTH / 2) - offset));
|
||||
const aMinWidth = (aColumn.minWidth ? aColumn.minWidth : COLUMN_MIN_WIDTH) + aColumnPadding;
|
||||
const bMinWidth = (bColumn.minWidth ? bColumn.minWidth : COLUMN_MIN_WIDTH) + bColumnPadding;
|
||||
const aMaxWidth = widthSum - bMinWidth;
|
||||
const aDragWidth = event.clientX - (RESIZER_WIDTH / 2) - offset;
|
||||
// Constrain the drag position to the min and max widths
|
||||
const aColumnWidth = Math.min(aMaxWidth, Math.max(aMinWidth, aDragWidth));
|
||||
const bColumnWidth = widthSum - aColumnWidth;
|
||||
let onResizeData = {};
|
||||
onResizeData[aColumn.dataKey] = aColumnWidth;
|
||||
|
|
@ -1055,7 +1068,7 @@ class VirtualizedTable extends React.Component {
|
|||
|
||||
this._setXulTooltip();
|
||||
|
||||
this._topDiv.style.setProperty("--firstColumnExtraWidth", `${this.props.firstColumnExtraWidth || 0}px`);
|
||||
this._topDiv.style.setProperty("--first-column-extra-width", `${this.firstColumnExtraWidth}px`);
|
||||
window.addEventListener("resize", () => {
|
||||
this._debouncedRerender();
|
||||
});
|
||||
|
|
@ -1113,13 +1126,13 @@ class VirtualizedTable extends React.Component {
|
|||
return {
|
||||
getItemCount: this.props.getRowCount,
|
||||
itemHeight: this._rowHeight,
|
||||
renderItem: this._renderItem,
|
||||
renderItem: this._renderItem.bind(this),
|
||||
targetElement: document.getElementById(this._jsWindowID),
|
||||
customRowHeights: this.props.customRowHeights ?? []
|
||||
};
|
||||
}
|
||||
|
||||
_renderItem = (index, oldElem = null) => {
|
||||
|
||||
_renderItem(index, oldElem = null) {
|
||||
let node = this.props.renderItem(index, this.selection, oldElem, this._getColumns());
|
||||
if (!node.dataset.eventHandlersAttached) {
|
||||
node.dataset.eventHandlersAttached = true;
|
||||
|
|
@ -1184,7 +1197,7 @@ class VirtualizedTable extends React.Component {
|
|||
if (!column.iconLabel && column.sortDirection) {
|
||||
sortIndicator = <CSSIcon name="sort-indicator" className={"icon-8 sort-indicator " + (column.sortDirection === 1 ? "ascending" : "descending")} />;
|
||||
}
|
||||
const className = cx("cell", column.className, { 'first-column': index === 0, dragging: this.state.draggingColumn == index },
|
||||
const className = cx("cell", column.className, { dragging: this.state.draggingColumn == index },
|
||||
{ "cell-icon": !!column.iconLabel });
|
||||
return (<Draggable
|
||||
onDragStart={this._handleColumnDragStart.bind(this, index)}
|
||||
|
|
@ -1238,7 +1251,9 @@ class VirtualizedTable extends React.Component {
|
|||
{
|
||||
resizing: this.state.resizing,
|
||||
'multi-select': this.props.multiSelect
|
||||
}]),
|
||||
},
|
||||
this.className
|
||||
]),
|
||||
id: this.props.id,
|
||||
ref: ref => this._topDiv = ref,
|
||||
tabIndex: 0,
|
||||
|
|
@ -1408,6 +1423,11 @@ class VirtualizedTable extends React.Component {
|
|||
&& row <= this._jsWindow.getLastVisibleRow();
|
||||
}
|
||||
|
||||
toggleOpenState(index, ...args) {
|
||||
let onToggleOpenState = this.props.toggleOpenState;
|
||||
if (typeof onToggleOpenState == 'function') return onToggleOpenState(index, ...args);
|
||||
}
|
||||
|
||||
async _resetColumns() {
|
||||
this.invalidate();
|
||||
this._columns = new Columns(this);
|
||||
|
|
@ -1424,6 +1444,143 @@ class VirtualizedTable extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VirtualizedTree wraps VirtualizedTable to provide common tree affordances:
|
||||
* - Adds an indent spacer based on depth to the first visible cell
|
||||
* - Adds a twisty for non-empty containers
|
||||
* - Sets tree-specific ARIA attributes on rows and the container
|
||||
* - Wires twisty mouse handlers to toggle container open state
|
||||
*
|
||||
* Consumers should provide isContainer/isContainerEmpty/isContainerOpen/onToggleOpenState
|
||||
* and getParentIndex(index) to compute ancestry.
|
||||
*/
|
||||
class VirtualizedTree extends VirtualizedTable {
|
||||
static propTypes = { ...VirtualizedTable.propTypes,
|
||||
getParentIndex: PropTypes.func.isRequired,
|
||||
isContainer: PropTypes.func.isRequired,
|
||||
isContainerEmpty: PropTypes.func.isRequired,
|
||||
isContainerOpen: PropTypes.func.isRequired,
|
||||
onToggleOpenState: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
_toggledOpenStateIndex = null;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.className += " virtualized-tree";
|
||||
this.firstColumnExtraWidth += 16; // 16px for twisty
|
||||
}
|
||||
|
||||
toggleOpenState(index, ...args) {
|
||||
this._toggledOpenStateIndex = index;
|
||||
return this.props.onToggleOpenState(index, ...args);
|
||||
}
|
||||
|
||||
_renderItem(index, oldElem=null) {
|
||||
let node = super._renderItem(index, oldElem);
|
||||
if (!(node instanceof (node?.ownerDocument?.defaultView || window).Element)) {
|
||||
return node;
|
||||
}
|
||||
node = this._addIndentAndTwisty(node, index);
|
||||
this._setRowAria(node, index);
|
||||
return node;
|
||||
}
|
||||
|
||||
_getDepth(index) {
|
||||
let depth = 0;
|
||||
try {
|
||||
let parent = typeof this.props.getParentIndex == 'function' ? this.props.getParentIndex(index) : -1;
|
||||
while (parent != -1 && typeof parent == 'number') {
|
||||
depth++;
|
||||
parent = this.props.getParentIndex(parent);
|
||||
}
|
||||
}
|
||||
catch (e) {}
|
||||
return depth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an indent spacer and twisty to the first cell of the node
|
||||
*
|
||||
* We add it to the first cell instead of as a separate pseudo-cell or just elements before
|
||||
* the first cell because otherwise it messes with column spacing.
|
||||
*
|
||||
* @param node {HTMLElement} The rendered item row
|
||||
* @param index {number} The index of the node being rendered
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_addIndentAndTwisty(node, index) {
|
||||
let firstCell = node.querySelector('.cell');
|
||||
if (!firstCell) return node;
|
||||
|
||||
let twisty;
|
||||
if (this.props.isContainerEmpty(index)) {
|
||||
twisty = firstCell.querySelector('.spacer-twisty');
|
||||
if (!twisty) {
|
||||
twisty = node.ownerDocument.createElement('span');
|
||||
firstCell.prepend(twisty);
|
||||
twisty.classList.add('spacer-twisty');
|
||||
}
|
||||
firstCell.querySelector(`:scope > .twisty`)?.remove();
|
||||
}
|
||||
else {
|
||||
twisty = firstCell.querySelector('.twisty');
|
||||
if (!twisty) {
|
||||
twisty = getCSSIcon('twisty');
|
||||
twisty.classList.add('twisty');
|
||||
twisty.style.pointerEvents = 'auto';
|
||||
twisty.addEventListener('mousedown', (event) => event.stopPropagation());
|
||||
twisty.addEventListener('mouseup', (event) => {
|
||||
this.toggleOpenState(index);
|
||||
event.stopPropagation();
|
||||
}, { passive: true });
|
||||
twisty.addEventListener('dblclick', (event) => event.stopImmediatePropagation(), { passive: true });
|
||||
firstCell.prepend(twisty);
|
||||
}
|
||||
firstCell.querySelector(`:scope > .spacer-twisty`)?.remove();
|
||||
|
||||
// Apply the twisty animation
|
||||
if (this._toggledOpenStateIndex == index) {
|
||||
twisty.classList.toggle('open', !this.props.isContainerOpen(index));
|
||||
requestAnimationFrame(() => {
|
||||
twisty.classList.toggle('open', this.props.isContainerOpen(index));
|
||||
this._toggledOpenStateIndex = null;
|
||||
});
|
||||
}
|
||||
else {
|
||||
twisty.classList.toggle('open', this.props.isContainerOpen(index));
|
||||
}
|
||||
}
|
||||
|
||||
let indentSpan = firstCell.querySelector('.cell-indent');
|
||||
if (!indentSpan) {
|
||||
indentSpan = node.ownerDocument.createElement('span');
|
||||
indentSpan.className = 'cell-indent';
|
||||
firstCell.prepend(indentSpan);
|
||||
}
|
||||
// Use padding for indent similar to ItemTree
|
||||
const CHILD_INDENT = 16;
|
||||
indentSpan.style.paddingInlineStart = (CHILD_INDENT * this._getDepth(index)) + 'px';
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
_setRowAria(node, index) {
|
||||
const depth = this._getDepth(index);
|
||||
node.setAttribute('role', 'treeitem');
|
||||
node.setAttribute('aria-level', depth + 1);
|
||||
if (!this.props.isContainerEmpty(index)) {
|
||||
node.setAttribute('aria-expanded', !!this.props.isContainerOpen(index));
|
||||
}
|
||||
else {
|
||||
node.removeAttribute('aria-expanded');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VirtualizedTree.propTypes = Object.assign({}, VirtualizedTable.propTypes);
|
||||
VirtualizedTree.defaultProps = Object.assign({}, VirtualizedTable.defaultProps, { role: 'tree' });
|
||||
|
||||
/**
|
||||
* Create a function that calls the given function `fn` only once per animation
|
||||
* frame.
|
||||
|
|
@ -1514,7 +1671,6 @@ var Columns = class {
|
|||
// Storing back persist settings to account for legacy upgrades
|
||||
this._storePrefs(columnsSettings);
|
||||
|
||||
this._adjustColumnWidths();
|
||||
// Set column width CSS rules
|
||||
this.onResize(columnWidths);
|
||||
// Whew, all this just to get a list of columns
|
||||
|
|
@ -1581,24 +1737,6 @@ var Columns = class {
|
|||
this._virtualizedTable.props.storeColumnPrefs(prefs);
|
||||
}
|
||||
|
||||
_adjustColumnWidths = () => {
|
||||
if (!this._virtualizedTable.props.firstColumnExtraWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
const extraWidth = this._virtualizedTable.props.firstColumnExtraWidth;
|
||||
this._columns.filter(c => !c.hidden).forEach((column, index) => {
|
||||
const isFirstColumn = index === 0;
|
||||
if (column.fixedWidth) {
|
||||
column.width = isFirstColumn ? parseInt(column.originalWidth) + extraWidth : column.originalWidth;
|
||||
}
|
||||
if (column.staticWidth) {
|
||||
column.minWidth = isFirstColumn ? (column.originalMinWidth ?? 20) + extraWidth : column.originalMinWidth;
|
||||
column.width = isFirstColumn ? Math.max(parseInt(column.width) ?? 0, column.minWidth) : column.width;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Programatically sets the injected CSS width rules for each column.
|
||||
* This is necessary for performance reasons
|
||||
|
|
@ -1610,11 +1748,14 @@ var Columns = class {
|
|||
var prefs = this._getPrefs();
|
||||
}
|
||||
|
||||
let visibleColumns = this.getAsArray().filter(column => !column.hidden);
|
||||
|
||||
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);
|
||||
if (column.hidden) continue;
|
||||
const styleIndex = this._columnStyleMap[window.CSS.escape(dataKey)];
|
||||
const columnPadding = column.iconLabel ? 0 : COLUMN_PADDING;
|
||||
if (storePrefs && !column.fixedWidth) {
|
||||
|
|
@ -1626,12 +1767,16 @@ var Columns = class {
|
|||
}
|
||||
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');
|
||||
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('max-width', `calc(var(--extra-width, 0px) + ${width}px`, 'important');
|
||||
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('min-width', `calc(var(--extra-width, 0px) + ${width}px`, 'important');
|
||||
} else {
|
||||
// It's set in CSS, so we subtract it here to prevent sliding
|
||||
if (column.dataKey === visibleColumns[0].dataKey) {
|
||||
width -= this._virtualizedTable.firstColumnExtraWidth;
|
||||
}
|
||||
width = (width - columnPadding);
|
||||
Zotero.debug(`Columns ${dataKey} width ${width}`);
|
||||
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('flex-basis', `${width}px`);
|
||||
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('flex-basis', `calc(var(--extra-width, 0px) + ${width}px`);
|
||||
}
|
||||
}
|
||||
if (storePrefs) {
|
||||
|
|
@ -1649,7 +1794,6 @@ var Columns = class {
|
|||
return a.ordinal - b.ordinal;
|
||||
});
|
||||
|
||||
this._adjustColumnWidths();
|
||||
this.onResize(Object.fromEntries(this._columns.map(c => [c.dataKey, c.width])));
|
||||
|
||||
let prefs = this._getPrefs();
|
||||
|
|
@ -1689,7 +1833,6 @@ var Columns = class {
|
|||
this._columns.find(c => c.dataKey === 'title').hidden = false;
|
||||
}
|
||||
|
||||
this._adjustColumnWidths();
|
||||
this.onResize(Object.fromEntries(this._columns.map(c => [c.dataKey, c.width])));
|
||||
this._storePrefs(prefs);
|
||||
this._updateVirtualizedTable();
|
||||
|
|
@ -1699,15 +1842,35 @@ var Columns = class {
|
|||
const column = this._columns[index];
|
||||
column.hidden = !column.hidden;
|
||||
|
||||
if (!column.hidden && !column.width) {
|
||||
column.width = this._computeFlexWidth(column);
|
||||
}
|
||||
|
||||
let prefs = this._getPrefs();
|
||||
if (prefs[column.dataKey]) {
|
||||
prefs[column.dataKey].hidden = column.hidden;
|
||||
}
|
||||
this._adjustColumnWidths();
|
||||
this.onResize(Object.fromEntries(this._columns.map(c => [c.dataKey, c.width])));
|
||||
this._storePrefs(prefs);
|
||||
this._updateVirtualizedTable();
|
||||
}
|
||||
|
||||
_computeFlexWidth(column) {
|
||||
const containerWidth = this._virtualizedTable._containerWidth;
|
||||
const visibleColumns = this._columns.filter(c => !c.hidden);
|
||||
let fixedWidth = 0;
|
||||
let totalFlex = 0;
|
||||
for (let col of visibleColumns) {
|
||||
if (col.fixedWidth || col.staticWidth || !col.flex) {
|
||||
fixedWidth += parseFloat(col.width) || col.minWidth || 0;
|
||||
}
|
||||
else {
|
||||
totalFlex++;
|
||||
}
|
||||
}
|
||||
let availableWidth = containerWidth - fixedWidth;
|
||||
return availableWidth / totalFlex * (column.flex || 1);
|
||||
}
|
||||
|
||||
toggleSort(sortIndex) {
|
||||
if (!this._virtualizedTable.props.onColumnSort) return;
|
||||
|
|
@ -1727,8 +1890,9 @@ var Columns = class {
|
|||
}
|
||||
}
|
||||
});
|
||||
this._virtualizedTable.props.onColumnSort(sortIndex, sortedColumn.sortDirection);
|
||||
let result = this._virtualizedTable.props.onColumnSort(sortIndex, sortedColumn.sortDirection);
|
||||
this._virtualizedTable.forceUpdate();
|
||||
return result;
|
||||
}
|
||||
|
||||
getAsArray() {
|
||||
|
|
@ -1738,8 +1902,8 @@ var Columns = class {
|
|||
|
||||
function renderCell(index, data, column, dir = null) {
|
||||
column = column || { dataKey: "" };
|
||||
if (column.renderer) {
|
||||
return column.renderer(index, data, column, dir);
|
||||
if (column.renderCell) {
|
||||
return column.renderCell(index, data, column, dir);
|
||||
}
|
||||
let span = document.createElement('span');
|
||||
span.className = `cell ${column.className}`;
|
||||
|
|
@ -1881,6 +2045,8 @@ function formatColumnName(column) {
|
|||
}
|
||||
|
||||
module.exports = VirtualizedTable;
|
||||
module.exports.VirtualizedTree = VirtualizedTree;
|
||||
|
||||
module.exports.TreeSelection = TreeSelection;
|
||||
module.exports.TreeSelectionStub = TreeSelectionStub;
|
||||
module.exports.renderCell = renderCell;
|
||||
|
|
|
|||
|
|
@ -214,11 +214,18 @@ module.exports = class {
|
|||
index = Math.max(0, Math.min(index, itemCount - 1));
|
||||
let startPosition = this._getItemPosition(index);
|
||||
let endPosition = this._getItemPosition(index + 1);
|
||||
// If forceScrollToTop is set, always scroll to the start position even if the row is
|
||||
// already visible. This is used when restoring scroll position, where we need an exact
|
||||
// first-visible-row rather than just ensuring the row is within view.
|
||||
if (forceScrollToTop) {
|
||||
this.scrollTo(startPosition);
|
||||
return;
|
||||
}
|
||||
if (startPosition < scrollOffset) {
|
||||
this.scrollTo(startPosition);
|
||||
}
|
||||
else if (endPosition > scrollOffset + height) {
|
||||
this.scrollTo(forceScrollToTop ? startPosition : endPosition - height - 1);
|
||||
this.scrollTo(endPosition - height - 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -114,6 +114,27 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely fetch tags from the current collection tree row, returning [] on search error.
|
||||
* CollectionTreeRow.getTags() calls getSearchResults() under the hood, which throws
|
||||
* Zotero.CollectionTreeRow.SearchError if the underlying search query fails (e.g., a
|
||||
* saved search with invalid conditions). The tag selector should degrade gracefully in
|
||||
* that case — showing no tags — rather than throwing upwards and breaking the UI.
|
||||
* Real bugs (TypeError, etc.) are re-thrown so they surface in tests and logs.
|
||||
*/
|
||||
async _safeGetTags(...args) {
|
||||
try {
|
||||
return await this.collectionTreeRow.getTags(...args);
|
||||
}
|
||||
catch (e) {
|
||||
if (e instanceof Zotero.CollectionTreeRow.SearchError) {
|
||||
Zotero.logError(e);
|
||||
return [];
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Update trigger #1 (triggered by ZoteroPane)
|
||||
async onItemViewChanged({ collectionTreeRow, libraryID }) {
|
||||
Zotero.debug('Updating tag selector from current view');
|
||||
|
|
@ -192,7 +213,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
}
|
||||
// Check tags for each tag type to see if they're in view/scope
|
||||
for (let [type, tagIDs] of tagsByType) {
|
||||
changedTagsInScope.push(...await this.collectionTreeRow.getTags([type], tagIDs));
|
||||
changedTagsInScope.push(...await this._safeGetTags([type], tagIDs));
|
||||
if (this.displayAllTags) {
|
||||
changedTagsInView.push(
|
||||
...await Zotero.Tags.getAllWithin({ libraryID: this.libraryID, tagIDs })
|
||||
|
|
@ -315,7 +336,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
}
|
||||
|
||||
async getTagsAndScope() {
|
||||
var tags = await this.collectionTreeRow.getTags();
|
||||
var tags = await this._safeGetTags();
|
||||
// The scope is all visible tags, not all tags in the library
|
||||
var scope = new Set(tags.map(t => t.tag));
|
||||
if (this.displayAllTags) {
|
||||
|
|
|
|||
|
|
@ -179,7 +179,6 @@
|
|||
|
||||
async merge() {
|
||||
let itembox = document.getElementById('zotero-duplicates-merge-info-box');
|
||||
Zotero.CollectionTreeCache.clear();
|
||||
// Update master item with any field alternatives from the item box
|
||||
let json = this._masterItem.toJSON();
|
||||
// Exclude certain properties that are empty in the cloned object, so we don't clobber them
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
*/
|
||||
|
||||
|
||||
const ItemTree = require('zotero/itemTree');
|
||||
const CollectionViewItemTree = require('zotero/collectionViewItemTree');
|
||||
const { getCSSIcon } = require('components/icons');
|
||||
const { COLUMNS } = require('zotero/itemTreeColumns');
|
||||
var doc, io, ioReadyPromise, ioIsReady, accepted;
|
||||
|
|
@ -660,10 +660,11 @@ class LibraryLayout extends Layout {
|
|||
label: columnLabel,
|
||||
htmlLabel: ' ', // space for column label to appear empty
|
||||
width: 26,
|
||||
hidden: false,
|
||||
staticWidth: true,
|
||||
fixedWidth: true,
|
||||
showInColumnPicker: false,
|
||||
renderer: (index, inCitation, column) => {
|
||||
renderCell: (index, inCitation, column) => {
|
||||
let cell = Helpers.createNode("span", {}, `cell ${column.className} clickable`);
|
||||
if (inCitation === null) {
|
||||
// no icon should be shown when an item cannot be added
|
||||
|
|
@ -688,7 +689,7 @@ class LibraryLayout extends Layout {
|
|||
return cell;
|
||||
}
|
||||
});
|
||||
this.itemsView = await ItemTree.init(itemsTree, {
|
||||
this.itemsView = await CollectionViewItemTree.init(itemsTree, {
|
||||
id: "citationDialog",
|
||||
dragAndDrop: DIALOG_STATE.isCitingItems(),
|
||||
persistColumns: true,
|
||||
|
|
@ -717,7 +718,7 @@ class LibraryLayout extends Layout {
|
|||
if (!isClick) {
|
||||
let lastItemID = items[items.length - 1].id;
|
||||
let rowIndex = this.itemsView.getRowIndexByID(lastItemID);
|
||||
row = doc.querySelector(`#item-tree-citationDialog-row-${rowIndex}`) || row;
|
||||
row = doc.getElementById(`${this.itemsView.id}-row-${rowIndex}`) || row;
|
||||
}
|
||||
let rowTopBeforeRefresh = row.getBoundingClientRect().top;
|
||||
IOManager.addItemsToCitation(items, { noInputRefocus: true }).then(() => {
|
||||
|
|
@ -848,9 +849,14 @@ class LibraryLayout extends Layout {
|
|||
id: collectionTreeRow.id,
|
||||
getItems: async () => {
|
||||
let items = await collectionTreeRow.getItems();
|
||||
// when citing notes, only keep notes or note parents
|
||||
// In add-note mode, note parent checks call item.getNotes(), which requires childItems
|
||||
if (DIALOG_STATE.isAddingNote()) {
|
||||
items = items.filter(item => SearchHandler.isItemWithNotes(item));
|
||||
let regularItems = items.filter(item => SearchHandler.isItemWithNotes(item));
|
||||
if (regularItems.length) {
|
||||
await Zotero.Items.loadDataTypes(regularItems, ['childItems']);
|
||||
}
|
||||
// when citing notes, only keep notes or note parents
|
||||
items = items.filter(item => item.isNote() || item.getNotes().length);
|
||||
}
|
||||
// when adding annotations, only keep annotations, their attachments, and their top-level items
|
||||
if (DIALOG_STATE.isAddingAnnotations()) {
|
||||
|
|
@ -861,9 +867,10 @@ class LibraryLayout extends Layout {
|
|||
isSearch: () => true,
|
||||
isSearchMode: () => true,
|
||||
setSearch: (searchText, mode) => collectionTreeRow.setSearch(searchText, mode),
|
||||
clearCache: () => collectionTreeRow.clearCache(),
|
||||
ref: collectionTreeRow.ref
|
||||
});
|
||||
await this.itemsView.setFilter('search', SearchHandler.searchValue);
|
||||
await this.itemsView.setFilter('citation-search', SearchHandler.searchValue);
|
||||
|
||||
this.itemsView.clearItemsPaneMessage();
|
||||
}
|
||||
|
|
@ -883,7 +890,7 @@ class LibraryLayout extends Layout {
|
|||
|
||||
// click on + icon will add the item to the citation
|
||||
_handleItemsViewIconClick(index) {
|
||||
let rowNode = doc.querySelector(`#item-tree-citationDialog-row-${index}`);
|
||||
let rowNode = doc.getElementById(`${this.itemsView.id}-row-${index}`);
|
||||
let rowTopBeforeRefresh = rowNode.getBoundingClientRect().top;
|
||||
this.itemsView.selection.clearSelection();
|
||||
let row = this.itemsView.getRow(index);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ const ReactDOM = require('react-dom');
|
|||
const diff = require('diff');
|
||||
const VirtualizedTable = require('components/virtualized-table');
|
||||
const { getCSSIcon, IconAttachSmall } = require('components/icons');
|
||||
// TODO: Create a custom row provider for citationExplorer to use with base ItemTree.
|
||||
// Currently uses changeCollectionTreeRow which only exists on CollectionViewItemTree,
|
||||
// so this is broken until we either switch to CollectionViewItemTree or create a
|
||||
// simple row provider that can display arbitrary items.
|
||||
const ItemTree = require('zotero/itemTree');
|
||||
const { getColumnDefinitionsByDataKey } = require('zotero/itemTreeColumns');
|
||||
const { makeRowRenderer } = VirtualizedTable;
|
||||
|
|
@ -48,7 +52,7 @@ const citationColumns = [
|
|||
width: 26,
|
||||
staticWidth: true,
|
||||
fixedWidth: true,
|
||||
renderer: (index, data, column) => {
|
||||
renderCell: (index, data, column) => {
|
||||
let icon = getCSSIcon('IconCross');
|
||||
if (data) {
|
||||
icon = getCSSIcon('IconTick');
|
||||
|
|
@ -72,7 +76,7 @@ itemColumns.push({
|
|||
width: 26,
|
||||
staticWidth: true,
|
||||
fixedWidth: true,
|
||||
renderer: (index, data, column) => {
|
||||
renderCell: (index, data, column) => {
|
||||
let icon = getCSSIcon('IconCross');
|
||||
if (data) {
|
||||
icon = getCSSIcon('IconTick');
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -188,6 +188,7 @@ const COLUMNS = [
|
|||
},
|
||||
{
|
||||
dataKey: "lastRead",
|
||||
sortReverse: true,
|
||||
defaultSort: -1,
|
||||
defaultIn: ["recentlyRead"],
|
||||
disabledIn: ["feeds", "feed"],
|
||||
|
|
|
|||
648
chrome/content/zotero/itemTreeRow.js
Normal file
648
chrome/content/zotero/itemTreeRow.js
Normal file
|
|
@ -0,0 +1,648 @@
|
|||
const { getCSSIcon, getCSSItemTypeIcon } = require('components/icons');
|
||||
const { renderCell: baseRenderCell } = require('components/virtualized-table');
|
||||
const { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
|
||||
|
||||
const lazy = {};
|
||||
XPCOMUtils.defineLazyPreferenceGetter(
|
||||
lazy,
|
||||
"BIDI_BROWSER_UI",
|
||||
"bidi.browser.ui",
|
||||
false
|
||||
);
|
||||
|
||||
const ATTACHMENT_STATE_LOAD_DELAY = 150;
|
||||
|
||||
/**
|
||||
* Base row in an ItemTree.
|
||||
*
|
||||
* Provides safe defaults for all row types. Subclass for specific reference
|
||||
* types (ZoteroItemTreeRow, CollectionItemTreeRow, SearchItemTreeRow, etc.).
|
||||
*/
|
||||
class ItemTreeRow {
|
||||
constructor(ref, level, isOpen, id) {
|
||||
this.ref = ref;
|
||||
this.level = level;
|
||||
this.isOpen = isOpen;
|
||||
this.id = id ?? ref.treeViewID;
|
||||
if (this.id == null) {
|
||||
throw new Error('ItemTreeRow: ref.treeViewID is required (or pass id explicitly)');
|
||||
}
|
||||
}
|
||||
|
||||
get type() {
|
||||
return 'item';
|
||||
}
|
||||
|
||||
get isDraggable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether child rows should be sorted by the tree's active comparator
|
||||
* when this container is opened. Default false preserves the order
|
||||
* returned by getChildItems().
|
||||
*/
|
||||
get sortChildren() {
|
||||
return false;
|
||||
}
|
||||
|
||||
isContainer() {
|
||||
return false;
|
||||
}
|
||||
|
||||
isContainerOpen() {
|
||||
return this.isOpen;
|
||||
}
|
||||
|
||||
isContainerEmpty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
getChildItems() {
|
||||
return [];
|
||||
}
|
||||
|
||||
getBestAttachmentStateCached() {
|
||||
return null;
|
||||
}
|
||||
|
||||
getBestAttachmentState() {
|
||||
return null;
|
||||
}
|
||||
|
||||
numNotes() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
getField(field) {
|
||||
if (Zotero.ItemTreeManager.isCustomColumn(field)) {
|
||||
return Zotero.ItemTreeManager.getCustomCellData(this.ref, field);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
getTypeLabel() {
|
||||
return '';
|
||||
}
|
||||
|
||||
getDisplayTitle() {
|
||||
return '';
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return getCSSItemTypeIcon('document');
|
||||
}
|
||||
|
||||
renderRow(div, index, columns, rowData, renderCtx) {
|
||||
for (let column of columns) {
|
||||
if (column.hidden) continue;
|
||||
div.appendChild(renderCtx.renderCell(index, rowData[column.dataKey], column, column === renderCtx.firstColumn));
|
||||
}
|
||||
}
|
||||
|
||||
renderCell(index, data, column, isFirstColumn) {
|
||||
let cell;
|
||||
if (column.primary) {
|
||||
cell = this.renderPrimaryCell(index, data, column);
|
||||
}
|
||||
else {
|
||||
cell = baseRenderCell(index, data, column, isFirstColumn);
|
||||
if (column.dataKey === 'numNotes' && data) {
|
||||
cell.dataset.l10nId = 'items-table-cell-notes';
|
||||
cell.dataset.l10nArgs = JSON.stringify({ count: data });
|
||||
}
|
||||
else if (column.dataKey === 'itemType') {
|
||||
cell.setAttribute('aria-hidden', true);
|
||||
}
|
||||
}
|
||||
if (column.noPadding) {
|
||||
cell.classList.add('no-padding');
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
renderPrimaryCell(index, data, column) {
|
||||
let span = document.createElement('span');
|
||||
span.className = `cell ${column.className}`;
|
||||
span.classList.add('primary');
|
||||
|
||||
let textSpan = document.createElement('span');
|
||||
textSpan.className = 'cell-text';
|
||||
Zotero.Utilities.Internal.renderItemTitle(data, textSpan);
|
||||
span.append(textSpan);
|
||||
|
||||
return span;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Row wrapping a Zotero.Item (regular items, notes, and non-file attachments).
|
||||
*
|
||||
* Provides field access, container logic for child notes/attachments,
|
||||
* and full primary-cell rendering (tags, retraction marks, BIDI handling).
|
||||
*/
|
||||
class ZoteroItemTreeRow extends ItemTreeRow {
|
||||
get isDraggable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
getField(field, unformatted) {
|
||||
if (this.ref.hasOwnProperty(field) && this.ref[field] != null) {
|
||||
return this.ref[field];
|
||||
}
|
||||
else if (!Zotero.ItemTreeManager.isCustomColumn(field)) {
|
||||
return this.ref.getField(field, unformatted, true);
|
||||
}
|
||||
return Zotero.ItemTreeManager.getCustomCellData(this.ref, field);
|
||||
}
|
||||
|
||||
numNotes() {
|
||||
if (this.ref.isNote()) {
|
||||
return 0;
|
||||
}
|
||||
if (this.ref.isAttachment()) {
|
||||
return this.ref.note !== '' ? 1 : 0;
|
||||
}
|
||||
return this.ref.numNotes(false, true) || 0;
|
||||
}
|
||||
|
||||
isContainer() {
|
||||
return this.ref.isRegularItem();
|
||||
}
|
||||
|
||||
isContainerEmpty({ includeTrashed } = {}) {
|
||||
if (!this.ref.isRegularItem()) {
|
||||
return true;
|
||||
}
|
||||
return this.ref.numNotes(includeTrashed) === 0
|
||||
&& this.ref.numAttachments(includeTrashed) == 0;
|
||||
}
|
||||
|
||||
getChildItems({ includeTrashed, filterChildItems } = {}) {
|
||||
if (!this.ref.isRegularItem()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let attachments = this.ref.getAttachments(includeTrashed);
|
||||
let notes = this.ref.getNotes(includeTrashed);
|
||||
let childIDs;
|
||||
if (attachments.length && notes.length) {
|
||||
childIDs = notes.concat(attachments);
|
||||
}
|
||||
else if (attachments.length) {
|
||||
childIDs = attachments;
|
||||
}
|
||||
else if (notes.length) {
|
||||
childIDs = notes;
|
||||
}
|
||||
else {
|
||||
return [];
|
||||
}
|
||||
|
||||
let items = Zotero.Items.get(childIDs);
|
||||
// TODO: This is a bad pattern after item tree refactor and needs to be fixed (should not be used as an example)
|
||||
// Skip unwanted child items (e.g. in citation dialog)
|
||||
if (filterChildItems) {
|
||||
items = items.filter(filterChildItems);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
_supportsBestAttachmentState() {
|
||||
return this.ref.isRegularItem() && this.ref.numAttachments();
|
||||
}
|
||||
|
||||
getBestAttachmentStateCached() {
|
||||
if (!this._supportsBestAttachmentState()) {
|
||||
return null;
|
||||
}
|
||||
return this.ref.getBestAttachmentStateCached();
|
||||
}
|
||||
|
||||
getBestAttachmentState() {
|
||||
if (!this._supportsBestAttachmentState()) {
|
||||
return null;
|
||||
}
|
||||
return this.ref.getBestAttachmentState();
|
||||
}
|
||||
|
||||
getTypeLabel() {
|
||||
if (!this.ref.itemTypeID) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return Zotero.ItemTypes.getLocalizedString(this.ref.itemTypeID);
|
||||
}
|
||||
catch (e) {
|
||||
Zotero.debug(`Error getting localized item type for ${this.ref.itemTypeID}`, 1);
|
||||
Zotero.debug(e, 1);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
getDisplayTitle() {
|
||||
return this.ref.getDisplayTitle();
|
||||
}
|
||||
|
||||
getAddedBy() {
|
||||
return this.ref.createdByUserID
|
||||
? Zotero.Users.getName(this.ref.createdByUserID) : "";
|
||||
}
|
||||
|
||||
getLastModifiedBy() {
|
||||
return this.ref.lastModifiedByUserID
|
||||
? Zotero.Users.getName(this.ref.lastModifiedByUserID) : this.getAddedBy();
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return getCSSItemTypeIcon(this.ref.getItemTypeIconName());
|
||||
}
|
||||
|
||||
renderCell(index, data, column, isFirstColumn, renderCtx) {
|
||||
if (column.dataKey === 'hasAttachment') {
|
||||
return this.renderHasAttachmentCell(index, data, column, renderCtx);
|
||||
}
|
||||
return super.renderCell(index, data, column, isFirstColumn, renderCtx);
|
||||
}
|
||||
|
||||
renderPrimaryCell(index, data, column) {
|
||||
let span = document.createElement('span');
|
||||
span.className = `cell ${column.className}`;
|
||||
span.classList.add('primary');
|
||||
|
||||
const item = this.ref;
|
||||
let retracted = '';
|
||||
let retractedAriaLabel = '';
|
||||
if (Zotero.Retractions.isRetracted(item)) {
|
||||
retracted = getCSSIcon('cross');
|
||||
retracted.classList.add('icon-16');
|
||||
retracted.classList.add('retracted');
|
||||
retractedAriaLabel = Zotero.getString('retraction.banner');
|
||||
}
|
||||
|
||||
let tagAriaLabel = '';
|
||||
let tagSpans = [];
|
||||
let coloredTags = item.getItemsListTags();
|
||||
if (coloredTags.length) {
|
||||
let { emoji, colored } = coloredTags.reduce((acc, tag) => {
|
||||
acc[Zotero.Utilities.Internal.containsEmoji(tag.tag) ? 'emoji' : 'colored'].push(tag);
|
||||
return acc;
|
||||
}, { emoji: [], colored: [] });
|
||||
|
||||
if (colored.length) {
|
||||
let coloredTagSpans = colored.map(x => this.getTagSwatch(x.tag, x.color));
|
||||
let coloredTagSpanWrapper = document.createElement('span');
|
||||
coloredTagSpanWrapper.className = 'colored-tag-swatches';
|
||||
coloredTagSpanWrapper.append(...coloredTagSpans);
|
||||
tagSpans.push(coloredTagSpanWrapper);
|
||||
}
|
||||
|
||||
tagSpans.push(...emoji.map(x => this.getTagSwatch(x.tag)));
|
||||
|
||||
tagAriaLabel = coloredTags.length == 1 ? Zotero.getString('search-conditions-tag') : Zotero.getString('itemFields.tags');
|
||||
tagAriaLabel += ' ' + coloredTags.map(x => x.tag).join(', ') + '.';
|
||||
}
|
||||
|
||||
let itemTypeAriaLabel = this.getTypeLabel();
|
||||
if (itemTypeAriaLabel) {
|
||||
itemTypeAriaLabel += '.';
|
||||
}
|
||||
|
||||
let textSpan = document.createElement('span');
|
||||
let textWithFullStop = Zotero.Utilities.Internal.renderItemTitle(data, textSpan);
|
||||
if (!textWithFullStop.match(/\.$/)) {
|
||||
textWithFullStop += '.';
|
||||
}
|
||||
let textSpanAriaLabel = [textWithFullStop, itemTypeAriaLabel, tagAriaLabel, retractedAriaLabel]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
textSpan.className = 'cell-text';
|
||||
if (item.itemTypeID && lazy.BIDI_BROWSER_UI) {
|
||||
textSpan.dir = Zotero.ItemFields.getDirection(
|
||||
item.itemTypeID, column.dataKey, item.getField('language')
|
||||
);
|
||||
}
|
||||
textSpan.setAttribute('aria-label', textSpanAriaLabel);
|
||||
|
||||
if (Zotero.Prefs.get('ui.tagsAfterTitle')) {
|
||||
span.append(retracted, textSpan, ...tagSpans);
|
||||
}
|
||||
else {
|
||||
span.append(retracted, ...tagSpans, textSpan);
|
||||
}
|
||||
|
||||
return span;
|
||||
}
|
||||
|
||||
getTagSwatch(tag, color) {
|
||||
let span = document.createElement('span');
|
||||
span.className = 'tag-swatch';
|
||||
let extractedEmojis = Zotero.Tags.extractEmojiForItemsList(tag);
|
||||
if (extractedEmojis) {
|
||||
span.textContent = extractedEmojis;
|
||||
span.className += ' emoji';
|
||||
}
|
||||
else {
|
||||
span.className += ' colored';
|
||||
span.dataset.color = color.toLowerCase();
|
||||
span.style.color = color;
|
||||
}
|
||||
return span;
|
||||
}
|
||||
|
||||
renderHasAttachmentCell(index, data, column, renderCtx = {}) {
|
||||
let span = document.createElement('span');
|
||||
span.className = `cell ${column.className}`;
|
||||
|
||||
if (renderCtx.includeTrashed) {
|
||||
return span;
|
||||
}
|
||||
|
||||
const item = this.ref;
|
||||
if ((!this.isContainer() || !this.isContainerOpen())) {
|
||||
let progressValue = Zotero.Sync.Storage.getItemDownloadProgress(item);
|
||||
if (progressValue) {
|
||||
let progress = document.createElement('progress');
|
||||
progress.value = progressValue;
|
||||
progress.max = 100;
|
||||
progress.style.setProperty('--progress', `${progressValue}%`);
|
||||
progress.className = 'attachment-progress';
|
||||
span.append(progress);
|
||||
return span;
|
||||
}
|
||||
}
|
||||
|
||||
const attachmentState = this.getBestAttachmentStateCached();
|
||||
if (!attachmentState) {
|
||||
return span;
|
||||
}
|
||||
const { type, exists } = attachmentState;
|
||||
let icon;
|
||||
let ariaLabel;
|
||||
if (type !== null && type != 'none') {
|
||||
if (type == 'pdf') {
|
||||
icon = getCSSItemTypeIcon('attachmentPDF', 'attachment-type');
|
||||
ariaLabel = Zotero.getString('pane.item.attachments.hasPDF');
|
||||
}
|
||||
else if (type == 'snapshot') {
|
||||
icon = getCSSItemTypeIcon('attachmentSnapshot', 'attachment-type');
|
||||
ariaLabel = Zotero.getString('pane.item.attachments.hasSnapshot');
|
||||
}
|
||||
else if (type == 'epub') {
|
||||
icon = getCSSItemTypeIcon('attachmentEPUB', 'attachment-type');
|
||||
ariaLabel = Zotero.getString('pane.item.attachments.hasEPUB');
|
||||
}
|
||||
else if (type == 'image') {
|
||||
icon = getCSSItemTypeIcon('attachmentImage', 'attachment-type');
|
||||
ariaLabel = Zotero.getString('pane.item.attachments.hasImage');
|
||||
}
|
||||
else if (type == 'video') {
|
||||
icon = getCSSItemTypeIcon('attachmentVideo', 'attachment-type');
|
||||
ariaLabel = Zotero.getString('pane.item.attachments.hasVideo');
|
||||
}
|
||||
else {
|
||||
icon = getCSSItemTypeIcon('attachmentFile', 'attachment-type');
|
||||
ariaLabel = Zotero.getString('pane.item.attachments.has');
|
||||
}
|
||||
|
||||
if (!exists) {
|
||||
icon.classList.add('icon-missing-file');
|
||||
}
|
||||
}
|
||||
if (icon && ariaLabel) {
|
||||
icon.setAttribute('aria-label', ariaLabel + '.');
|
||||
span.setAttribute('title', ariaLabel);
|
||||
}
|
||||
if (icon) {
|
||||
span.append(icon);
|
||||
}
|
||||
|
||||
let invalidateRow = renderCtx.invalidateRow;
|
||||
if (!invalidateRow) {
|
||||
return span;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
let statePromise = this.getBestAttachmentState();
|
||||
if (!statePromise?.then) {
|
||||
return;
|
||||
}
|
||||
statePromise
|
||||
.then(({ type: newType, exists: newExists } = {}) => {
|
||||
if (newType !== type || newExists !== exists) {
|
||||
invalidateRow(index);
|
||||
}
|
||||
})
|
||||
.catch((e) => Zotero.logError(e));
|
||||
}, ATTACHMENT_STATE_LOAD_DELAY);
|
||||
|
||||
return span;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Row wrapping a file attachment (PDF, snapshot, EPUB, etc.).
|
||||
*
|
||||
* Acts as a container for annotation child rows and overrides title display
|
||||
* to show attachment filenames when configured.
|
||||
*/
|
||||
class FileItemTreeRow extends ZoteroItemTreeRow {
|
||||
isContainer() {
|
||||
return true;
|
||||
}
|
||||
|
||||
isContainerEmpty({ searchMode, searchItemIDs } = {}) {
|
||||
if (Zotero.Prefs.get("hideContextAnnotationRows") && searchMode) {
|
||||
return !this.ref.getAnnotations().some(annotation => searchItemIDs.has(annotation.id));
|
||||
}
|
||||
return this.ref.numAnnotations() == 0;
|
||||
}
|
||||
|
||||
getChildItems({ searchMode, searchItemIDs } = {}) {
|
||||
let annotations = this.ref.getAnnotations();
|
||||
if (Zotero.Prefs.get("hideContextAnnotationRows") && searchMode) {
|
||||
annotations = annotations.filter(annotation => searchItemIDs.has(annotation.id));
|
||||
}
|
||||
return annotations;
|
||||
}
|
||||
|
||||
_supportsBestAttachmentState() {
|
||||
return this.ref.isTopLevelItem();
|
||||
}
|
||||
|
||||
getDisplayTitle() {
|
||||
if (!(this.ref.isSnapshotAttachment()
|
||||
&& /snapshot/i.test(this.ref.getField('title')))
|
||||
&& Zotero.Prefs.get('showAttachmentFilenames')) {
|
||||
try {
|
||||
return this.ref.attachmentFilename;
|
||||
}
|
||||
catch {
|
||||
// Path wasn't parseable - it could be truly invalid, or just
|
||||
// invalid for this platform (e.g., Windows path on macOS/Linux)
|
||||
return this.ref.attachmentPath;
|
||||
}
|
||||
}
|
||||
return this.ref.getDisplayTitle();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Row wrapping an annotation item.
|
||||
*
|
||||
* Never a container. Uses the annotation-specific icon and custom row content
|
||||
* layout used in the items tree.
|
||||
*/
|
||||
class AnnotationItemTreeRow extends ZoteroItemTreeRow {
|
||||
get type() {
|
||||
return 'annotation';
|
||||
}
|
||||
|
||||
isContainer() {
|
||||
return false;
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
let itemType = this.ref.getItemTypeIconName();
|
||||
return getCSSItemTypeIcon(itemType, `annotation-${this.ref.annotationType}-${this.ref.annotationColor}`);
|
||||
}
|
||||
|
||||
renderRow(div, index, columns, rowData, renderCtx) {
|
||||
div.classList.add('annotation-row');
|
||||
div.classList.remove('tight');
|
||||
|
||||
let titleRowData = Object.assign({}, columns.find(column => column.dataKey == 'title'));
|
||||
titleRowData.className = 'title';
|
||||
|
||||
let title;
|
||||
let parserUtils = Cc["@mozilla.org/parserutils;1"].getService(Ci.nsIParserUtils);
|
||||
let plainText = parserUtils.convertToPlainText(this.ref.annotationText || "", Ci.nsIDocumentEncoder.OutputRaw, 0);
|
||||
let plainComment = parserUtils.convertToPlainText(this.ref.annotationComment || "", Ci.nsIDocumentEncoder.OutputRaw, 0);
|
||||
if (["highlight", "underline"].includes(this.ref.annotationType)) {
|
||||
title = renderCtx.renderCell(index, plainText, titleRowData, true);
|
||||
let titleCell = title.querySelector('.cell-text');
|
||||
titleCell.classList.add('italics');
|
||||
titleCell.setAttribute('q-mark-open', Zotero.getString('punctuation.openingQMark'));
|
||||
title.setAttribute('q-mark-close', Zotero.getString('punctuation.closingQMark'));
|
||||
if (this.ref.annotationComment) {
|
||||
let comment = baseRenderCell(null, plainComment, { className: 'annotation-comment' });
|
||||
div.appendChild(comment);
|
||||
}
|
||||
let containsCJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(this.ref.annotationText);
|
||||
div.classList.toggle('tight', !containsCJK);
|
||||
}
|
||||
else if (this.ref.annotationComment) {
|
||||
title = renderCtx.renderCell(index, plainComment, titleRowData, true);
|
||||
}
|
||||
else {
|
||||
let annotationTypeName = Zotero.getString(`reader-${this.ref.annotationType}-annotation`);
|
||||
title = renderCtx.renderCell(index, annotationTypeName, titleRowData, true);
|
||||
}
|
||||
div.prepend(title);
|
||||
|
||||
let addToCitationColumn = columns.find(column => column.dataKey == 'addToCitation');
|
||||
if (addToCitationColumn) {
|
||||
let data = rowData?.[addToCitationColumn.dataKey];
|
||||
let cell = renderCtx.renderCell(index, data, addToCitationColumn, false);
|
||||
if (cell) {
|
||||
div.append(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Row wrapping a Zotero.Collection (shown in trash view).
|
||||
*/
|
||||
class CollectionItemTreeRow extends ItemTreeRow {
|
||||
get type() {
|
||||
return 'collection';
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
let icon = getCSSIcon('collection');
|
||||
icon.classList.add('icon-item-type');
|
||||
return icon;
|
||||
}
|
||||
|
||||
getTypeLabel() {
|
||||
return Zotero.getString('search-conditions-collection');
|
||||
}
|
||||
|
||||
getDisplayTitle() {
|
||||
return this.ref.name;
|
||||
}
|
||||
|
||||
getField(field) {
|
||||
if (field == 'title') {
|
||||
return this.ref.name;
|
||||
}
|
||||
if (Zotero.ItemTreeManager.isCustomColumn(field)) {
|
||||
return Zotero.ItemTreeManager.getCustomCellData(this.ref, field);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
renderPrimaryCell(index, data, column) {
|
||||
return super.renderPrimaryCell(index, data, column);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Row wrapping a Zotero.Search (saved search, shown in trash view).
|
||||
*/
|
||||
class SearchItemTreeRow extends ItemTreeRow {
|
||||
get type() {
|
||||
return 'search';
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
let icon = getCSSIcon('search');
|
||||
icon.classList.add('icon-item-type');
|
||||
return icon;
|
||||
}
|
||||
|
||||
getTypeLabel() {
|
||||
return Zotero.getString('search-conditions-savedSearch');
|
||||
}
|
||||
|
||||
getDisplayTitle() {
|
||||
return this.ref.name;
|
||||
}
|
||||
|
||||
getField(field) {
|
||||
if (field == 'title') {
|
||||
return this.ref.name;
|
||||
}
|
||||
if (Zotero.ItemTreeManager.isCustomColumn(field)) {
|
||||
return Zotero.ItemTreeManager.getCustomCellData(this.ref, field);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
renderPrimaryCell(index, data, column) {
|
||||
return super.renderPrimaryCell(index, data, column);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the appropriate ItemTreeRow subclass for a reference object.
|
||||
*
|
||||
* Dispatch order: Collection, Search, annotation item, file attachment item,
|
||||
* generic Zotero.Item, and finally the base ItemTreeRow fallback.
|
||||
*/
|
||||
ItemTreeRow.create = function (ref, level, isOpen) {
|
||||
if (ref instanceof Zotero.Collection) return new CollectionItemTreeRow(ref, level, isOpen);
|
||||
if (ref instanceof Zotero.Search) return new SearchItemTreeRow(ref, level, isOpen);
|
||||
if (ref.isAnnotation?.()) return new AnnotationItemTreeRow(ref, level, isOpen);
|
||||
if (ref.isFileAttachment?.()) return new FileItemTreeRow(ref, level, isOpen);
|
||||
return new ZoteroItemTreeRow(ref, level, isOpen);
|
||||
};
|
||||
|
||||
module.exports = ItemTreeRow;
|
||||
module.exports.ItemTreeRow = ItemTreeRow;
|
||||
module.exports.ZoteroItemTreeRow = ZoteroItemTreeRow;
|
||||
module.exports.FileItemTreeRow = FileItemTreeRow;
|
||||
module.exports.AnnotationItemTreeRow = AnnotationItemTreeRow;
|
||||
module.exports.CollectionItemTreeRow = CollectionItemTreeRow;
|
||||
module.exports.SearchItemTreeRow = SearchItemTreeRow;
|
||||
|
|
@ -33,14 +33,11 @@ const React = require('react');
|
|||
var LibraryTree = class LibraryTree extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this._rows = [];
|
||||
this._rowMap = {};
|
||||
|
||||
this.domEl = props.domEl;
|
||||
this._ownerDocument = props.domEl.ownerDocument;
|
||||
|
||||
this.onSelect = this.createEventBinding('select');
|
||||
this.onRefresh = this.createEventBinding('refresh');
|
||||
}
|
||||
|
||||
get window() {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
*/
|
||||
|
||||
import CollectionTree from 'zotero/collectionTree';
|
||||
import ItemTree from 'zotero/itemTree';
|
||||
import CollectionViewItemTree from 'zotero/collectionViewItemTree';
|
||||
|
||||
var itemsView;
|
||||
var collectionsView;
|
||||
|
|
@ -63,7 +63,7 @@ var doLoad = async function () {
|
|||
if(io.addBorder) document.getElementsByTagName("dialog")[0].style.border = "1px solid black";
|
||||
if(io.singleSelection) document.getElementById("zotero-items-tree").setAttribute("seltype", "single");
|
||||
|
||||
itemsView = await ItemTree.init(document.getElementById('zotero-items-tree'), {
|
||||
itemsView = await CollectionViewItemTree.init(document.getElementById('zotero-items-tree'), {
|
||||
onSelectionChange: () => {
|
||||
if (isEditBibliographyDialog) {
|
||||
Zotero_Bibliography_Dialog.treeItemSelected();
|
||||
|
|
@ -77,7 +77,6 @@ var doLoad = async function () {
|
|||
},
|
||||
id: io.itemTreeID || "select-items-dialog",
|
||||
dragAndDrop: false,
|
||||
persistColumns: true,
|
||||
regularOnly: io.onlyRegularItems,
|
||||
columnPicker: true,
|
||||
multiSelect: io.multiSelect,
|
||||
|
|
|
|||
|
|
@ -32,8 +32,40 @@ Zotero.CollectionTreeRow = function (collectionTreeView, type, ref, level, isOpe
|
|||
this.level = level || 0;
|
||||
this.isOpen = isOpen || false;
|
||||
this.onUnload = null;
|
||||
this.searchText = "";
|
||||
this.searchMode = "search";
|
||||
this.tags = new Set();
|
||||
|
||||
// Per-instance search cache. Within a single refresh cycle, multiple consumers need the
|
||||
// same search results — getItems() for the items pane and getTags() for the tag selector
|
||||
// both call getSearchResults(), and getSearchResults() calls getSearchObject(). This cache
|
||||
// ensures the underlying DB query only runs once per cycle. Call clearCache() to invalidate
|
||||
// (e.g., at the start of a refresh, or when filters change).
|
||||
//
|
||||
// On search failure (e.g., a saved search with invalid conditions), getSearchResults() throws
|
||||
// a Zotero.CollectionTreeRow.SearchError. This is caught in
|
||||
// CollectionViewItemTreeRowProvider.refresh() to show a load-error message without bricking
|
||||
// the UI, so the user can still edit/delete the broken search. See the catch block in
|
||||
// refresh() for details.
|
||||
this._cachedResults = null;
|
||||
this._cachedSearch = null;
|
||||
this._cachedTempTable = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown by CollectionTreeRow.getSearchResults() when the underlying
|
||||
* Zotero.Search query fails (e.g., a saved search with invalid conditions).
|
||||
* Caught by CollectionViewItemTreeRowProvider.refresh() to show a load-error
|
||||
* message without bricking the UI.
|
||||
*/
|
||||
Zotero.CollectionTreeRow.SearchError = class SearchError extends Error {
|
||||
constructor(cause) {
|
||||
super('ZoteroSearchError');
|
||||
this.name = 'ZoteroSearchError';
|
||||
this.cause = cause;
|
||||
}
|
||||
};
|
||||
|
||||
Zotero.CollectionTreeRow.IDCounter = 0;
|
||||
|
||||
|
||||
|
|
@ -322,32 +354,24 @@ Zotero.CollectionTreeRow.prototype.getItems = async function () {
|
|||
};
|
||||
|
||||
Zotero.CollectionTreeRow.prototype.getSearchResults = async function (asTempTable) {
|
||||
if (Zotero.CollectionTreeCache.lastTreeRow && Zotero.CollectionTreeCache.lastTreeRow.id !== this.id) {
|
||||
Zotero.CollectionTreeCache.clear();
|
||||
}
|
||||
|
||||
if(!Zotero.CollectionTreeCache.lastResults) {
|
||||
if (!this._cachedResults) {
|
||||
let s = await this.getSearchObject();
|
||||
Zotero.CollectionTreeCache.error = false;
|
||||
try {
|
||||
Zotero.CollectionTreeCache.lastResults = await s.search();
|
||||
this._cachedResults = await s.search();
|
||||
}
|
||||
catch (e) {
|
||||
Zotero.logError(e);
|
||||
Zotero.CollectionTreeCache.lastResults = [];
|
||||
// Flag error so ZoteroPane::onCollectionSelected() can show a message
|
||||
Zotero.CollectionTreeCache.error = true;
|
||||
throw new Zotero.CollectionTreeRow.SearchError(e);
|
||||
}
|
||||
Zotero.CollectionTreeCache.lastTreeRow = this;
|
||||
}
|
||||
|
||||
if(asTempTable) {
|
||||
if(!Zotero.CollectionTreeCache.lastTempTable) {
|
||||
Zotero.CollectionTreeCache.lastTempTable = await Zotero.Search.idsToTempTable(Zotero.CollectionTreeCache.lastResults);
|
||||
if (asTempTable) {
|
||||
if (!this._cachedTempTable) {
|
||||
this._cachedTempTable = await Zotero.Search.idsToTempTable(this._cachedResults);
|
||||
}
|
||||
return Zotero.CollectionTreeCache.lastTempTable;
|
||||
return this._cachedTempTable;
|
||||
}
|
||||
return Zotero.CollectionTreeCache.lastResults;
|
||||
return this._cachedResults;
|
||||
};
|
||||
|
||||
/*
|
||||
|
|
@ -356,14 +380,10 @@ Zotero.CollectionTreeRow.prototype.getSearchResults = async function (asTempTabl
|
|||
* This accounts for the collection, saved search, quicksearch, tags, etc.
|
||||
*/
|
||||
Zotero.CollectionTreeRow.prototype.getSearchObject = async function () {
|
||||
if (Zotero.CollectionTreeCache.lastTreeRow && Zotero.CollectionTreeCache.lastTreeRow.id !== this.id) {
|
||||
Zotero.CollectionTreeCache.clear();
|
||||
if (this._cachedSearch) {
|
||||
return this._cachedSearch;
|
||||
}
|
||||
|
||||
if(Zotero.CollectionTreeCache.lastSearch) {
|
||||
return Zotero.CollectionTreeCache.lastSearch;
|
||||
}
|
||||
|
||||
var s;
|
||||
var includeScopeChildren = false;
|
||||
|
||||
|
|
@ -456,8 +476,7 @@ Zotero.CollectionTreeRow.prototype.getSearchObject = async function () {
|
|||
}
|
||||
}
|
||||
|
||||
Zotero.CollectionTreeCache.lastTreeRow = this;
|
||||
Zotero.CollectionTreeCache.lastSearch = s2;
|
||||
this._cachedSearch = s2;
|
||||
return s2;
|
||||
};
|
||||
|
||||
|
|
@ -488,15 +507,54 @@ Zotero.CollectionTreeRow.prototype.getTags = async function (types, tagIDs) {
|
|||
};
|
||||
|
||||
|
||||
/**
|
||||
* Clear the per-instance search cache. Call this at the start of a refresh cycle
|
||||
* or when search/tag filters change, so the next getSearchResults()/getSearchObject()
|
||||
* call runs a fresh DB query.
|
||||
*/
|
||||
Zotero.CollectionTreeRow.prototype.clearCache = function () {
|
||||
this._cachedSearch = null;
|
||||
if (this._cachedTempTable) {
|
||||
let tableName = this._cachedTempTable;
|
||||
let id = Zotero.DB.addCallback('commit', async function () {
|
||||
await Zotero.DB.queryAsync(
|
||||
"DROP TABLE IF EXISTS " + tableName, false, { noCache: true }
|
||||
);
|
||||
Zotero.DB.removeCallback('commit', id);
|
||||
});
|
||||
}
|
||||
this._cachedTempTable = null;
|
||||
this._cachedResults = null;
|
||||
};
|
||||
|
||||
Zotero.CollectionTreeRow.prototype.setSearch = function (searchText, mode = null) {
|
||||
Zotero.CollectionTreeCache.clear();
|
||||
if (this.searchText === searchText && this.searchMode === mode) {
|
||||
return false;
|
||||
}
|
||||
this.clearCache();
|
||||
this.searchText = searchText;
|
||||
this.searchMode = mode;
|
||||
return true;
|
||||
}
|
||||
|
||||
Zotero.CollectionTreeRow.prototype.setTags = function (tags) {
|
||||
Zotero.CollectionTreeCache.clear();
|
||||
this.tags = tags;
|
||||
let oldTags = this.tags instanceof Set ? this.tags : new Set(this.tags || []);
|
||||
let newTags = tags instanceof Set ? new Set(tags) : new Set(tags || []);
|
||||
if (oldTags.size === newTags.size) {
|
||||
let hasChanges = false;
|
||||
for (let tag of newTags) {
|
||||
if (!oldTags.has(tag)) {
|
||||
hasChanges = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasChanges) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this.clearCache();
|
||||
this.tags = newTags;
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
@ -507,6 +565,8 @@ Zotero.CollectionTreeRow.prototype.isSearchMode = function () {
|
|||
case 'search':
|
||||
case 'publications':
|
||||
case 'trash':
|
||||
case 'unfiled':
|
||||
case 'recentlyRead':
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -524,26 +584,3 @@ Zotero.CollectionTreeRow.prototype.isSearchMode = function () {
|
|||
Zotero.CollectionTreeRow.prototype.isSortable = function () {
|
||||
return !this.isFeedsOrFeed() && !this.isRecentlyRead();
|
||||
}
|
||||
|
||||
Zotero.CollectionTreeCache = {
|
||||
"lastTreeRow":null,
|
||||
"lastTempTable":null,
|
||||
"lastSearch":null,
|
||||
"lastResults":null,
|
||||
|
||||
"clear": function () {
|
||||
this.lastTreeRow = null;
|
||||
this.lastSearch = null;
|
||||
if (this.lastTempTable) {
|
||||
let tableName = this.lastTempTable;
|
||||
let id = Zotero.DB.addCallback('commit', async function () {
|
||||
await Zotero.DB.queryAsync(
|
||||
"DROP TABLE IF EXISTS " + tableName, false, { noCache: true }
|
||||
);
|
||||
Zotero.DB.removeCallback('commit', id);
|
||||
});
|
||||
}
|
||||
this.lastTempTable = null;
|
||||
this.lastResults = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4009,7 +4009,7 @@ Zotero.Item.prototype.getAttachments = function (includeTrashed) {
|
|||
*/
|
||||
Zotero.Item.prototype.getBestAttachment = async function () {
|
||||
if (!this.isRegularItem()) {
|
||||
throw ("getBestAttachment() can only be called on regular items");
|
||||
throw new Error(`getBestAttachment() can only be called on regular items. Called on ${this.attachmentContentType}`);
|
||||
}
|
||||
var attachments = await this.getBestAttachments();
|
||||
let bestAttachment = attachments ? attachments[0] : false;
|
||||
|
|
@ -4061,7 +4061,7 @@ Zotero.Item.prototype.getBestAttachmentState = async function () {
|
|||
if (this._bestAttachmentState !== null && this._bestAttachmentState.type) {
|
||||
return this._bestAttachmentState;
|
||||
}
|
||||
var item = this.isAttachment() && this.isTopLevelItem()
|
||||
var item = !this.isRegularItem()
|
||||
? this
|
||||
: await this.getBestAttachment();
|
||||
if (!item) {
|
||||
|
|
|
|||
|
|
@ -2017,7 +2017,7 @@ Zotero.Utilities.Internal = {
|
|||
*/
|
||||
makeClassEventDispatcher: function (cls) {
|
||||
cls.prototype._events = null;
|
||||
cls.prototype.runListeners = async function (event) {
|
||||
cls.prototype.runListeners = async function (event, ...args) {
|
||||
// Zotero.debug(`Running ${event} listeners on ${cls.toString()}`);
|
||||
if (!this._events) this._events = {};
|
||||
if (!this._events[event]) {
|
||||
|
|
@ -2030,7 +2030,7 @@ Zotero.Utilities.Internal = {
|
|||
// at the time of runListeners() call to prevent triggering listeners that are added right
|
||||
// runListeners() invocation
|
||||
for (let [listener, once] of Array.from(this._events[event].listeners.entries())) {
|
||||
await Promise.resolve(listener.call(this));
|
||||
await Promise.resolve(listener.call(this, ...args));
|
||||
if (once) {
|
||||
this._events[event].listeners.delete(listener);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1659,12 +1659,11 @@ var ZoteroPane = new function () {
|
|||
|
||||
this.initItemsTree = async function () {
|
||||
try {
|
||||
const ItemTree = require('zotero/itemTree');
|
||||
const CollectionViewItemTree = require('zotero/collectionViewItemTree');
|
||||
var itemsTree = document.getElementById('zotero-items-tree');
|
||||
ZoteroPane.itemsView = await ItemTree.init(itemsTree, {
|
||||
ZoteroPane.itemsView = await CollectionViewItemTree.init(itemsTree, {
|
||||
id: "main",
|
||||
dragAndDrop: true,
|
||||
persistColumns: true,
|
||||
columnPicker: true,
|
||||
onSelectionChange: selection => ZoteroPane.itemSelected(selection),
|
||||
onContextMenu: (...args) => ZoteroPane.onItemsContextMenuOpen(...args),
|
||||
|
|
@ -1857,7 +1856,7 @@ var ZoteroPane = new function () {
|
|||
}
|
||||
}
|
||||
|
||||
this.itemsView.changeCollectionTreeRow(collectionTreeRow);
|
||||
await this.itemsView.changeCollectionTreeRow(collectionTreeRow);
|
||||
|
||||
Zotero.Prefs.set('lastViewedFolder', collectionTreeRow.id);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ ConcurrentCaller.prototype._processNext = function () {
|
|||
if (!task) {
|
||||
if (this._numRunning == 0 && !this._pausing) {
|
||||
this._log("All tasks are done");
|
||||
this._deferred.resolve();
|
||||
this._deferred?.resolve();
|
||||
}
|
||||
else {
|
||||
this._log("Nothing left to run -- waiting for running tasks to complete");
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.first-column {
|
||||
.cell:first-child {
|
||||
&::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
|
|
@ -42,11 +42,11 @@
|
|||
position: absolute;
|
||||
inset-inline-start: -8px;
|
||||
|
||||
@include state(".first-column:hover") {
|
||||
@include state(".cell:hover") {
|
||||
background: var(--material-mix-quinary);
|
||||
}
|
||||
|
||||
@include state(".first-column.dragging") {
|
||||
@include state(".cell.dragging") {
|
||||
background: var(--material-mix-quarternary);
|
||||
}
|
||||
}
|
||||
|
|
@ -110,13 +110,13 @@
|
|||
.icon-item-type + .colored-tag-swatches,
|
||||
.annotation-icon + .tag-swatch,
|
||||
.annotation-icon + .colored-tag-swatches {
|
||||
margin-inline-start: 4px;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
.emoji + .emoji,
|
||||
.emoji + .colored-tag-swatches,
|
||||
.colored-tag-swatches + .emoji {
|
||||
margin-inline-start: 4px;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
.tag-swatch {
|
||||
|
|
@ -245,6 +245,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
.cell:not(.hasAttachment) .item-icon {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
.cell .annotation-icon {
|
||||
-moz-context-properties: fill;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@
|
|||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
// Used in virtualized-table.jsx when setting column widths
|
||||
// to reserve extra space for the first column. See onResize()
|
||||
--extra-width: 0px;
|
||||
--first-column-extra-width: 0px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
|
@ -55,12 +60,13 @@
|
|||
padding-inline-end: 4px;
|
||||
}
|
||||
|
||||
&.first-column {
|
||||
&:first-child {
|
||||
--extra-width: var(--first-column-extra-width, 0px);
|
||||
// No padding on the first cell since it's done via twisty and indent
|
||||
padding-inline-start: 0;
|
||||
min-width: calc(var(--firstColumnExtraWidth, 0px) + 30px);
|
||||
}
|
||||
|
||||
&.first-column,
|
||||
&:first-child,
|
||||
&.primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -73,12 +79,6 @@
|
|||
flex-grow: 1;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
&:not(:first-child) {
|
||||
@include state(".cell.first-column:not(.hasAttachment)") {
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.twisty + .cell-text, .spacer-twisty + .cell-text {
|
||||
|
|
@ -286,11 +286,9 @@
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.first-column {
|
||||
> :first-child {
|
||||
// offset header's column label/icon to align with the text in the first column, without introducing padding to the cell itself to avoid flexbox issues
|
||||
padding-inline-start: calc(var(--firstColumnExtraWidth, 0px) + 8px);
|
||||
}
|
||||
.cell:first-child > :first-child {
|
||||
// offset header's column label/icon to align with the text in the first column, without introducing padding to the cell itself to avoid flexbox issues
|
||||
padding-inline-start: var(--first-column-extra-width, 0px);
|
||||
}
|
||||
|
||||
.cell {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,11 @@ describe("Citation Dialog", function () {
|
|||
beforeEach(async function () {
|
||||
// Many operations (e.g. IOManager.addItemsToCitation) are disabled
|
||||
// when search runs. Search can be triggered by a variety of events
|
||||
// so before each test, we make sure that search has finished running
|
||||
// (notably the window "focus" listener, which kicks off a search after
|
||||
// a 100ms delay). Before each test, wait out that delay, then wait for
|
||||
// any searches — including ones that only just started during the
|
||||
// delay — to finish running.
|
||||
await Zotero.Promise.delay(150);
|
||||
while (SearchHandler.searching) {
|
||||
await Zotero.Promise.delay(10);
|
||||
}
|
||||
|
|
@ -218,11 +222,12 @@ describe("Citation Dialog", function () {
|
|||
|
||||
// open popup
|
||||
let firstBubble = CitationDataManager.items[0];
|
||||
IOManager._openItemDetailsPopup(firstBubble.dialogReferenceID);
|
||||
let popup = dialog.document.getElementById("itemDetails");
|
||||
|
||||
// give the popup time to open
|
||||
await Zotero.Promise.delay(50);
|
||||
let popupOpenPromise = popup.state == "open"
|
||||
? Promise.resolve()
|
||||
: waitForDOMEvent(popup, "popupshown");
|
||||
IOManager._openItemDetailsPopup(firstBubble.dialogReferenceID);
|
||||
await popupOpenPromise;
|
||||
assert.equal(popup.state, "open");
|
||||
|
||||
// set locator/suffix/prefix values
|
||||
|
|
@ -472,7 +477,7 @@ describe("Citation Dialog", function () {
|
|||
await dialog.libraryLayout.itemsView.selectItem(itemOne.id);
|
||||
// Make sure the row node is highlighted
|
||||
let rowIndex = dialog.libraryLayout.itemsView.getRowIndexByID(itemOne.id);
|
||||
let rowID = "item-tree-citationDialog-row-" + rowIndex;
|
||||
let rowID = `${dialog.libraryLayout.itemsView.id}-row-${rowIndex}`;
|
||||
let rowNode = dialog.document.getElementById(rowID);
|
||||
assert.isTrue(rowNode.classList.contains("highlighted"));
|
||||
});
|
||||
|
|
@ -882,9 +887,11 @@ describe("Citation Dialog", function () {
|
|||
let bubble = dialog.document.querySelector("bubble-input .bubble");
|
||||
let popup = dialog.document.getElementById("itemDetails");
|
||||
|
||||
let popupOpenPromise = popup.state == "open"
|
||||
? Promise.resolve()
|
||||
: waitForDOMEvent(popup, "popupshown");
|
||||
bubble.click();
|
||||
// give the popup time to open
|
||||
await Zotero.Promise.delay(50);
|
||||
await popupOpenPromise;
|
||||
assert.equal(popup.state, "open");
|
||||
|
||||
// make sure the annotation-row preview is visible in the popup
|
||||
|
|
|
|||
|
|
@ -15,6 +15,122 @@ describe("CollectionTreeRow", function () {
|
|||
after(function () {
|
||||
win.close();
|
||||
});
|
||||
|
||||
describe("Search cache", function () {
|
||||
var collectionTreeRow;
|
||||
|
||||
// Stub getSearchObject on a collectionTreeRow so that the search's .search() throws,
|
||||
// simulating a broken saved search (e.g., "too many SQL variables").
|
||||
function stubBrokenSearch(ctr) {
|
||||
return sinon.stub(ctr, 'getSearchObject').resolves({
|
||||
search: () => { throw new Error('simulated search failure'); }
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
collectionTreeRow = zp.getCollectionTreeRow();
|
||||
collectionTreeRow.clearCache();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
collectionTreeRow.setSearch('');
|
||||
collectionTreeRow.setTags([]);
|
||||
});
|
||||
|
||||
it("should memoize getSearchResults() within a refresh cycle", async function () {
|
||||
var results1 = await collectionTreeRow.getSearchResults();
|
||||
var results2 = await collectionTreeRow.getSearchResults();
|
||||
assert.strictEqual(results1, results2, 'should return same cached array');
|
||||
});
|
||||
|
||||
it("should memoize getSearchObject() within a refresh cycle", async function () {
|
||||
var search1 = await collectionTreeRow.getSearchObject();
|
||||
var search2 = await collectionTreeRow.getSearchObject();
|
||||
assert.strictEqual(search1, search2, 'should return same cached search');
|
||||
});
|
||||
|
||||
it("should invalidate cache on clearCache()", async function () {
|
||||
var results1 = await collectionTreeRow.getSearchResults();
|
||||
collectionTreeRow.clearCache();
|
||||
var results2 = await collectionTreeRow.getSearchResults();
|
||||
assert.notStrictEqual(results1, results2, 'should return new array after clearCache()');
|
||||
});
|
||||
|
||||
it("should invalidate cache when setSearch() changes filters", async function () {
|
||||
await collectionTreeRow.getSearchResults();
|
||||
assert.isNotNull(collectionTreeRow._cachedResults);
|
||||
|
||||
collectionTreeRow.setSearch('test-query');
|
||||
assert.isNull(collectionTreeRow._cachedResults);
|
||||
assert.isNull(collectionTreeRow._cachedSearch);
|
||||
});
|
||||
|
||||
it("should not invalidate cache when setSearch() is called with same value", async function () {
|
||||
collectionTreeRow.setSearch('same');
|
||||
|
||||
await collectionTreeRow.getSearchResults();
|
||||
var cached = collectionTreeRow._cachedResults;
|
||||
assert.isNotNull(cached);
|
||||
|
||||
collectionTreeRow.setSearch('same');
|
||||
assert.strictEqual(collectionTreeRow._cachedResults, cached,
|
||||
'cache should survive idempotent setSearch()');
|
||||
});
|
||||
|
||||
it("should throw SearchError on search failure", async function () {
|
||||
var stub = stubBrokenSearch(collectionTreeRow);
|
||||
try {
|
||||
var err;
|
||||
try {
|
||||
await collectionTreeRow.getSearchResults();
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
assert.ok(err, 'getSearchResults() should throw');
|
||||
assert.instanceOf(err, Zotero.CollectionTreeRow.SearchError);
|
||||
}
|
||||
finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("should propagate SearchError through getItems()", async function () {
|
||||
var stub = stubBrokenSearch(collectionTreeRow);
|
||||
try {
|
||||
var err;
|
||||
try {
|
||||
await collectionTreeRow.getItems();
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
assert.ok(err, 'getItems() should throw');
|
||||
assert.instanceOf(err, Zotero.CollectionTreeRow.SearchError);
|
||||
}
|
||||
finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("should propagate SearchError through getTags()", async function () {
|
||||
var stub = stubBrokenSearch(collectionTreeRow);
|
||||
try {
|
||||
var err;
|
||||
try {
|
||||
await collectionTreeRow.getTags();
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
assert.ok(err, 'getTags() should throw');
|
||||
assert.instanceOf(err, Zotero.CollectionTreeRow.SearchError);
|
||||
}
|
||||
finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unfiled Items", function () {
|
||||
// https://github.com/zotero/zotero/issues/2771
|
||||
|
|
|
|||
|
|
@ -1443,25 +1443,9 @@ describe("Zotero.CollectionTree", function () {
|
|||
feedItem.setField('url', url);
|
||||
await feedItem.saveTx();
|
||||
var translateFn = sinon.spy(feedItem, 'translate');
|
||||
|
||||
// Add observer to wait for collection-item add, using setTimeout
|
||||
// to ensure all synchronous notifier processing completes first
|
||||
var deferred = Zotero.Promise.defer();
|
||||
var observerID = Zotero.Notifier.registerObserver({
|
||||
notify: function (event, type, ids) {
|
||||
if (type == 'collection-item' && event == 'add'
|
||||
&& ids.some(id => id.startsWith(collection.id + "-"))) {
|
||||
setTimeout(function () {
|
||||
deferred.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 'collection-item', 'test');
|
||||
|
||||
await onDrop('item', 'C' + collection.id, [feedItem.id], deferred.promise);
|
||||
|
||||
Zotero.Notifier.unregisterObserver(observerID);
|
||||
|
||||
|
||||
var ids = ((await onDrop('item', 'C' + collection.id, [feedItem.id]))).ids;
|
||||
|
||||
// Check that the translated item was the one that was created after drag
|
||||
var item = await translateFn.returnValues[0];
|
||||
assert.ok(item, 'Translation should return an item');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
// Integration tests for CollectionViewItemTree via ZoteroPane.itemsView.
|
||||
// Inherited ItemTree/ItemTreeRowProvider behavior is also tested through
|
||||
// the CVIT instance.
|
||||
"use strict";
|
||||
|
||||
describe("Zotero.ItemTree", function () {
|
||||
describe("CollectionViewItemTree", function () {
|
||||
var win, zp, cv, itemsView;
|
||||
var existingItemID;
|
||||
var existingItemID2;
|
||||
|
|
@ -47,7 +50,7 @@ describe("Zotero.ItemTree", function () {
|
|||
|
||||
Zotero.Prefs.clear('recursiveCollections');
|
||||
});
|
||||
|
||||
|
||||
describe("when performing a quick search", function () {
|
||||
let quicksearch;
|
||||
|
||||
|
|
@ -179,7 +182,7 @@ describe("Zotero.ItemTree", function () {
|
|||
assert.equal(quicksearch.value, "test");
|
||||
});
|
||||
|
||||
it("should hide context annotation rows if hideContextAnnotationRows=true", async function () {
|
||||
it("should keep attachment rows collapsed unless search matches annotation text when hideContextAnnotationRows=true", async function () {
|
||||
Zotero.Prefs.set("hideContextAnnotationRows", true);
|
||||
|
||||
let item = await createDataObject('item', { title: "Item" });
|
||||
|
|
@ -197,22 +200,22 @@ describe("Zotero.ItemTree", function () {
|
|||
let attachmentTwo = await importFileAttachment('test.pdf', { title: 'PDF test', parentItemID: item.id });
|
||||
let highlightTwo = await createAnnotation('highlight', attachmentTwo, { comment: "Highlight te" });
|
||||
|
||||
// "te" search - all rows are visible
|
||||
await zp.itemsView.setFilter('search', "te");
|
||||
// Search matching attachment title only should keep attachment rows collapsed
|
||||
await zp.itemsView.setFilter('search', "PDF test");
|
||||
|
||||
assert.isNumber(itemsView.getRowIndexByID(attachmentOne.id));
|
||||
assert.isNumber(itemsView.getRowIndexByID(highlightOne.id));
|
||||
assert.isNumber(itemsView.getRowIndexByID(underlineOne.id));
|
||||
assert.isNumber(itemsView.getRowIndexByID(attachmentTwo.id));
|
||||
assert.isNumber(itemsView.getRowIndexByID(highlightTwo.id));
|
||||
let attachmentTwoRow = itemsView.getRowIndexByID(attachmentTwo.id);
|
||||
assert.isNumber(attachmentTwoRow);
|
||||
assert.isFalse(itemsView.isContainerOpen(attachmentTwoRow));
|
||||
assert.isFalse(itemsView.getRowIndexByID(highlightTwo.id));
|
||||
|
||||
// "test" search - only annotations with "testing" remain
|
||||
await zp.itemsView.setFilter('search', "test");
|
||||
// Search matching annotation text should reveal matching annotation rows
|
||||
await zp.itemsView.setFilter('search', "testing");
|
||||
|
||||
assert.isNumber(itemsView.getRowIndexByID(attachmentOne.id));
|
||||
let attachmentOneRow = itemsView.getRowIndexByID(attachmentOne.id);
|
||||
assert.isNumber(attachmentOneRow);
|
||||
assert.isTrue(itemsView.isContainerOpen(attachmentOneRow));
|
||||
assert.isNumber(itemsView.getRowIndexByID(underlineOne.id));
|
||||
assert.isFalse(itemsView.getRowIndexByID(highlightOne.id));
|
||||
assert.isNumber(itemsView.getRowIndexByID(attachmentTwo.id));
|
||||
assert.isFalse(itemsView.getRowIndexByID(highlightTwo.id));
|
||||
});
|
||||
});
|
||||
|
|
@ -311,16 +314,28 @@ describe("Zotero.ItemTree", function () {
|
|||
});
|
||||
|
||||
describe("Expand/Collapse all rows", function () {
|
||||
let item1, item2, attachment1, attachment2;
|
||||
let collection, item1, item2, emptyItem, attachment1, attachment2;
|
||||
|
||||
before(async () => {
|
||||
collection = await createDataObject('collection');
|
||||
// Top-level items, attachment child per each, one annotation per attachment
|
||||
item1 = await createDataObject('item', { title: 'Item 1' });
|
||||
item2 = await createDataObject('item', { title: 'Item 2' });
|
||||
item1 = await createDataObject('item', { title: 'Item 1', collections: [collection.id] });
|
||||
item2 = await createDataObject('item', { title: 'Item 2', collections: [collection.id] });
|
||||
attachment1 = await importFileAttachment('test.pdf', { title: 'Attachment 1', parentItemID: item1.id });
|
||||
attachment2 = await importFileAttachment('test.pdf', { title: 'Attachment 2', parentItemID: item2.id });
|
||||
await createAnnotation('highlight', attachment1);
|
||||
await createAnnotation('highlight', attachment2);
|
||||
// An empty top-level item (no children) to test that it doesn't block level progression
|
||||
emptyItem = await createDataObject('item', { title: 'Empty Item', collections: [collection.id] });
|
||||
await select(win, collection);
|
||||
itemsView = zp.itemsView;
|
||||
await waitForItemsLoad(win);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await select(win, collection);
|
||||
itemsView = zp.itemsView;
|
||||
await waitForItemsLoad(win);
|
||||
});
|
||||
|
||||
it("should expand all top-level rows when all rows are collapsed", async function () {
|
||||
|
|
@ -401,7 +416,6 @@ describe("Zotero.ItemTree", function () {
|
|||
|
||||
itemsView.collapseAllRows();
|
||||
|
||||
|
||||
// All top-level items should be expanded
|
||||
assert.isTrue(itemsView.isContainerOpen(itemsView.getRowIndexByID(item1.id)));
|
||||
assert.isTrue(itemsView.isContainerOpen(itemsView.getRowIndexByID(item2.id)));
|
||||
|
|
@ -423,7 +437,124 @@ describe("Zotero.ItemTree", function () {
|
|||
})
|
||||
})
|
||||
|
||||
describe.skip("#sort()", function () {
|
||||
describe("#_saveOpenState() / #_restoreOpenState()", function () {
|
||||
it("should restore open containers and keep closed containers closed", async function () {
|
||||
let item1 = await createDataObject('item', { title: 'Item 1' });
|
||||
let att1 = await importFileAttachment('test.pdf', { parentItemID: item1.id });
|
||||
let ann1 = await createAnnotation('highlight', att1);
|
||||
|
||||
let item2 = await createDataObject('item', { title: 'Item 2' });
|
||||
await importFileAttachment('test.png', { parentItemID: item2.id });
|
||||
|
||||
let item3 = await createDataObject('item', { title: 'Item 3' });
|
||||
let att3 = await importFileAttachment('test.png', { parentItemID: item3.id });
|
||||
|
||||
await waitForItemsLoad(win);
|
||||
|
||||
let rowProvider = itemsView.rowProvider;
|
||||
// Deep nesting for item1: open item1 -> attachment -> annotation path
|
||||
assert.isTrue(rowProvider._expandToItem(ann1.id));
|
||||
// Open item3 only
|
||||
assert.isTrue(rowProvider._expandToItem(att3.id));
|
||||
|
||||
let item1Row = itemsView.getRowIndexByID(item1.id);
|
||||
let att1Row = itemsView.getRowIndexByID(att1.id);
|
||||
let item2Row = itemsView.getRowIndexByID(item2.id);
|
||||
let item3Row = itemsView.getRowIndexByID(item3.id);
|
||||
assert.isTrue(itemsView.isContainerOpen(item1Row));
|
||||
assert.isTrue(itemsView.isContainerOpen(att1Row));
|
||||
assert.isFalse(itemsView.isContainerOpen(item2Row));
|
||||
assert.isTrue(itemsView.isContainerOpen(item3Row));
|
||||
|
||||
let openItemIDs = rowProvider._saveOpenState();
|
||||
|
||||
// _saveOpenState closes top-level open containers
|
||||
item1Row = itemsView.getRowIndexByID(item1.id);
|
||||
item2Row = itemsView.getRowIndexByID(item2.id);
|
||||
item3Row = itemsView.getRowIndexByID(item3.id);
|
||||
assert.isFalse(itemsView.isContainerOpen(item1Row));
|
||||
assert.isFalse(itemsView.isContainerOpen(item2Row));
|
||||
assert.isFalse(itemsView.isContainerOpen(item3Row));
|
||||
assert.isFalse(itemsView.getRowIndexByID(att1.id));
|
||||
|
||||
rowProvider._restoreOpenState(openItemIDs);
|
||||
|
||||
item1Row = itemsView.getRowIndexByID(item1.id);
|
||||
att1Row = itemsView.getRowIndexByID(att1.id);
|
||||
item2Row = itemsView.getRowIndexByID(item2.id);
|
||||
item3Row = itemsView.getRowIndexByID(item3.id);
|
||||
assert.isTrue(itemsView.isContainerOpen(item1Row));
|
||||
assert.isTrue(itemsView.isContainerOpen(att1Row));
|
||||
assert.isFalse(itemsView.isContainerOpen(item2Row));
|
||||
assert.isTrue(itemsView.isContainerOpen(item3Row));
|
||||
});
|
||||
});
|
||||
|
||||
describe("#toggleOpenState()", function () {
|
||||
it("shouldn't scroll back to selected row when opening another container", async function () {
|
||||
var collection = await createDataObject('collection');
|
||||
await select(win, collection);
|
||||
itemsView = zp.itemsView;
|
||||
|
||||
var treebox = itemsView._treebox;
|
||||
var numVisibleRows = treebox.getLastVisibleRow() - treebox.getFirstVisibleRow();
|
||||
|
||||
function getTitle(i, max) {
|
||||
return new String(new Array(max + 1).join(0) + i).slice(-1 * max);
|
||||
}
|
||||
|
||||
var num = numVisibleRows * 2 + 10;
|
||||
var parentItem = await createDataObject('item', {
|
||||
title: getTitle(0, num + 1),
|
||||
collections: [collection.id]
|
||||
});
|
||||
await importFileAttachment('test.png', { parentItemID: parentItem.id });
|
||||
|
||||
var itemIDs = [];
|
||||
await Zotero.DB.executeTransaction(async function () {
|
||||
for (let i = 1; i <= num; i++) {
|
||||
let item = createUnsavedDataObject('item', {
|
||||
title: getTitle(i, num + 1),
|
||||
collections: [collection.id]
|
||||
});
|
||||
await item.save();
|
||||
itemIDs.push(item.id);
|
||||
}
|
||||
});
|
||||
await waitForItemsLoad(win);
|
||||
|
||||
var parentRow = itemsView.getRowIndexByID(parentItem.id);
|
||||
var selectedItemID;
|
||||
var maxDistance = -1;
|
||||
for (let id of itemIDs) {
|
||||
let row = itemsView.getRowIndexByID(id);
|
||||
let distance = Math.abs(row - parentRow);
|
||||
if (distance > maxDistance) {
|
||||
maxDistance = distance;
|
||||
selectedItemID = id;
|
||||
}
|
||||
}
|
||||
assert.isAbove(maxDistance, numVisibleRows);
|
||||
|
||||
await itemsView.selectItem(selectedItemID);
|
||||
assert.sameMembers(itemsView.getSelectedItems(true), [selectedItemID]);
|
||||
|
||||
treebox.scrollToRow(parentRow);
|
||||
var firstVisibleBefore = treebox.getFirstVisibleRow();
|
||||
assert.isFalse(itemsView.tree.rowIsVisible(itemsView.getRowIndexByID(selectedItemID)));
|
||||
assert.isFalse(itemsView.isContainerOpen(parentRow));
|
||||
|
||||
await itemsView.toggleOpenState(parentRow);
|
||||
await itemsView.waitForLoad();
|
||||
|
||||
assert.isTrue(itemsView.isContainerOpen(itemsView.getRowIndexByID(parentItem.id)));
|
||||
assert.sameMembers(itemsView.getSelectedItems(true), [selectedItemID]);
|
||||
assert.equal(treebox.getFirstVisibleRow(), firstVisibleBefore);
|
||||
assert.isFalse(itemsView.tree.rowIsVisible(itemsView.getRowIndexByID(selectedItemID)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("#sort()", function () {
|
||||
it("should ignore invalid secondary-sort field", async function () {
|
||||
await createDataObject('item', { title: 'A' });
|
||||
await createDataObject('item', { title: 'A' });
|
||||
|
|
@ -453,6 +584,47 @@ describe("Zotero.ItemTree", function () {
|
|||
assert.isFalse(e);
|
||||
assert.equal(Zotero.Prefs.get('fallbackSort'), originalFallback);
|
||||
});
|
||||
|
||||
it("should preserve open container state when sorting", async function () {
|
||||
let parentItem = await createDataObject('item', { title: 'Parent' });
|
||||
let attachment = await importFileAttachment('test.pdf', { parentItemID: parentItem.id });
|
||||
await createAnnotation('highlight', attachment);
|
||||
|
||||
await waitForItemsLoad(win);
|
||||
itemsView.expandAllRows(true);
|
||||
await waitForItemsLoad(win);
|
||||
|
||||
let parentRow = itemsView.getRowIndexByID(parentItem.id);
|
||||
let attachmentRow = itemsView.getRowIndexByID(attachment.id);
|
||||
assert.isTrue(itemsView.isContainerOpen(parentRow));
|
||||
assert.isTrue(itemsView.isContainerOpen(attachmentRow));
|
||||
|
||||
await itemsView.sort();
|
||||
|
||||
parentRow = itemsView.getRowIndexByID(parentItem.id);
|
||||
attachmentRow = itemsView.getRowIndexByID(attachment.id);
|
||||
assert.isTrue(itemsView.isContainerOpen(parentRow));
|
||||
assert.isTrue(itemsView.isContainerOpen(attachmentRow));
|
||||
});
|
||||
|
||||
it("should await sort context readiness before sorting", async function () {
|
||||
let deferred = Zotero.Promise.defer();
|
||||
let ensureStub = sinon.stub(itemsView, '_ensureSortContextReady').returns(deferred.promise);
|
||||
let sortStub = sinon.stub(itemsView.rowProvider, 'sort');
|
||||
|
||||
try {
|
||||
let sortPromise = itemsView.sort();
|
||||
await Zotero.Promise.delay(20);
|
||||
assert.equal(sortStub.callCount, 0);
|
||||
deferred.resolve();
|
||||
await sortPromise;
|
||||
assert.equal(sortStub.callCount, 1);
|
||||
}
|
||||
finally {
|
||||
sortStub.restore();
|
||||
ensureStub.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("#notify()", function () {
|
||||
|
|
@ -620,10 +792,8 @@ describe("Zotero.ItemTree", function () {
|
|||
item.setField('title', 'no select on modify');
|
||||
await item.saveTx();
|
||||
|
||||
// itemSelected should have been called once (from 'selectEventsSuppressed = false'
|
||||
// in notify()) as a no-op
|
||||
assert.equal(win.ZoteroPane.itemSelected.callCount, 1);
|
||||
assert.isFalse(await win.ZoteroPane.itemSelected.returnValues[0]);
|
||||
// itemSelected should not have been called
|
||||
assert.equal(win.ZoteroPane.itemSelected.callCount, 0);
|
||||
|
||||
// Modified item should not be selected
|
||||
assert.lengthOf(itemsView.getSelectedItems(), 0);
|
||||
|
|
@ -646,8 +816,8 @@ describe("Zotero.ItemTree", function () {
|
|||
item.setField('title', 'maintain selection on modify');
|
||||
await item.saveTx();
|
||||
|
||||
// itemSelected should have been called once (from 'selectEventsSuppressed = false'
|
||||
// in notify()) as a no-op
|
||||
// itemSelected should have been called once from restoreSelection
|
||||
// due to potential resort on modification
|
||||
assert.equal(win.ZoteroPane.itemSelected.callCount, 1);
|
||||
assert.isFalse(await win.ZoteroPane.itemSelected.returnValues[0]);
|
||||
|
||||
|
|
@ -755,9 +925,9 @@ describe("Zotero.ItemTree", function () {
|
|||
assert.equal(itemsView.getRow(treebox.getFirstVisibleRow()).ref.id, firstVisibleItemID);
|
||||
});
|
||||
|
||||
it.skip("should keep first visible selected item in position when other items are added with skipSelect", function* () {
|
||||
var collection = yield createDataObject('collection');
|
||||
yield select(win, collection);
|
||||
it("should keep first visible selected item in position when other items are added with skipSelect", async function () {
|
||||
var collection = await createDataObject('collection');
|
||||
await select(win, collection);
|
||||
itemsView = zp.itemsView;
|
||||
|
||||
var treebox = itemsView._treebox;
|
||||
|
|
@ -769,14 +939,14 @@ describe("Zotero.ItemTree", function () {
|
|||
}
|
||||
|
||||
var num = numVisibleRows + 10;
|
||||
yield Zotero.DB.executeTransaction(async function () {
|
||||
await Zotero.DB.executeTransaction(async function () {
|
||||
for (let i = 0; i < num; i++) {
|
||||
let title = getTitle(i, num);
|
||||
let item = createUnsavedDataObject('item', { title });
|
||||
item.addToCollection(collection.id);
|
||||
await item.save();
|
||||
}
|
||||
}.bind(this));
|
||||
});
|
||||
|
||||
// Scroll halfway
|
||||
treebox.scrollToRow(Math.round(num / 2) - Math.round(numVisibleRows / 2));
|
||||
|
|
@ -790,11 +960,11 @@ describe("Zotero.ItemTree", function () {
|
|||
var item = createUnsavedDataObject(
|
||||
'item', { title: getTitle(0, num), collections: [collection.id] }
|
||||
);
|
||||
yield item.saveTx({
|
||||
await item.saveTx({
|
||||
skipSelect: true
|
||||
});
|
||||
// Then add a few more in a transaction
|
||||
yield Zotero.DB.executeTransaction(async function () {
|
||||
await Zotero.DB.executeTransaction(async function () {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
var item = createUnsavedDataObject(
|
||||
'item', { title: getTitle(0, num), collections: [collection.id] }
|
||||
|
|
@ -803,7 +973,7 @@ describe("Zotero.ItemTree", function () {
|
|||
skipSelect: true
|
||||
});
|
||||
}
|
||||
}.bind(this));
|
||||
});
|
||||
|
||||
// Make sure the selected item is still at the same position
|
||||
assert.equal(itemsView.getSelectedItems()[0], selectedItem);
|
||||
|
|
@ -1105,6 +1275,18 @@ describe("Zotero.ItemTree", function () {
|
|||
assert.isTrue(itemsView.isContainerOpen(itemsView.getRowIndexByID(item2.id)));
|
||||
assert.equal(noteRowIndex, secondItemRowIndex + 1);
|
||||
});
|
||||
|
||||
it("should not expand an empty parent item when attachment is added", async function () {
|
||||
let item2RowIndex = itemsView.getRowIndexByID(item2.id);
|
||||
assert.isFalse(itemsView.isContainerOpen(item2RowIndex));
|
||||
|
||||
// Add attachment to item2
|
||||
await importFileAttachment('test.png', { parentItemID: item2.id });
|
||||
|
||||
// Verify item2 is still collapsed
|
||||
item2RowIndex = itemsView.getRowIndexByID(item2.id);
|
||||
assert.isFalse(itemsView.isContainerOpen(item2RowIndex));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Recently Read", function () {
|
||||
|
|
@ -1447,7 +1629,34 @@ describe("Zotero.ItemTree", function () {
|
|||
assert.isNumber(itemsView.getRowIndexByID(c1.treeViewID));
|
||||
assert.isFalse(itemsView.getRowIndexByID(c2.treeViewID));
|
||||
assert.isFalse(itemsView.getRowIndexByID(c3.treeViewID));
|
||||
})
|
||||
});
|
||||
|
||||
it("should assign collection/search row types in trash", async function () {
|
||||
let collection = await createDataObject('collection', { deleted: true });
|
||||
let search = await createDataObject('search', { deleted: true });
|
||||
|
||||
await selectTrash(win);
|
||||
|
||||
let collectionRowIndex = itemsView.getRowIndexByID(collection.treeViewID);
|
||||
let searchRowIndex = itemsView.getRowIndexByID(search.treeViewID);
|
||||
assert.isNumber(collectionRowIndex);
|
||||
assert.isNumber(searchRowIndex);
|
||||
assert.equal(itemsView.getRow(collectionRowIndex).type, 'collection');
|
||||
assert.equal(itemsView.getRow(searchRowIndex).type, 'search');
|
||||
});
|
||||
|
||||
it("should sort by hasAttachment in trash without crashing", async function () {
|
||||
await createDataObject('collection', { deleted: true });
|
||||
await createDataObject('search', { deleted: true });
|
||||
await createDataObject('item', { deleted: true });
|
||||
await selectTrash(win);
|
||||
|
||||
let columnIndex = itemsView._getColumns().findIndex(column => column.dataKey == 'hasAttachment');
|
||||
assert.isAtLeast(columnIndex, 0);
|
||||
|
||||
await itemsView._handleColumnSort(columnIndex, 1);
|
||||
assert.isAbove(itemsView.rowCount, 0);
|
||||
});
|
||||
|
||||
it("should restore all subcollections when parent is restored", async function () {
|
||||
var c1 = await createDataObject('collection', { deleted: true });
|
||||
|
|
@ -1723,8 +1932,6 @@ describe("Zotero.ItemTree", function () {
|
|||
})
|
||||
|
||||
await promise;
|
||||
// Attachment add triggers multiple notifications and multiple select events
|
||||
await itemsView.waitForSelect();
|
||||
var items = itemsView.getSelectedItems();
|
||||
var path = await items[0].getFilePathAsync();
|
||||
assert.equal(
|
||||
|
|
@ -2193,6 +2400,133 @@ describe("Zotero.ItemTree", function () {
|
|||
});
|
||||
});
|
||||
|
||||
describe("#_expandToItem()", function () {
|
||||
it("should expand all ancestors for a nested annotation", async function () {
|
||||
let parentItem = await createDataObject('item', { title: 'Parent Item' });
|
||||
let attachment = await importFileAttachment('test.pdf', { parentItemID: parentItem.id });
|
||||
let annotation = await createAnnotation('highlight', attachment);
|
||||
await waitForItemsLoad(win);
|
||||
|
||||
itemsView.collapseAllRows();
|
||||
await waitForItemsLoad(win);
|
||||
|
||||
let collapsedParentRow = itemsView.getRowIndexByID(parentItem.id);
|
||||
assert.isNumber(collapsedParentRow);
|
||||
assert.isFalse(itemsView.isContainerOpen(collapsedParentRow));
|
||||
assert.isFalse(itemsView.getRowIndexByID(attachment.id));
|
||||
assert.isFalse(itemsView.getRowIndexByID(annotation.id));
|
||||
|
||||
let expanded = itemsView.rowProvider._expandToItem(annotation.id);
|
||||
assert.isTrue(expanded);
|
||||
|
||||
let parentRow = itemsView.getRowIndexByID(parentItem.id);
|
||||
let attachmentRow = itemsView.getRowIndexByID(attachment.id);
|
||||
assert.isTrue(itemsView.isContainerOpen(parentRow));
|
||||
assert.isTrue(itemsView.isContainerOpen(attachmentRow));
|
||||
assert.isNumber(itemsView.getRowIndexByID(annotation.id));
|
||||
});
|
||||
});
|
||||
|
||||
describe("#setCollectionTreeRow()", function () {
|
||||
it("should no-op when setting the same row", async function () {
|
||||
let rowProvider = itemsView.rowProvider;
|
||||
let currentRow = rowProvider.collectionTreeRow;
|
||||
assert.ok(currentRow);
|
||||
|
||||
let refreshSpy = sinon.spy(rowProvider, 'refresh');
|
||||
|
||||
try {
|
||||
await rowProvider.setCollectionTreeRow(currentRow);
|
||||
assert.equal(refreshSpy.callCount, 0);
|
||||
}
|
||||
finally {
|
||||
refreshSpy.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("#setFilter()", function () {
|
||||
it("should refresh when search filter value changes", async function () {
|
||||
let rowProvider = itemsView.rowProvider;
|
||||
let refreshSpy = sinon.spy(rowProvider, 'refresh');
|
||||
let setSearchStub = sinon.stub(rowProvider.collectionTreeRow, 'setSearch').returns(true);
|
||||
|
||||
try {
|
||||
await rowProvider.setFilter('search', 'changed-search');
|
||||
assert.isTrue(setSearchStub.calledOnceWithExactly('changed-search'));
|
||||
assert.isTrue(refreshSpy.calledOnceWithExactly({ restoreSelection: true }));
|
||||
}
|
||||
finally {
|
||||
setSearchStub.restore();
|
||||
refreshSpy.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("should not refresh when filter value is unchanged", async function () {
|
||||
let rowProvider = itemsView.rowProvider;
|
||||
let refreshSpy = sinon.spy(rowProvider, 'refresh');
|
||||
let setSearchStub = sinon.stub(rowProvider.collectionTreeRow, 'setSearch').returns(false);
|
||||
|
||||
try {
|
||||
await rowProvider.setFilter('search', 'unchanged-search');
|
||||
assert.equal(refreshSpy.callCount, 0);
|
||||
}
|
||||
finally {
|
||||
setSearchStub.restore();
|
||||
refreshSpy.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("#_refresh()", function () {
|
||||
it("should await sort context readiness before sorting", async function () {
|
||||
let rowProvider = itemsView.rowProvider;
|
||||
let deferred = Zotero.Promise.defer();
|
||||
let ensureStub = sinon.stub(itemsView, '_ensureSortContextReady').returns(deferred.promise);
|
||||
let sortSpy = sinon.spy(rowProvider, '_sort');
|
||||
|
||||
try {
|
||||
let refreshPromise = rowProvider._refresh();
|
||||
await Zotero.Promise.delay(20);
|
||||
assert.equal(sortSpy.callCount, 0);
|
||||
deferred.resolve();
|
||||
await refreshPromise;
|
||||
assert.isTrue(sortSpy.called);
|
||||
}
|
||||
finally {
|
||||
sortSpy.restore();
|
||||
ensureStub.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("#handleRowModelUpdate()", function () {
|
||||
it("should clear selection and return false when loading is true", async function () {
|
||||
await itemsView.waitForLoad();
|
||||
let item = await createDataObject('item');
|
||||
await waitForItemsLoad(win);
|
||||
|
||||
let row = itemsView.getRowIndexByID(item.id);
|
||||
assert.isNumber(row);
|
||||
itemsView.selection.select(row);
|
||||
assert.equal(itemsView.selection.count, 1);
|
||||
|
||||
let setMessageSpy = sinon.spy(itemsView, 'setItemsPaneMessage');
|
||||
|
||||
try {
|
||||
let done = await itemsView.handleRowModelUpdate([], { loading: true });
|
||||
assert.isFalse(done);
|
||||
assert.equal(itemsView.selection.count, 0);
|
||||
assert.equal(itemsView.selection.focused, 0);
|
||||
assert.isTrue(setMessageSpy.calledOnce);
|
||||
assert.equal(setMessageSpy.firstCall.args[0], Zotero.getString('pane.items.loading'));
|
||||
}
|
||||
finally {
|
||||
setMessageSpy.restore();
|
||||
await itemsView.clearItemsPaneMessage();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("#_restoreSelection()", function () {
|
||||
it("should reselect collection in trash", async function () {
|
||||
|
|
@ -2214,9 +2548,31 @@ describe("Zotero.ItemTree", function () {
|
|||
zp.itemsView._restoreSelection(selection);
|
||||
assert.lengthOf(zp.itemsView.getSelectedObjects(), 2);
|
||||
});
|
||||
|
||||
it("should not expand collapsed parents when expandCollapsedParents is false", async function () {
|
||||
let parentItem = await createDataObject('item', { title: 'Parent Item' });
|
||||
let childAttachment = await importFileAttachment('test.png', { parentItemID: parentItem.id });
|
||||
await waitForItemsLoad(win);
|
||||
|
||||
await itemsView.selectItem(childAttachment.id);
|
||||
let parentRow = itemsView.getRowIndexByID(parentItem.id);
|
||||
assert.isTrue(itemsView.isContainerOpen(parentRow));
|
||||
|
||||
itemsView.rowProvider._closeContainer(parentRow);
|
||||
parentRow = itemsView.getRowIndexByID(parentItem.id);
|
||||
assert.isFalse(itemsView.isContainerOpen(parentRow));
|
||||
|
||||
itemsView.selection.clearSelection();
|
||||
await itemsView._restoreSelection([childAttachment], false, false);
|
||||
|
||||
parentRow = itemsView.getRowIndexByID(parentItem.id);
|
||||
assert.isFalse(itemsView.isContainerOpen(parentRow));
|
||||
assert.isFalse(itemsView.getRowIndexByID(childAttachment.id));
|
||||
assert.sameMembers(itemsView.getSelectedItems(true), [parentItem.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#_renderPrimaryCell()", function () {
|
||||
describe("primary cell rendering", function () {
|
||||
async function getPrimaryCellContent(asHTML = false) {
|
||||
let cellText;
|
||||
do {
|
||||
|
|
@ -2296,6 +2652,7 @@ describe("Zotero.ItemTree", function () {
|
|||
let annotationRowIndex = zp.itemsView.getRowIndexByID(annotation.id);
|
||||
offset += 1;
|
||||
assert.equal(annotationRowIndex, attachmentRowIndex + offset);
|
||||
assert.equal(zp.itemsView.getRow(annotationRowIndex).type, 'annotation');
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -2304,9 +2661,17 @@ describe("Zotero.ItemTree", function () {
|
|||
let itemAboveTwo = await createDataObject('item', { title: "BBB" });
|
||||
let itemBelowOne = await createDataObject('item', { title: "ZZZ" });
|
||||
|
||||
// Initially, everything is sorted by title
|
||||
// Ensure known starting state: primary sort by title ascending
|
||||
var colIndex = itemsView.tree._getColumns().findIndex(column => column.dataKey == 'title');
|
||||
await zp.itemsView.tree._columns.toggleSort(colIndex);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
let sortFields = itemsView.getSortFields();
|
||||
if (sortFields[0] == 'title' && itemsView.getSortDirection(sortFields) == 1) {
|
||||
break;
|
||||
}
|
||||
await zp.itemsView.tree._columns.toggleSort(colIndex);
|
||||
}
|
||||
assert.equal(itemsView.getSortField(), 'title');
|
||||
assert.equal(itemsView.getSortDirection(itemsView.getSortFields()), 1);
|
||||
|
||||
// Expand annotations
|
||||
var itemRowIndex = zp.itemsView.getRowIndexByID(toplevelItem.id);
|
||||
|
|
@ -2465,4 +2830,60 @@ describe("Zotero.ItemTree", function () {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Search error handling", function () {
|
||||
var rowProvider;
|
||||
|
||||
// Stub getSearchObject on a collectionTreeRow so that the search's .search() throws,
|
||||
// simulating a broken saved search (e.g., "too many SQL variables").
|
||||
function stubBrokenSearch(ctr) {
|
||||
return sinon.stub(ctr, 'getSearchObject').resolves({
|
||||
search: () => { throw new Error('simulated search failure'); }
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async function () {
|
||||
var search = await createDataObject('search');
|
||||
await select(win, search);
|
||||
itemsView = zp.itemsView;
|
||||
rowProvider = itemsView.rowProvider;
|
||||
});
|
||||
|
||||
it("should show load error message on search failure", async function () {
|
||||
var stub = stubBrokenSearch(rowProvider.collectionTreeRow);
|
||||
var setMessageSpy = sinon.spy(itemsView, 'setItemsPaneMessage');
|
||||
try {
|
||||
await rowProvider.refresh();
|
||||
assert.isTrue(setMessageSpy.called);
|
||||
assert.include(setMessageSpy.lastCall.args[0], Zotero.getString('pane.items.loadError'));
|
||||
assert.equal(itemsView.rowCount, 0);
|
||||
}
|
||||
finally {
|
||||
stub.restore();
|
||||
setMessageSpy.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("should recover after switching to a working collection", async function () {
|
||||
var stub = stubBrokenSearch(rowProvider.collectionTreeRow);
|
||||
await rowProvider.refresh();
|
||||
stub.restore();
|
||||
|
||||
await selectLibrary(win);
|
||||
itemsView = zp.itemsView;
|
||||
assert.isAbove(itemsView.rowCount, 0);
|
||||
assert.isFalse(!!itemsView._itemsPaneMessage);
|
||||
});
|
||||
|
||||
it("should not re-throw SearchError from refresh()", async function () {
|
||||
var stub = stubBrokenSearch(rowProvider.collectionTreeRow);
|
||||
try {
|
||||
// refresh() should resolve, not reject
|
||||
await rowProvider.refresh();
|
||||
}
|
||||
finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
181
test/tests/itemTreeRowTest.js
Normal file
181
test/tests/itemTreeRowTest.js
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"use strict";
|
||||
|
||||
describe("ItemTreeRow", function () {
|
||||
var win;
|
||||
var ItemTreeRow;
|
||||
var ZoteroItemTreeRow;
|
||||
var FileItemTreeRow;
|
||||
var AnnotationItemTreeRow;
|
||||
var CollectionItemTreeRow;
|
||||
var SearchItemTreeRow;
|
||||
|
||||
before(async function () {
|
||||
win = await loadZoteroPane();
|
||||
({
|
||||
ItemTreeRow,
|
||||
ZoteroItemTreeRow,
|
||||
FileItemTreeRow,
|
||||
AnnotationItemTreeRow,
|
||||
CollectionItemTreeRow,
|
||||
SearchItemTreeRow,
|
||||
} = win.require('zotero/itemTreeRow'));
|
||||
await selectLibrary(win);
|
||||
});
|
||||
|
||||
after(function () {
|
||||
win.close();
|
||||
});
|
||||
|
||||
it("should create row subclasses via factory", async function () {
|
||||
let item = await createDataObject('item');
|
||||
let attachment = await importFileAttachment('test.pdf', { parentItemID: item.id });
|
||||
let annotation = await createAnnotation('highlight', attachment);
|
||||
let collection = await createDataObject('collection');
|
||||
let search = await createDataObject('search');
|
||||
|
||||
assert.instanceOf(ItemTreeRow.create(item, 0, false), ZoteroItemTreeRow);
|
||||
assert.instanceOf(ItemTreeRow.create(attachment, 0, false), FileItemTreeRow);
|
||||
assert.instanceOf(ItemTreeRow.create(annotation, 0, false), AnnotationItemTreeRow);
|
||||
assert.instanceOf(ItemTreeRow.create(annotation, 0, false), ZoteroItemTreeRow);
|
||||
assert.instanceOf(ItemTreeRow.create(collection, 0, false), CollectionItemTreeRow);
|
||||
assert.instanceOf(ItemTreeRow.create(search, 0, false), SearchItemTreeRow);
|
||||
});
|
||||
|
||||
it("should provide container and child behavior for regular items and file attachments", async function () {
|
||||
let item = await createDataObject('item');
|
||||
let note = await createDataObject('item', { itemType: 'note', parentID: item.id });
|
||||
let attachment = await importFileAttachment('test.pdf', { parentItemID: item.id });
|
||||
let annotation = await createAnnotation('highlight', attachment);
|
||||
|
||||
let itemRow = ItemTreeRow.create(item, 0, false);
|
||||
assert.isTrue(itemRow.isContainer());
|
||||
assert.isFalse(itemRow.isContainerOpen());
|
||||
assert.isFalse(itemRow.isContainerEmpty({
|
||||
searchMode: false,
|
||||
searchItemIDs: new Set(),
|
||||
includeTrashed: false,
|
||||
}));
|
||||
assert.sameMembers(
|
||||
itemRow.getChildItems({ includeTrashed: false }).map(x => x.id),
|
||||
[note.id, attachment.id]
|
||||
);
|
||||
|
||||
let attachmentRow = ItemTreeRow.create(attachment, 1, false);
|
||||
assert.isTrue(attachmentRow.isContainer());
|
||||
assert.isFalse(attachmentRow.isContainerOpen());
|
||||
assert.sameMembers(
|
||||
attachmentRow.getChildItems({
|
||||
searchMode: false,
|
||||
searchItemIDs: new Set(),
|
||||
includeTrashed: false,
|
||||
}).map(x => x.id),
|
||||
[annotation.id]
|
||||
);
|
||||
|
||||
let annotationRow = ItemTreeRow.create(annotation, 2, false);
|
||||
assert.isFalse(annotationRow.isContainer());
|
||||
});
|
||||
|
||||
it("should expose attachment-state behavior by row type", async function () {
|
||||
let item = await createDataObject('item');
|
||||
let childAttachment = await importFileAttachment('test.pdf', { parentItemID: item.id });
|
||||
let topLevelAttachment = await importFileAttachment('test.pdf');
|
||||
let annotation = await createAnnotation('highlight', topLevelAttachment);
|
||||
let note = await createDataObject('item', { itemType: 'note' });
|
||||
let collection = await createDataObject('collection');
|
||||
let search = await createDataObject('search');
|
||||
|
||||
let itemRow = ItemTreeRow.create(item, 0, false);
|
||||
let childAttachmentRow = ItemTreeRow.create(childAttachment, 1, false);
|
||||
let topLevelAttachmentRow = ItemTreeRow.create(topLevelAttachment, 0, false);
|
||||
let annotationRow = ItemTreeRow.create(annotation, 1, false);
|
||||
let noteRow = ItemTreeRow.create(note, 0, false);
|
||||
let collectionRow = ItemTreeRow.create(collection, 0, false);
|
||||
let searchRow = ItemTreeRow.create(search, 0, false);
|
||||
|
||||
assert.ok(itemRow.getBestAttachmentState()?.then);
|
||||
assert.notOk(childAttachmentRow.getBestAttachmentState());
|
||||
assert.ok(topLevelAttachmentRow.getBestAttachmentState()?.then);
|
||||
assert.notOk(annotationRow.getBestAttachmentState());
|
||||
assert.notOk(noteRow.getBestAttachmentState());
|
||||
assert.notOk(collectionRow.getBestAttachmentState());
|
||||
assert.notOk(searchRow.getBestAttachmentState());
|
||||
});
|
||||
|
||||
it("should return localized type labels for all row types", async function () {
|
||||
let item = await createDataObject('item', { itemType: 'book' });
|
||||
let attachment = await importFileAttachment('test.pdf', { parentItemID: item.id });
|
||||
let annotation = await createAnnotation('highlight', attachment);
|
||||
let collection = await createDataObject('collection');
|
||||
let search = await createDataObject('search');
|
||||
|
||||
let itemRow = ItemTreeRow.create(item, 0, false);
|
||||
let attachmentRow = ItemTreeRow.create(attachment, 1, false);
|
||||
let annotationRow = ItemTreeRow.create(annotation, 2, false);
|
||||
let collectionRow = ItemTreeRow.create(collection, 0, false);
|
||||
let searchRow = ItemTreeRow.create(search, 0, false);
|
||||
|
||||
assert.equal(itemRow.getTypeLabel(), Zotero.ItemTypes.getLocalizedString(item.itemTypeID));
|
||||
assert.equal(attachmentRow.getTypeLabel(), Zotero.ItemTypes.getLocalizedString(attachment.itemTypeID));
|
||||
assert.equal(annotationRow.getTypeLabel(), Zotero.ItemTypes.getLocalizedString(annotation.itemTypeID));
|
||||
assert.equal(collectionRow.getTypeLabel(), Zotero.getString('search-conditions-collection'));
|
||||
assert.equal(searchRow.getTypeLabel(), Zotero.getString('search-conditions-savedSearch'));
|
||||
});
|
||||
|
||||
it("should return filename as display title for file attachment when pref is enabled", async function () {
|
||||
let pref = Zotero.Prefs.get('showAttachmentFilenames');
|
||||
let item = await createDataObject('item');
|
||||
let attachment = await importFileAttachment('test.pdf', { parentItemID: item.id });
|
||||
attachment.setField('title', 'Custom Attachment Title');
|
||||
await attachment.saveTx();
|
||||
|
||||
try {
|
||||
Zotero.Prefs.set('showAttachmentFilenames', false);
|
||||
let withoutPref = ItemTreeRow.create(attachment, 1, false);
|
||||
assert.equal(withoutPref.getDisplayTitle(), attachment.getDisplayTitle());
|
||||
|
||||
Zotero.Prefs.set('showAttachmentFilenames', true);
|
||||
let withPref = ItemTreeRow.create(attachment, 1, false);
|
||||
assert.notEqual(withPref.getDisplayTitle(), attachment.getDisplayTitle());
|
||||
assert.equal(withPref.getDisplayTitle(), attachment.attachmentFilename);
|
||||
}
|
||||
finally {
|
||||
Zotero.Prefs.set('showAttachmentFilenames', pref);
|
||||
}
|
||||
});
|
||||
|
||||
it("should render annotation row content with title and comment cells", async function () {
|
||||
let item = await createDataObject('item');
|
||||
let attachment = await importFileAttachment('test.pdf', { parentItemID: item.id });
|
||||
let annotation = await createAnnotation('highlight', attachment, {
|
||||
comment: 'Annotation comment',
|
||||
});
|
||||
|
||||
let row = ItemTreeRow.create(annotation, 2, false);
|
||||
let div = win.document.createElement('div');
|
||||
let columns = [{ dataKey: 'title', className: 'title' }];
|
||||
let calls = {
|
||||
renderCell: 0,
|
||||
};
|
||||
|
||||
let renderCtx = {
|
||||
firstColumn: columns[0],
|
||||
renderCell: () => {
|
||||
calls.renderCell++;
|
||||
let span = win.document.createElement('span');
|
||||
span.className = 'cell title';
|
||||
let text = win.document.createElement('span');
|
||||
text.className = 'cell-text';
|
||||
span.append(text);
|
||||
return span;
|
||||
},
|
||||
};
|
||||
|
||||
row.renderRow(div, 0, columns, {}, renderCtx);
|
||||
|
||||
assert.equal(row.type, 'annotation');
|
||||
assert.isTrue(div.classList.contains('annotation-row'));
|
||||
assert.isAbove(calls.renderCell, 0);
|
||||
assert.exists(div.querySelector('.annotation-comment'));
|
||||
});
|
||||
});
|
||||
|
|
@ -782,4 +782,25 @@ describe("Tag Selector", function () {
|
|||
assert.notInclude(getRegularTags(), 'automatic');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Search error handling", function () {
|
||||
it("should degrade gracefully when getTags() throws SearchError", async function () {
|
||||
// _safeGetTags wraps collectionTreeRow.getTags(), which calls getSearchResults().
|
||||
// If the underlying search query fails, getSearchResults() throws SearchError.
|
||||
// The tag selector should catch this and return [] rather than throwing upwards and breaking the UI.
|
||||
var collectionTreeRow = win.ZoteroPane.getCollectionTreeRow();
|
||||
collectionTreeRow.clearCache();
|
||||
var stub = sinon.stub(collectionTreeRow, 'getSearchObject').resolves({
|
||||
search: () => { throw new Error('simulated search failure'); }
|
||||
});
|
||||
try {
|
||||
var tags = await tagSelector._safeGetTags();
|
||||
assert.isArray(tags);
|
||||
assert.equal(tags.length, 0);
|
||||
}
|
||||
finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
|
|
|
|||
95
test/tests/virtualized-tableTest.js
Normal file
95
test/tests/virtualized-tableTest.js
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"use strict";
|
||||
|
||||
describe("VirtualizedTable", function () {
|
||||
let win, zp, itemsView;
|
||||
|
||||
before(async function () {
|
||||
win = await loadZoteroPane();
|
||||
zp = win.ZoteroPane;
|
||||
});
|
||||
|
||||
beforeEach(async function () {
|
||||
await selectLibrary(win);
|
||||
itemsView = zp.itemsView;
|
||||
await createDataObject('item');
|
||||
await waitForItemsLoad(win);
|
||||
});
|
||||
|
||||
after(function () {
|
||||
win.close();
|
||||
});
|
||||
|
||||
describe("#selectEventsSuppressed", function () {
|
||||
it("should not trigger updates when set to false repeatedly", function () {
|
||||
let selection = itemsView.selection;
|
||||
selection.selectEventsSuppressed = false;
|
||||
|
||||
let updateSpy = sinon.spy(selection, '_updateTree');
|
||||
let invalidateSpy = sinon.spy(itemsView.tree, 'invalidate');
|
||||
|
||||
try {
|
||||
selection.selectEventsSuppressed = false;
|
||||
assert.equal(updateSpy.callCount, 0);
|
||||
assert.equal(invalidateSpy.callCount, 0);
|
||||
}
|
||||
finally {
|
||||
updateSpy.restore();
|
||||
invalidateSpy.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("should trigger updates when changed from true to false", function () {
|
||||
let selection = itemsView.selection;
|
||||
selection.selectEventsSuppressed = true;
|
||||
|
||||
let updateSpy = sinon.spy(selection, '_updateTree');
|
||||
let invalidateSpy = sinon.spy(itemsView.tree, 'invalidate');
|
||||
|
||||
try {
|
||||
selection.selectEventsSuppressed = false;
|
||||
assert.equal(updateSpy.callCount, 1);
|
||||
assert.equal(invalidateSpy.callCount, 1);
|
||||
}
|
||||
finally {
|
||||
updateSpy.restore();
|
||||
invalidateSpy.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("VirtualizedTree rendering", function () {
|
||||
it("should render tree indentation and ARIA attributes", async function () {
|
||||
let parentItem = await createDataObject('item', { title: 'Parent Item' });
|
||||
let attachment = await importFileAttachment('test.pdf', { parentItemID: parentItem.id });
|
||||
let annotation = await createAnnotation('highlight', attachment);
|
||||
|
||||
await waitForItemsLoad(win);
|
||||
await itemsView.selectItem(annotation.id);
|
||||
|
||||
let parentIndex = itemsView.getRowIndexByID(parentItem.id);
|
||||
let attachmentIndex = itemsView.getRowIndexByID(attachment.id);
|
||||
let annotationIndex = itemsView.getRowIndexByID(annotation.id);
|
||||
|
||||
let parentNode = itemsView.tree._renderItem(parentIndex);
|
||||
let attachmentNode = itemsView.tree._renderItem(attachmentIndex);
|
||||
let annotationNode = itemsView.tree._renderItem(annotationIndex);
|
||||
|
||||
assert.equal(parentNode.getAttribute('role'), 'treeitem');
|
||||
assert.equal(attachmentNode.getAttribute('role'), 'treeitem');
|
||||
assert.equal(annotationNode.getAttribute('role'), 'treeitem');
|
||||
|
||||
assert.equal(parentNode.getAttribute('aria-level'), '1');
|
||||
assert.equal(attachmentNode.getAttribute('aria-level'), '2');
|
||||
assert.equal(annotationNode.getAttribute('aria-level'), '3');
|
||||
|
||||
assert.equal(parentNode.getAttribute('aria-expanded'), 'true');
|
||||
assert.equal(attachmentNode.getAttribute('aria-expanded'), 'true');
|
||||
assert.isNull(annotationNode.getAttribute('aria-expanded'));
|
||||
|
||||
let getIndent = (node) => parseInt(node.querySelector('.cell-indent').style.paddingInlineStart || 0);
|
||||
assert.equal(getIndent(parentNode), 0);
|
||||
assert.equal(getIndent(attachmentNode), 16);
|
||||
assert.equal(getIndent(annotationNode), 32);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue