this.getRowIndexByID(o.id) === false)) {
- // In duplicates view, select the next set on delete
- if (collectionTreeRow.isDuplicates()) {
- if (this._rows[previousFirstSelectedRow]) {
- var itemID = this._rows[previousFirstSelectedRow].ref.id;
- var setItemIDs = collectionTreeRow.ref.getSetItemsByItemID(itemID);
- this.selectItems(setItemIDs);
- reselect = true;
- }
- }
- else {
- // If this was a child item and the next item at this
- // position is a top-level item, move selection one row
- // up to select a sibling or parent
- if (ids.length == 1 && previousFirstSelectedRow > 0) {
- let previousItem = Zotero.Items.get(ids[0]);
- if (previousItem && !previousItem.isTopLevelItem()) {
- if (this._rows[previousFirstSelectedRow]
- && this.getLevel(previousFirstSelectedRow) == 0) {
- previousFirstSelectedRow--;
- }
- }
- }
-
- if (previousFirstSelectedRow !== undefined && previousFirstSelectedRow in this._rows) {
- this.selection.select(previousFirstSelectedRow);
- reselect = true;
- }
- // If no item at previous position, select last item in list
- else if (this._rows.length > 0 && this._rows[this._rows.length - 1]) {
- this.selection.select(this._rows.length - 1);
- reselect = true;
- }
- }
- }
- else {
- await this._restoreSelection(savedSelection);
- reselect = true;
- }
- }
-
- this._rememberScrollPosition(scrollPosition);
- }
-
- this._updateIntroText();
-
- // If we made changes to the selection (including reselecting the same item, which will register as
- // a selection when selectEventsSuppressed is set to false), wait for a select event on the tree
- // view (e.g., as triggered by itemsView.runListeners('select') in ZoteroPane::itemSelected())
- // before returning. This guarantees that changes are reflected in the middle and right-hand panes
- // before returning from the save transaction.
- if (reselect) {
- var selectPromise = this.waitForSelect();
- // Triggers reselect on the item tree and fires a select event
- this.selection.selectEventsSuppressed = false;
- return selectPromise;
- }
- else {
- this.selection.selectEventsSuppressed = false;
- }
+ await this.rowProvider.notify(action, type, ids, extraData);
}
- handleActivate = (event, indices) => {
- // Ignore double-clicks in duplicates view on everything except attachments
+ handleActivate(event, indices) {
let items = indices.map(index => this.getRow(index).ref);
- if (event.button == 0 && this.collectionTreeRow.isDuplicates()) {
- if (items.length != 1 || !items[0].isAttachment()) {
- return false;
- }
- }
this.props.onActivate(event, items);
}
@@ -1020,7 +1292,7 @@ var ItemTree = class ItemTree extends LibraryTree {
* @param event {InputEvent}
* @returns {boolean} false to prevent any handling by the virtualized-table
*/
- handleKeyDown = (event) => {
+ handleKeyDown(event) {
if (Zotero.locked) {
return false;
}
@@ -1036,68 +1308,13 @@ var ItemTree = class ItemTree extends LibraryTree {
}
return false;
}
- if (!event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey && COLORED_TAGS_RE.test(event.code)) {
- let libraryID = this.collectionTreeRow.ref.libraryID;
- let position = COLORED_TAGS_RE.exec(event.code)[1] - 1;
- // When 0 is pressed, remove all colored tags
- if (position == -1) {
- let items = this.getSelectedItems();
- Zotero.Tags.removeColoredTagsFromItems(items);
- // Disable find-as-you-type for 0 keypress
- return false;
- }
- let colorData = Zotero.Tags.getColorByPosition(libraryID, position);
- // If a color isn't assigned to this number or any
- // other numbers, allow key navigation
- if (!colorData) {
- return !Zotero.Tags.getColors(libraryID).size;
- }
-
- var items = this.getSelectedItems();
- // Async operation and we're not waiting for the promise
- // since we need to return false below to prevent virtualized-table from handling the event
- const _promise = Zotero.Tags.toggleItemsListTags(items, colorData.name);
- return false;
- }
- else if (event.key == 'a'
- && !event.altKey
- && !event.shiftKey
- && (Zotero.isMac ? (event.metaKey && !event.ctrlKey) : event.ctrlKey)) {
- if (!this.collectionTreeRow.isPublications()) {
- this.expandMatchParents(this._searchParentIDs);
- }
- }
- else if (event.key == '+' && !(event.ctrlKey || event.altKey || event.metaKey)) {
- this.expandAllRows();
+ if (event.key == '+' && !(event.ctrlKey || event.altKey || event.metaKey)) {
+ this.rowProvider.expandAllRows();
return false;
}
else if (event.key == '-' && !(event.shiftKey || event.ctrlKey
|| event.altKey || event.metaKey)) {
- this.collapseAllRows();
- return false;
- }
- // On arrowUp/down without modifiers in duplicates view, select the entire set
- else if (this.collectionTreeRow.isDuplicates() && ["ArrowUp", "ArrowDown"].includes(event.key)
- && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
- // Find the first row outside of the current consecutive set of rows
- let findNextRow = index => (event.key == "ArrowUp" ? index - 1 : index + 1);
- let nextRowIndex = findNextRow(this.selection.focused);
- while (this.selection.selected.has(nextRowIndex)) {
- nextRowIndex = findNextRow(nextRowIndex);
- }
- if (nextRowIndex < 0 || nextRowIndex > this._rows.length - 1) return false;
- // Set that row as focused and select its item as the next set of duplicates
- let nextItem = this._rows[nextRowIndex].ref;
- var setItemIDs = this.collectionTreeRow.ref.getSetItemsByItemID(nextItem.id);
- this.selection.focused = nextRowIndex;
-
- this.selectItems(setItemIDs, false, true).then(() => {
- // make sure the focused row is visible
- if (!this.tree.rowIsVisible(nextRowIndex)) {
- this.ensureRowIsVisible(nextRowIndex);
- }
- });
-
+ this.rowProvider.collapseAllRows();
return false;
}
return true;
@@ -1111,12 +1328,10 @@ var ItemTree = class ItemTree extends LibraryTree {
this.selection.select(this.selection.focused);
}
};
-
- render() {
+
+ _renderItemsPaneMessage(showMessage) {
const itemsPaneMessageHTML = this._itemsPaneMessage || this.props.emptyMessage;
- const showMessage = !this.collectionTreeRow || this._itemsPaneMessage;
-
- const itemsPaneMessage = ( this.props.dragAndDrop && this.onDragOver(e, -1)}
onDrop={e => this.props.dragAndDrop && this.onDrop(e, -1)}
@@ -1132,55 +1347,56 @@ var ItemTree = class ItemTree extends LibraryTree {
style={{ display: showMessage ? "flex" : "none" }}
dangerouslySetInnerHTML={{ __html: itemsPaneMessageHTML }}>
);
+ }
- let virtualizedTable = ();
- if (this.collectionTreeRow) {
- virtualizedTable = React.createElement(VirtualizedTable,
- {
- getRowCount: () => this._rows.length,
- id: this.id,
- ref: ref => this.tree = ref,
- treeboxRef: ref => this._treebox = ref,
- renderItem: this._renderItem.bind(this),
- hide: showMessage,
- key: "virtualized-table",
+ render() {
+ const showMessage = !!this._itemsPaneMessage;
+ const itemsPaneMessage = this._renderItemsPaneMessage(showMessage);
- showHeader: true,
- columns: this._getColumns(),
- onColumnPickerMenu: this._displayColumnPickerMenu,
- onColumnSort: this.collectionTreeRow.isSortable() ? this._handleColumnSort : null,
- getColumnPrefs: this._getColumnPrefs,
- storeColumnPrefs: this._storeColumnPrefs,
- getDefaultColumnOrder: this._getDefaultColumnOrder,
- containerWidth: this.domEl.clientWidth,
- firstColumnExtraWidth: 28, // 16px for twisty + 16px for icon - 8px column padding + 4px margin
+ let virtualizedTable = React.createElement(VirtualizedTree,
+ {
+ getRowCount: () => this.rowProvider.getRowCount(),
+ id: this.id,
+ ref: ref => this.tree = ref,
+ treeboxRef: ref => this._treebox = ref,
+ renderItem: this._renderItem.bind(this),
+ hide: showMessage,
+ key: "virtualized-table",
- multiSelect: this.props.multiSelect,
+ showHeader: true,
+ columns: this._getColumns(),
+ onColumnPickerMenu: this._displayColumnPickerMenu.bind(this),
+ onColumnSort: this.isSortable ? this._handleColumnSort : null,
+ getColumnPrefs: this._getColumnPrefs.bind(this),
+ storeColumnPrefs: this._storeColumnPrefs.bind(this),
+ getDefaultColumnOrder: this._getDefaultColumnOrder.bind(this),
+ containerWidth: this.domEl.clientWidth,
+ firstColumnExtraWidth: 16 + 16 + 4, // 16px for twisty, 16px for icon + 4px for margin-right
- onSelectionChange: this._handleSelectionChange,
- isSelectable: this.isSelectable,
- getParentIndex: this.getParentIndex,
- isContainer: this.isContainer,
- isContainerEmpty: this.isContainerEmpty,
- isContainerOpen: this.isContainerOpen,
- toggleOpenState: this.toggleOpenState,
+ multiSelect: this.props.multiSelect,
- getRowString: this.getRowString.bind(this),
+ onSelectionChange: this._handleSelectionChange.bind(this),
+ isSelectable: this.isSelectable.bind(this),
+ getParentIndex: this.getParentIndex.bind(this),
+ isContainer: this.isContainer.bind(this),
+ isContainerEmpty: this.isContainerEmpty.bind(this),
+ isContainerOpen: this.isContainerOpen.bind(this),
+ onToggleOpenState: this.onToggleOpenState.bind(this),
- onDragOver: e => this.props.dragAndDrop && this.onDragOver(e, -1),
- onDrop: e => this.props.dragAndDrop && this.onDrop(e, -1),
- onKeyDown: this.handleKeyDown,
- onKeyUp: this.handleKeyUp,
- onActivate: this.handleActivate,
+ getRowString: this.getRowString.bind(this),
- onItemContextMenu: (...args) => this.props.onContextMenu(...args),
-
- role: 'tree',
- label: Zotero.getString('pane.items.title'),
- }
- );
- }
+ onDragOver: e => this.props.dragAndDrop && this.onDragOver(e, -1),
+ onDrop: e => this.props.dragAndDrop && this.onDrop(e, -1),
+ onKeyDown: this.handleKeyDown.bind(this),
+ onKeyUp: this.handleKeyUp.bind(this),
+ onActivate: this.handleActivate.bind(this),
+
+ onItemContextMenu: (...args) => this.props.onContextMenu(...args),
+
+ role: 'tree',
+ label: Zotero.getString('pane.items.title'),
+ }
+ );
Zotero.debug(`itemTree.render(). Displaying ${showMessage ? "Item Pane Message" : "Item Tree"}`);
return [
@@ -1188,63 +1404,8 @@ var ItemTree = class ItemTree extends LibraryTree {
virtualizedTable
];
}
-
- async changeCollectionTreeRow(collectionTreeRow) {
- // When used outside the Zotero Pane, "collectionTreeRow" is not an actual
- // tree row being passed in, but rather a simple object that defines necessary properties.
- // So we need to supplement other CollectionTreeRow properties so that itemTree code
- // does not complain.
- // Obviously this is not ideal and would be best refactored so that collection
- // tree row dependencies are specified separately
- // and there's a separate method to refresh the items.
- if (collectionTreeRow.constructor.name == "Object") {
- collectionTreeRow = Object.assign({}, STUB_COLLECTION_TREE_ROW, collectionTreeRow);
- }
- if (this._locked) return;
- if (!collectionTreeRow) {
- this.tree = null;
- this._treebox = null;
- return this.clearItemsPaneMessage();
- }
- Zotero.debug(`itemTree.changeCollectionTreeRow(): ${collectionTreeRow.id}`);
- this._itemTreeLoadingDeferred = Zotero.Promise.defer();
- this.setItemsPaneMessage(Zotero.getString('pane.items.loading'));
- let newId = "item-tree-" + this.props.id;
- if (collectionTreeRow.visibilityGroup) {
- newId += "-" + collectionTreeRow.visibilityGroup;
- }
- let idChanged = this.id != newId;
- if (idChanged && this.props.persistColumns) {
- await this._writeColumnPrefsToFile(true);
- this.id = newId;
- await this._loadColumnPrefsFromFile();
- }
- this.id = newId;
- this.collectionTreeRow = collectionTreeRow;
- this.selection.selectEventsSuppressed = true;
- this.collectionTreeRow.view.itemTreeView = this;
- // Ensures that an up to date this._columns is set
- this._getColumns();
- this.selection.clearSelection();
- this.selection.focused = 0;
- await this.refresh({ forceSortAll: idChanged });
- if (Zotero.CollectionTreeCache.error) {
- return this.setItemsPaneMessage(Zotero.getString('pane.items.loadError'));
- }
- else {
- this.clearItemsPaneMessage();
- }
- this.forceUpdate(() => {
- this.selection.selectEventsSuppressed = false;
- // Reset scrollbar to top
- this._treebox && this._treebox.scrollTo(0);
- this._updateIntroText();
- this._itemTreeLoadingDeferred.resolve();
- });
- await this._itemTreeLoadingDeferred.promise;
- }
-
+ // TODO investigate usage
async refreshAndMaintainSelection(clearItemsPaneMessage=true) {
if (this.selection) {
this.selection.selectEventsSuppressed = true;
@@ -1273,28 +1434,9 @@ var ItemTree = class ItemTree extends LibraryTree {
async selectItems(ids, noRecurse, noScroll) {
if (!ids.length) return 0;
- // If no row map, we're probably in the process of switching collections,
- // so store the items to select on the item group for later
- if (!this._rowMap) {
- if (this.collectionTreeRow) {
- this.collectionTreeRow.itemsToSelect = ids;
- Zotero.debug("_rowMap not yet set; not selecting items");
- return 0;
- }
-
- Zotero.debug('Item group not found and no row map in ItemTree.selectItem() -- discarding select', 2);
- return 0;
- }
-
var idsToSelect = [];
for (let id of ids) {
let row = this._rowMap[id];
- let item = Zotero.Items.get(id);
-
- // Can't select a deleted item if we're not in the trash
- if (item.deleted && !this.collectionTreeRow.isTrash()) {
- continue;
- }
// If row with id isn't visible, check to see if it's hidden under a parent
if (row == undefined) {
@@ -1352,6 +1494,7 @@ var ItemTree = class ItemTree extends LibraryTree {
var selectedRows = this.selection.selected;
if (rowsToSelect.length == selectedRows.size && rowsToSelect.every(row => selectedRows.has(row))) {
this.ensureRowsAreVisible(rowsToSelect);
+ this._handleSelectionChange(this.selection, false);
return rowsToSelect.length;
}
@@ -1405,293 +1548,24 @@ var ItemTree = class ItemTree extends LibraryTree {
* Sort the items by the currently sorted column.
*/
async sort(itemIDs) {
- var t = new Date;
-
- // For child items, just close and reopen parents
- if (itemIDs) {
- let parentItemIDs = new Set();
- let skipped = [];
- for (let itemID of itemIDs) {
- let row = this._rowMap[itemID];
- let item = this.getRow(row).ref;
- let parentItemID = item.parentItemID;
- if (!parentItemID) {
- skipped.push(itemID);
- continue;
- }
- parentItemIDs.add(parentItemID);
- }
-
- let parentRows = [...parentItemIDs].map(itemID => this._rowMap[itemID]);
- parentRows.sort();
-
- for (let i = parentRows.length - 1; i >= 0; i--) {
- let row = parentRows[i];
- this._closeContainer(row, true, true);
- this.toggleOpenState(row, true, true);
- }
- this._refreshRowMap();
-
- let numSorted = itemIDs.length - skipped.length;
- if (numSorted) {
- Zotero.debug(`Sorted ${numSorted} child items by parent toggle`);
- }
- if (!skipped.length) {
- return;
- }
- itemIDs = skipped;
- if (numSorted) {
- Zotero.debug(`${itemIDs.length} items left to sort`);
- }
- }
-
- var primaryField = this.getSortField();
- var sortFields = this.getSortFields();
- var order = this.getSortDirection(sortFields);
- var collation = Zotero.getLocaleCollation();
- var sortCreatorAsString = Zotero.Prefs.get('sortCreatorAsString');
-
- Zotero.debug(`Sorting items list by ${sortFields.join(", ")} ${order == 1 ? "ascending" : "descending"} `
- + (itemIDs && itemIDs.length
- ? `for ${itemIDs.length} ` + Zotero.Utilities.pluralize(itemIDs.length, ['item', 'items'])
- : ""));
-
- // Set whether rows with empty values should sort at the beginning
- var emptyFirst = {
- title: true,
-
- // Date columns start descending, so put empty rows at end
- date: true,
- year: true,
- };
-
- // Cache primary values while sorting, since base-field-mapped getField()
- // calls are relatively expensive
- var cache = {};
- sortFields.forEach(x => cache[x] = {});
-
- // Get the display field for a row (which might be a placeholder title)
- let getField = (field, row) => {
- var item = row.ref;
-
- switch (field) {
- case 'title':
- return Zotero.Items.getSortTitle(item.getDisplayTitle());
-
- case 'hasAttachment':
- if (this._canGetBestAttachmentState(item)) {
- return item.getBestAttachmentStateCached();
- }
- else {
- return 0;
- }
-
- case 'numNotes':
- return row.numNotes(false, true) || 0;
-
- // Use unformatted part of date strings (YYYY-MM-DD) for sorting
- case 'date':
- var val = row.ref.getField('date', true, true);
- if (val) {
- val = val.substr(0, 10);
- if (val.indexOf('0000') == 0) {
- val = "";
- }
- }
- return val;
-
- case 'year':
- var val = row.ref.getField('date', true, true);
- if (val) {
- val = val.substr(0, 4);
- if (val == '0000') {
- val = "";
- }
- }
- return val;
-
- case 'feed':
- return (row.ref.isFeedItem && Zotero.Feeds.get(row.ref.libraryID).name) || "";
+ var t = new Date();
- case 'lastRead':
- return item.getItemLastRead();
-
- case 'addedBy':
- return item.createdByUserID
- ? Zotero.Users.getName(item.createdByUserID) : '';
-
- case 'lastModifiedBy': {
- let userID = item.lastModifiedByUserID || item.createdByUserID;
- return userID ? Zotero.Users.getName(userID) : '';
- }
-
- default:
- let extraField = this.props.getExtraField(row.ref, field);
- if (extraField !== undefined) return extraField;
- // Get from row.getField() to allow for custom fields
- return row.getField(field, false, true);
- }
- }
-
- var includeTrashed = this.collectionTreeRow.isTrash();
-
- function fieldCompare(a, b, sortField) {
- var aItemID = a.id;
- var bItemID = b.id;
- var fieldA = cache[sortField][aItemID];
- var fieldB = cache[sortField][bItemID];
-
- switch (sortField) {
- case 'firstCreator':
- return creatorSort(a, b);
-
- case 'itemType':
- var typeA = Zotero.ItemTypes.getLocalizedString(a.ref.itemTypeID);
- var typeB = Zotero.ItemTypes.getLocalizedString(b.ref.itemTypeID);
- return (typeA > typeB) ? 1 : (typeA < typeB) ? -1 : 0;
-
- default:
- if (fieldA === undefined) {
- cache[sortField][aItemID] = fieldA = getField(sortField, a);
- }
-
- if (fieldB === undefined) {
- cache[sortField][bItemID] = fieldB = getField(sortField, b);
- }
-
- // Display rows with empty values last
- if (!emptyFirst[sortField]) {
- if(fieldA === '' && fieldB !== '') return 1;
- if(fieldA !== '' && fieldB === '') return -1;
- }
-
- if (sortField == 'hasAttachment') {
- // PDFs at the top
- const order = ['pdf', 'snapshot', 'epub', 'image', 'video', 'other', 'none'];
- fieldA = order.indexOf(fieldA.type || 'none') + (fieldA.exists ? 0 : (order.length - 1));
- fieldB = order.indexOf(fieldB.type || 'none') + (fieldB.exists ? 0 : (order.length - 1));
- return fieldA - fieldB;
- }
-
- if (sortField == 'callNumber') {
- return Zotero.Utilities.Item.compareCallNumbers(fieldA, fieldB);
- }
-
- return collation.compareString(1, fieldA, fieldB);
- }
- }
-
- var rowSort = function (a, b) {
- for (let i = 0; i < sortFields.length; i++) {
- let cmp = fieldCompare(a, b, sortFields[i]);
- if (cmp !== 0) {
- return cmp;
- }
- }
- return 0;
- };
-
- var creatorSortCache = {};
-
- function creatorSort(a, b) {
- var itemA = a.ref;
- var itemB = b.ref;
- //
- // Try sorting by the first name in the firstCreator field, since we already have it
- //
- // For sortCreatorAsString mode, just use the whole string
- //
- var aItemID = a.id,
- bItemID = b.id,
- fieldA = creatorSortCache[aItemID],
- fieldB = creatorSortCache[bItemID];
- var prop = sortCreatorAsString ? 'firstCreator' : 'sortCreator';
- var sortStringA = itemA[prop];
- // Unsaved items like those embedded in documents
- if (!sortStringA) sortStringA = itemA.getField('firstCreator');
- var sortStringB = itemB[prop];
- if (!sortStringB) sortStringB = itemB.getField('firstCreator');
- if (fieldA === undefined) {
- let firstCreator = Zotero.Items.getSortTitle(sortStringA);
- fieldA = firstCreator;
- creatorSortCache[aItemID] = fieldA;
- }
- if (fieldB === undefined) {
- let firstCreator = Zotero.Items.getSortTitle(sortStringB);
- fieldB = firstCreator;
- creatorSortCache[bItemID] = fieldB;
- }
-
- if (fieldA === "" && fieldB === "") {
- return 0;
- }
-
- // Display rows with empty values last
- if (fieldA === '' && fieldB !== '') return 1;
- if (fieldA !== '' && fieldB === '') return -1;
-
- return collation.compareString(1, fieldA, fieldB);
- }
-
- var savedSelection = this.getSelectedObjects();
-
- // Save open state and close containers before sorting
- var openItemIDs = this._saveOpenState(true);
-
- // Sort specific items
- try {
- if (itemIDs) {
- let idsToSort = new Set(itemIDs);
- this._rows.sort((a, b) => {
- // Don't re-sort existing items. This assumes a stable sort(), which is the case in Firefox
- // but not Chrome/v8.
- if (!idsToSort.has(a.ref.id) && !idsToSort.has(b.ref.id)) return 0;
- return rowSort(a, b) * order;
- });
- }
- // Full sort
- else {
- this._rows.sort((a, b) => rowSort(a, b) * order);
- }
- }
- catch (e) {
- Zotero.logError("Error sorting fields: " + e.message);
- Zotero.debug(e, 1);
- // Clear anything that might be contributing to the error
- Zotero.Prefs.clear('secondarySort.' + this.getSortField());
- Zotero.Prefs.clear('fallbackSort');
- }
-
- this._refreshRowMap();
-
- this._rememberOpenState(openItemIDs);
- this._restoreSelection(savedSelection);
-
- if (this.tree && !this.selection.selectEventsSuppressed) {
- this.tree.invalidate();
- }
+ await this._ensureSortContextReady();
+ this.rowProvider.sort(itemIDs);
+ await this.waitForLoad();
var numSorted = itemIDs ? itemIDs.length : this._rows.length;
Zotero.debug(`Sorted ${numSorted} ${Zotero.Utilities.pluralize(numSorted, ['item', 'items'])} `
- + `in ${new Date - t} ms`);
+ + `in ${new Date() - t} ms`);
}
+ /**
+ * Set a filter on the item tree.
+ * @param {string} type - Filter type ('search', 'citation-search', 'tags')
+ * @param {*} data - Filter data
+ */
async setFilter(type, data) {
- if (this._locked) return;
- switch (type) {
- case 'search':
- this.collectionTreeRow.setSearch(data);
- break;
- case 'citation-search':
- this.collectionTreeRow.setSearch(data, 'fields');
- break;
- case 'tags':
- this.collectionTreeRow.setTags(data);
- break;
- default:
- throw ('Invalid filter type in setFilter');
- }
- await this.refreshAndMaintainSelection();
+ throw new Error('setFilter not implemented');
};
ensureRowsAreVisible(indices) {
@@ -1763,270 +1637,52 @@ var ItemTree = class ItemTree extends LibraryTree {
this.tree.invalidate();
}
- toggleOpenState = async (index, skipRowMapRefresh=false) => {
+ closeContainer(index, skipRowMapRefresh=false) {
+ if (!this.isContainerOpen(index)) return;
+ return this.toggleOpenState(index, skipRowMapRefresh);
+ }
+
+ openContainer(index, skipRowMapRefresh=false) {
+ if (this.isContainerOpen(index)) return;
+ return this.toggleOpenState(index, skipRowMapRefresh);
+ }
+
+ toggleOpenState(index, skipRowMapRefresh=false) {
+ return this.tree.toggleOpenState(index, skipRowMapRefresh);
+ }
+
+ onToggleOpenState(index, skipRowMapRefresh=false) {
// Shouldn't happen but does if an item is dragged over a closed
// container until it opens and then released, since the container
// is no longer in the same place when the spring-load closes
if (!this.isContainer(index)) {
return;
}
-
- this._lastToggleOpenStateIndex = index;
-
- if (this.isContainerOpen(index)) {
- await this._closeContainer(index, skipRowMapRefresh, true);
- this._lastToggleOpenStateIndex = null;
- return;
- }
- if (!skipRowMapRefresh) {
- var savedSelection = this.getSelectedObjects();
- }
-
- var count = 0;
- var level = this.getLevel(index);
-
- //
- // Open
- //
- var item = this.getRow(index).ref;
-
- //Get children
- var includeTrashed = this.collectionTreeRow.isTrash();
- var attachments = item.isRegularItem() ? item.getAttachments(includeTrashed) : [];
- var notes = item.isRegularItem() ? item.getNotes(includeTrashed) : [];
- var annotations = [];
-
- if (item.isFileAttachment()) {
- annotations = item.getAnnotations();
- }
- // Optionally, only keep annotation rows that match the search query
- if (Zotero.Prefs.get("hideContextAnnotationRows") && this._searchMode) {
- annotations = annotations.filter(annotation => this._searchItemIDs.has(annotation.id));
- }
- var newRows = [];
- if (attachments.length && notes.length) {
- newRows = notes.concat(attachments);
- }
- else if (attachments.length) {
- newRows = attachments;
- }
- else if (notes.length) {
- newRows = notes;
- }
- if (annotations.length) {
- newRows = newRows.concat(annotations);
- }
-
- if (newRows) {
- if (!item.isFileAttachment()) {
- newRows = Zotero.Items.get(newRows);
- }
- // Skip unwanted child items (e.g. in citation dialog)
- if (this.props.filterChildItems) {
- newRows = newRows.filter(this.props.filterChildItems);
- }
- for (let i = 0; i < newRows.length; i++) {
- count++;
- this._addRow(
- new ItemTreeRow(newRows[i], level + 1, false),
- index + i + 1,
- true
- );
- }
- }
-
- this._rows[index].isOpen = true;
-
- if (count == 0) {
- this._lastToggleOpenStateIndex = null;
- return;
- }
-
- if (!skipRowMapRefresh) {
- Zotero.debug('Refreshing item row map');
- this._refreshRowMap();
-
- await this._refreshPromise;
- this._restoreSelection(savedSelection, false, true);
- this.tree.invalidate();
- }
- this._lastToggleOpenStateIndex = null;
- }
-
- expandMatchParents(searchParentIDs) {
- // Expand parents of child matches
- if (!this._searchMode) {
- return;
- }
-
- var savedSelection = this.getSelectedObjects();
- for (var i=0; i searchParentIDs.has(id));
- if (shouldBeOpened) {
- this.toggleOpenState(i, true);
- }
- }
- this._refreshRowMap();
- this._restoreSelection(savedSelection);
- }
-
- /**
- * Expand rows level by level.
- * If all regular items are collapsed, they are toggled open to reveal attachments.
- * If some regular items are expanded, only remaining collapsed regular items are toggled open.
- * If all regular items are expanded, their attachments' rows will be expanded to reveal annotations.
- * If there are expanded attachments rows, all rows on all levels are expanded.
- * @param {Boolean} acrossAllLevels - Expand rows across all levels
- */
- expandAllRows(acrossAllLevels) {
- // Do nothing if there are no rows
- if (this.rowCount == 0) return;
- this.selection.selectEventsSuppressed = true;
- var selectedItems = this.getSelectedObjects();
- // Remember scroll position of an anchor row relative to the viewport
- let isFocusedRowVisible = this.selection.focused >= this._treebox.getFirstVisibleRow() && this.selection.focused <= this._treebox.getLastVisibleRow();
- // The anchor row is the selected row if it is visible OR the first visible row otherwise
- let anchorRowIndex = isFocusedRowVisible ? this.selection.focused : this._treebox.getFirstVisibleRow();
- let anchorItemID = this.getRow(anchorRowIndex).ref.treeViewID;
- let anchorOffset = this._treebox._getItemPosition(anchorRowIndex) - this._treebox.scrollOffset;
- let deepestExpandedLevel = 0;
- // Find the deepest expanded container level
- for (let i = 0; i < this.rowCount; i++) {
- if (this.isContainer(i) && this.isContainerOpen(i) && this.getLevel(i) > deepestExpandedLevel) {
- deepestExpandedLevel = this.getLevel(i);
- }
- }
- // Check if there are any remaining rows on that level to expand
- let rowsToExpandAtThatLevel = false;
- for (let i = 0; i < this.rowCount; i++) {
- if (this.isContainer(i) && !this.isContainerOpen(i) && this.getLevel(i) == deepestExpandedLevel) {
- rowsToExpandAtThatLevel = true;
- break;
- }
- }
- // If not, move to the next level
- if (!rowsToExpandAtThatLevel) {
- deepestExpandedLevel++;
- }
- // Expand containers
- for (var i = 0; i < this.rowCount; i++) {
- if (this.isContainer(i) && !this.isContainerOpen(i) && (acrossAllLevels || this.getLevel(i) <= deepestExpandedLevel)) {
- this.toggleOpenState(i, true);
- }
- }
- this._refreshRowMap();
- this._restoreSelection(selectedItems, false, true);
- // Restore scroll position so the anchor row stays in place
- let newRowIndex = this._rowMap[anchorItemID];
- let newPosition = this._treebox._getItemPosition(newRowIndex);
- this._treebox.scrollTo(newPosition - anchorOffset);
- this.tree.invalidate();
- this.selection.selectEventsSuppressed = false;
+ this._cacheState();
+ return this.rowProvider.toggleOpenState(index, skipRowMapRefresh);
}
-
- /**
- * Collapse all rows up one level.
- * If there are expanded attachment container rows, only they are collapsed.
- * Otherwise, expanded regular items are collapsed.
- * @param {Boolean} acrossAllLevels - Collapse rows across all levels
- */
- collapseAllRows(acrossAllLevels = false) {
- // Do nothing if there are no rows
- if (this.rowCount == 0) return;
- this.selection.selectEventsSuppressed = true;
- const selectedItems = this.getSelectedObjects();
- // Remember scroll position of an anchor row relative to the viewport
- let isFocusedRowVisible = this.selection.focused >= this._treebox.getFirstVisibleRow() && this.selection.focused <= this._treebox.getLastVisibleRow();
- // The anchor row is the selected row if it is visible OR the first visible row otherwise
- let anchorRowIndex = isFocusedRowVisible ? this.selection.focused : this._treebox.getFirstVisibleRow();
- let anchorItemID = this.getRow(anchorRowIndex).ref.treeViewID;
- let anchorOffset = this._treebox._getItemPosition(anchorRowIndex) - this._treebox.scrollOffset;
- // Also record scroll position of anchor row's visible parent, in case anchor row is collapsed
- let parentItemOffset;
- if (Zotero.Items.get(anchorItemID).parentItemID) {
- let parentIndex = this._rowMap[Zotero.Items.get(anchorItemID).parentItem.treeViewID];
- if (parentIndex >= this._treebox.getFirstVisibleRow()) {
- parentItemOffset = this._treebox._getItemPosition(parentIndex) - this._treebox.scrollOffset;
- }
- }
- // Find the deepest level that has expanded containers
- let maxLevelWithCollapsed = -1;
- for (let i = 0; i < this.rowCount; i++) {
- if (this.isContainer(i) && this.isContainerOpen(i) && this.getLevel(i) > maxLevelWithCollapsed) {
- maxLevelWithCollapsed = this.getLevel(i);
- }
- }
- // Collapse containers at that level
- for (var i = 0; i < this.rowCount; i++) {
- if (this.isContainer(i) && this.isContainerOpen(i) && (acrossAllLevels || this.getLevel(i) === maxLevelWithCollapsed)) {
- this._closeContainer(i, true);
- }
- }
- this._refreshRowMap();
- this._restoreSelection(selectedItems, false, true);
-
- // Restore scroll position so the anchor row stays in place
- let newRowIndex = this._rowMap[anchorItemID];
- // If the anchor row was collapsed, fall back to its parent
- if (newRowIndex === undefined) {
- let parent = Zotero.Items.get(anchorItemID).parentItem;
- newRowIndex = this._rowMap[parent.treeViewID];
- // Preserve the scroll position of the parent, if it was visible
- // If it was not visible, just scroll parent exactly to the top
- anchorOffset = parentItemOffset || 0;
- }
- if (newRowIndex !== undefined) {
- let newPosition = this._treebox._getItemPosition(newRowIndex);
- this._treebox.scrollTo(newPosition - anchorOffset);
- }
-
- this.tree.invalidate();
- this.selection.selectEventsSuppressed = false;
- };
-
-
expandSelectedRows() {
- this.selection.selectEventsSuppressed = true;
- const selectedItems = this.getSelectedObjects();
- // Reverse sort so we don't mess up indices of subsequent
- // items when expanding
- const indices = Array.from(this.selection.selected).sort((a, b) => b - a);
- for (const index of indices) {
+ this._cacheState();
+ let rowsToOpen = [];
+ for (const index of this.selection.selected) {
if (this.isContainer(index) && !this.isContainerOpen(index)) {
- this.toggleOpenState(index, true);
+ rowsToOpen.push(index);
}
}
- this._refreshRowMap();
- this._restoreSelection(selectedItems, false, indices.length == 1);
- this.tree.invalidate();
- this.selection.selectEventsSuppressed = false;
+ this.rowProvider.expandRows(rowsToOpen);
}
-
collapseSelectedRows() {
- this.selection.selectEventsSuppressed = true;
- const selectedItems = this.getSelectedObjects();
- // Reverse sort and so we don't mess up indices of subsequent
- // items when collapsing
- const indices = Array.from(this.selection.selected).sort((a, b) => b - a);
- for (const index of indices) {
+ this._cacheState();
+ let rowsToClose = [];
+ for (const index of this.selection.selected) {
if (this.isContainer(index)) {
- this._closeContainer(index, true);
+ rowsToClose.push(index);
}
}
- this._refreshRowMap();
- this._restoreSelection(selectedItems, false, true);
- this.tree.invalidate();
- this.selection.selectEventsSuppressed = false;
+ this.rowProvider.collapseRows(rowsToClose);
}
// //////////////////////////////////////////////////////////////////////////////
@@ -2047,121 +1703,6 @@ var ItemTree = class ItemTree extends LibraryTree {
return this.getCellText(index, this.getSortField());
}
- async deleteSelection(force) {
- if (arguments.length > 1) {
- throw new Error("ItemTree.deleteSelection() no longer takes two parameters");
- }
-
- if (this.selection.count == 0) {
- return;
- }
-
- try {
- this.selection.selectEventsSuppressed = true;
-
- // Collapse open items
- for (var i = 0; i < this.rowCount; i++) {
- if (this.selection.isSelected(i) && this.isContainer(i)) {
- await this._closeContainer(i, false, true);
- }
- }
- this._refreshRowMap();
- this.tree.invalidate();
-
- let selectedObjects = [...this.selection.selected].map(index => this.getRow(index).ref);
- let selectedItems = selectedObjects.filter(o => o instanceof Zotero.Item);
- let selectedItemIDs = selectedItems.map(o => o.id);
-
- let collectionTreeRow = this.collectionTreeRow;
-
- // If all selected items are annotations, for now erase them skipping trash
- if (selectedItems.length && selectedItems.every(item => item.isAnnotation())) {
- await Zotero.Items.erase(selectedItemIDs);
- }
- else if (collectionTreeRow.isBucket()) {
- collectionTreeRow.ref.deleteItems(ids);
- }
- else if (collectionTreeRow.isTrash()) {
- let [trashedCollectionIDs, trashedSearches] = [[], []];
- for (let obj of selectedObjects) {
- if (obj instanceof Zotero.Collection) {
- trashedCollectionIDs.push(obj.id);
- }
- if (obj instanceof Zotero.Search) {
- trashedSearches.push(obj.id);
- }
- }
- if (trashedCollectionIDs.length > 0) {
- await Zotero.Collections.erase(trashedCollectionIDs);
- }
- if (trashedSearches.length > 0) {
- await Zotero.Searches.erase(trashedSearches);
- }
- if (selectedItemIDs.length > 0) {
- await Zotero.Items.erase(selectedItemIDs);
- }
- }
- else if (collectionTreeRow.isRecentlyRead() && !force) {
- await Zotero.DB.executeTransaction(async () => {
- for (let item of selectedItems) {
- let attachments;
- // Child attachment -- clear only this one
- if (item.isAttachment() && !item.isTopLevelItem()) {
- attachments = [item];
- }
- // Top-level item -- clear all child attachments
- else if (item.isTopLevelItem()) {
- attachments = item.isAttachment()
- ? [item]
- : Zotero.Items.get(item.getAttachments(false))
- .filter(a => a.attachmentLastRead);
- }
- // Child note or other non-attachment child -- skip
- else {
- continue;
- }
- for (let attachment of attachments) {
- attachment.attachmentLastRead = null;
- await attachment.save({ skipDateModifiedUpdate: true, skipEditCheck: true });
- }
- }
- });
- }
- else if (collectionTreeRow.isLibrary(true)
- || collectionTreeRow.isSearch()
- || collectionTreeRow.isUnfiled()
- || collectionTreeRow.isRecentlyRead()
- || collectionTreeRow.isRetracted()
- || collectionTreeRow.isDuplicates()
- || force) {
- await Zotero.Items.trashTx(selectedItemIDs);
- }
- else if (collectionTreeRow.isCollection()) {
- let collectionIDs = [collectionTreeRow.ref.id];
- if (Zotero.Prefs.get('recursiveCollections')) {
- collectionIDs.push(...collectionTreeRow.ref.getDescendents(false, 'collection').map(c => c.id));
- }
-
- await Zotero.DB.executeTransaction(async () => {
- for (let item of selectedItems) {
- for (let collectionID of collectionIDs) {
- item.removeFromCollection(collectionID);
- }
- await item.save({
- skipDateModifiedUpdate: true
- });
- }
- });
- }
- else if (collectionTreeRow.isPublications()) {
- await Zotero.Items.removeFromPublications(selectedItems);
- }
- }
- finally {
- this.selection.selectEventsSuppressed = false;
- }
- }
-
/**
* Get selected objects, including collections and searches in the trash
*/
@@ -2201,12 +1742,6 @@ var ItemTree = class ItemTree extends LibraryTree {
* @return {Number} - -1 for descending, 1 for ascending
*/
getSortDirection(sortFields) {
- if (this.collectionTreeRow.isFeedsOrFeed()) {
- return Zotero.Prefs.get('feeds.sortAscending') ? 1 : -1;
- }
- if (this.collectionTreeRow.isRecentlyRead()) {
- return -1;
- }
sortFields = sortFields || this.getSortFields();
const columns = this._getColumns();
for (const field of sortFields) {
@@ -2219,12 +1754,6 @@ var ItemTree = class ItemTree extends LibraryTree {
}
getSortField() {
- if (this.collectionTreeRow.isFeedsOrFeed()) {
- return 'id';
- }
- if (this.collectionTreeRow.isRecentlyRead()) {
- return 'lastRead';
- }
var column = this._sortedColumn;
if (!column) {
column = this._getColumns().find(col => !col.hidden);
@@ -2271,92 +1800,36 @@ var ItemTree = class ItemTree extends LibraryTree {
* @param selectAll {Boolean} Whether the selection is part of a select-all event
* @returns {Boolean}
*/
- isSelectable = (index, selectAll=false) => {
- if (!selectAll || !this._searchMode || this.collectionTreeRow.isPublications()) return true;
-
- let row = this.getRow(index);
- if (!row) {
- return false;
- }
- if (this.collectionTreeRow.isTrash()) {
- return row.ref.deleted;
- }
- else {
- return this._searchItemIDs.has(row.id);
- }
- };
-
- isContainer = (index) => {
- let item = this.getRow(index).ref;
- return item.isRegularItem() || item.isFileAttachment();
+ isSelectable(index, selectAll=false) {
+ // Override in subclasses
+ return true;
}
+
+ isContainer = (index) => this.rowProvider.isContainer(index);
- isContainerOpen = (index) => {
- return this.getRow(index).isOpen;
- };
+ isContainerOpen = (index) => this.rowProvider.isContainerOpen(index);
- isContainerEmpty = (index) => {
- if (this.props.regularOnly) {
- return true;
- }
+ isContainerEmpty = (index) => this.rowProvider.isContainerEmpty(index);
- var item = this.getRow(index).ref;
- if (item.isFileAttachment()) {
- // Consider attachments with non-matching annotation rows as empty when pref is set
- if (Zotero.Prefs.get("hideContextAnnotationRows") && this._searchMode) {
- return !item.getAnnotations().some(annotation => this._searchItemIDs.has(annotation.id));
- }
- return item.numAnnotations() == 0;
- }
- if (!item.isRegularItem()) {
- return true;
- }
- var includeTrashed = this.collectionTreeRow.isTrash();
- return item.numNotes(includeTrashed) === 0 && item.numAttachments(includeTrashed) == 0;
- };
+ getLevel = (index) => this.rowProvider.getLevel(index);
// Expand all ancestors of the specified item id
- expandToItem = async (id) => {
- let item = Zotero.Items.get(id);
- // Stop if the row already exists of if the item is not found
- if (this._rowMap[id] || !item) return;
- let toExpand = [];
- // Collect all ancestors of the item
- while (item.parentItemID) {
- item = Zotero.Items.get(item.parentItemID);
- toExpand.push(item.id);
- }
- if (!this.getRow(this._rowMap[item.id])) return;
- // Go through ancestors starting from the top-most one
- // and expand them if needed
- while (toExpand.length > 0) {
- let ancestorID = toExpand.pop();
- let ancestorRow = this._rowMap[ancestorID];
-
- // If the row for the next ancestor already exists, just move one
- if (toExpand.length > 0) {
- let nextAncestorID = toExpand[toExpand.length - 1];
- let nextAncestorRow = this._rowMap[nextAncestorID];
- if (this.getRow(nextAncestorRow)) continue;
- }
-
- // Close and re-open the ancestor to reveal the next row until
- // we reach the desired item
- await this._closeContainer(ancestorRow);
- await this.toggleOpenState(ancestorRow);
- }
- };
+ async expandToItem(id) {
+ this.rowProvider.expandToItem(id);
+ await this.waitForLoad();
+ }
////////////////////////////////////////////////////////////////////////////////
- ///
- /// Drag-and-drop methods
- ///
+ //
+ // Drag and Drop - override in subclasses for full implementation
+ //
////////////////////////////////////////////////////////////////////////////////
/**
- * Start a drag using HTML 5 Drag and Drop
+ * Start a drag using HTML 5 Drag and Drop.
+ * Base implementation - override in subclasses for full functionality.
*/
- onDragStart = (event, index) => {
+ onDragStart(event, index) {
// Propagate selection before we set the drag image if dragging not one of the selected rows
if (!this.selection.isSelected(index)) {
this.selection.select(index);
@@ -2373,510 +1846,41 @@ var ItemTree = class ItemTree extends LibraryTree {
// Get selected item IDs in the item tree order
itemIDs = this.getSortedItems(true).filter(id => itemIDs.includes(id));
- Zotero.DragDrop.currentDragSource = this.collectionTreeRow;
-
Zotero.Utilities.Internal.onDragItems(event, itemIDs, this._dragImageContainer);
};
/**
- * We use this to set the drag action, which is used by view.canDrop(),
- * based on the view's canDropCheck() and modifier keys.
+ * Handle drag over event.
+ * Base implementation - override in subclasses for full functionality.
*/
- onDragOver = (event, row) => {
- try {
- event.preventDefault();
- event.stopPropagation();
- var previousOrientation = Zotero.DragDrop.currentOrientation;
- Zotero.DragDrop.currentOrientation = getDragTargetOrient(event);
- Zotero.debug(`Dragging over item ${row} with ${Zotero.DragDrop.currentOrientation}, drop row: ${this._dropRow}`);
-
- var target = event.currentTarget;
- if (target.classList.contains('items-tree-message')) {
- let doc = target.ownerDocument;
- // Consider a drop on the items pane message box (e.g., when showing the welcome text)
- // a drop on the items tree
- if (target.firstChild.dataset.allowdrop) {
- target = doc.querySelector('#zotero-items-tree treechildren');
- }
- else {
- this.setDropEffect(event, "none");
- return false;
- }
- }
-
- if (!this.canDropCheck(row, Zotero.DragDrop.currentOrientation, event.dataTransfer)) {
- this.setDropEffect(event, "none");
- return false;
- }
-
- if (event.dataTransfer.getData("zotero/item")) {
- var sourceCollectionTreeRow = Zotero.DragDrop.getDragSource();
- if (sourceCollectionTreeRow) {
- var targetCollectionTreeRow = this.collectionTreeRow;
-
- if (!targetCollectionTreeRow) {
- this.setDropEffect(event, "none");
- return false;
- }
-
- if (sourceCollectionTreeRow.id == targetCollectionTreeRow.id) {
- // If dragging from the same source, do a move
- this.setDropEffect(event, "move");
- return false;
- }
- // If the source isn't a collection, the action has to be a copy
- if (!sourceCollectionTreeRow.isCollection()) {
- this.setDropEffect(event, "copy");
- return false;
- }
- // For now, all cross-library drags are copies
- if (sourceCollectionTreeRow.ref.libraryID != targetCollectionTreeRow.ref.libraryID) {
- this.setDropEffect(event, "copy");
- return false;
- }
- }
-
- if ((Zotero.isMac && event.metaKey) || (!Zotero.isMac && event.shiftKey)) {
- this.setDropEffect(event, "move");
- }
- else {
- this.setDropEffect(event, "copy");
- }
- }
- else if (event.dataTransfer.types.includes("application/x-moz-file")) {
- // As of Aug. 2013 nightlies:
- //
- // - Setting the dropEffect only works on Linux and OS X.
- //
- // - Modifier keys don't show up in the drag event on OS X until the
- // drop (https://bugzilla.mozilla.org/show_bug.cgi?id=911918),
- // so since we can't show a correct effect, we leave it at
- // the default 'move', the least misleading option, and set it
- // below in onDrop().
- //
- // - The cursor effect gets set by the system on Windows 7 and can't
- // be overridden.
- if (!Zotero.isMac) {
- if (event.shiftKey) {
- if (event.ctrlKey) {
- event.dataTransfer.dropEffect = "link";
- }
- else {
- event.dataTransfer.dropEffect = "move";
- }
- }
- else {
- event.dataTransfer.dropEffect = "copy";
- }
- }
- }
- return false;
- }
- finally {
- let prevDropRow = this._dropRow;
- if (event.dataTransfer.dropEffect != 'none') {
- this._dropRow = row;
- } else {
- this._dropRow = null;
- }
- if (prevDropRow != this._dropRow || previousOrientation != Zotero.DragDrop.currentOrientation) {
- typeof prevDropRow == 'number' && this.tree.invalidateRow(prevDropRow);
- this.tree.invalidateRow(row);
- }
- }
+ onDragOver(event, row) {
+ event.preventDefault();
+ event.stopPropagation();
+ this.setDropEffect(event, "none");
+ return false;
};
- onDragEnd = () => {
+ onDragEnd() {
this._dragImageContainer.innerHTML = "";
this._dropRow = null;
this.tree.invalidate();
};
- onDragLeave = () => {
+ onDragLeave() {
let dropRow = this._dropRow;
this._dropRow = null;
- this.tree.invalidateRow(dropRow);
+ if (dropRow !== null) {
+ this.tree.invalidateRow(dropRow);
+ }
};
+
/**
- * Called by treeRow.onDragOver() before setting the dropEffect
+ * Handle drop event.
+ * Base implementation does nothing - override in subclasses.
*/
- canDropCheck = (row, orient, dataTransfer) => {
- //Zotero.debug("Row is " + row + "; orient is " + orient);
-
- var dragData = Zotero.DragDrop.getDataFromDataTransfer(dataTransfer);
- if (!dragData) {
- Zotero.debug("No drag data");
- return false;
- }
- var dataType = dragData.dataType;
- var data = dragData.data;
-
- var collectionTreeRow = this.collectionTreeRow;
-
- if (row != -1 && orient == 0) {
- var rowItem = this.getRow(row).ref; // the item we are dragging over
- // Cannot drop anything on attachments/notes
- if (!rowItem.isRegularItem()) {
- return false;
- }
- }
-
- if (dataType == 'zotero/item') {
- let items = Zotero.Items.get(data);
-
- // Directly on a row
- if (rowItem) {
- var canDrop = false;
-
- for (let item of items) {
- // If any regular items, disallow drop
- if (item.isRegularItem()) {
- return false;
- }
-
- // Disallow drag of annotation items
- if (item.isAnnotation()) {
- return false;
- }
-
- // Disallow cross-library child drag
- if (item.libraryID != collectionTreeRow.ref.libraryID) {
- return false;
- }
-
- // Only allow dragging of notes and attachments
- // that aren't already children of the item
- if (item.parentItemID != rowItem.id) {
- canDrop = true;
- }
- }
- return canDrop;
- }
-
- // In library, allow children to be dragged out of parent
- else if (collectionTreeRow.isLibrary(true) || collectionTreeRow.isCollection()) {
- let targetRow = row != -1 ? this.getRow(row) : null;
- for (let item of items) {
- // Don't allow drag if any top-level items
- if (item.isTopLevelItem()) {
- return false;
- }
-
- // Disallow drag of annotation items
- if (item.isAnnotation()) {
- return false;
- }
-
- // Don't allow web attachments to be dragged out of parents,
- // except for files that can be recognized
- if (item.isWebAttachment()
- // Keep in sync with Zotero.RecognizeDocument.canRecognize()
- && !item.isPDFAttachment()
- && !item.isEPUBAttachment()) {
- return false;
- }
-
- // Can always drop into empty space
- if (!targetRow) continue;
- // Can only drop before or after a top-level item
- if (!targetRow.ref.isTopLevelItem()) return false;
- // Cannot drop between an opened container and the first child row
- if (orient == 1 && targetRow.isOpen) return false;
- // Cannot drop after the last child of a parent container
- if (orient == -1) {
- let parentIndex = this._rowMap[item.parentItemID];
- let nextParentIndex = null;
- for (let i = parentIndex + 1; i < this.rowCount; i++) {
- if (this.getLevel(i) == 0) {
- nextParentIndex = i;
- break;
- }
- }
- if (row === nextParentIndex) {
- return false;
- }
- }
-
- // Disallow cross-library child drag
- if (item.libraryID != collectionTreeRow.ref.libraryID) {
- return false;
- }
- }
- return true;
- }
- return false;
- }
- else if (dataType == 'application/x-moz-file') {
- // Disallow direct drop on a non-regular item (e.g. note)
- if (rowItem) {
- if (!rowItem.isRegularItem()) {
- return false;
- }
- }
- // Don't allow drop into searches or publications
- else if (collectionTreeRow.isSearch() || collectionTreeRow.isPublications()) {
- return false;
- }
-
- return true;
- }
-
- return false;
- };
-
- /*
- * Called when something's been dropped on or next to a row
- */
- onDrop = async (event, row) => {
- const dataTransfer = event.dataTransfer;
- var orient = Zotero.DragDrop.currentOrientation;
- if (row == -1) {
- row = 0;
- orient = -1;
- }
- this._dropRow = null;
- Zotero.DragDrop.currentDragSource = null;
- if (!dataTransfer.dropEffect || dataTransfer.dropEffect == "none") {
- return false;
- }
-
- var dragData = Zotero.DragDrop.getDataFromDataTransfer(dataTransfer);
- if (!dragData) {
- Zotero.debug("No drag data");
- return false;
- }
- var dropEffect = dragData.dropEffect;
- var dataType = dragData.dataType;
- var data = dragData.data;
- var sourceCollectionTreeRow = Zotero.DragDrop.getDragSource(dataTransfer);
- var collectionTreeRow = this.collectionTreeRow;
- var targetLibraryID = collectionTreeRow.ref.libraryID;
-
- if (dataType == 'zotero/item') {
- var ids = data;
- var items = Zotero.Items.get(ids);
- if (items.length < 1) {
- return;
- }
-
- // TEMP: This is always false for now, since cross-library drag
- // is disallowed in canDropCheck()
- //
- // TODO: support items coming from different sources?
- if (items[0].libraryID == targetLibraryID) {
- var sameLibrary = true;
- }
- else {
- var sameLibrary = false;
- }
-
- var toMove = [];
-
- // Dropped directly on a row
- if (orient == 0) {
- // Set drop target as the parent item for dragged items
- //
- // canDrop() limits this to child items
- var rowItem = this.getRow(row).ref; // the item we are dragging over
- await Zotero.DB.executeTransaction(async function () {
- for (let i=0; i 1;
-
- for (var i=0; i item.id));
- }
- }
- finally {
- await Zotero.Notifier.commit(notifierQueue);
- }
-
- // Automatically retrieve metadata for PDFs and ebooks
- if (!parentItemID) {
- Zotero.RecognizeDocument.autoRecognizeItems(addedItems);
- }
- }
+ async onDrop(event, row) {
+ // Base implementation - override in subclasses
};
// //////////////////////////////////////////////////////////////////////////////
@@ -2885,13 +1889,12 @@ var ItemTree = class ItemTree extends LibraryTree {
//
// //////////////////////////////////////////////////////////////////////////////
- buildColumnPickerMenu(menupopup) {
- const prefix = 'zotero-column-picker-';
- // Filter out ignored columns
- const columns = this._getColumns();
+ _buildColumnPickerMenu(menupopup, prefix, columns) {
let columnMenuitemElements = {};
+ // Build menuitem entries for all columns
for (let i = 0; i < columns.length; i++) {
const column = columns[i];
+ // Filter out columns that are not shown in the column picker
if (column.showInColumnPicker === false) continue;
let label = formatColumnName(column);
let menuitem = document.createXULElement('menuitem');
@@ -2902,7 +1905,7 @@ var ItemTree = class ItemTree extends LibraryTree {
if (!column.hidden) {
menuitem.setAttribute('checked', true);
}
- if (column.disabledIn && column.disabledIn.includes(this.collectionTreeRow.visibilityGroup)) {
+ if (column.disabledIn && column.disabledIn.includes(this.visibilityGroup)) {
menuitem.setAttribute('disabled', true);
}
columnMenuitemElements[column.dataKey] = menuitem;
@@ -2910,7 +1913,7 @@ var ItemTree = class ItemTree extends LibraryTree {
}
try {
- // More Columns menu
+ // Move columnPickerSubMenu columns to a "More Columns" submenu
let id = prefix + 'more-menu';
let moreMenu = document.createXULElement('menu');
@@ -2946,74 +1949,73 @@ var ItemTree = class ItemTree extends LibraryTree {
Zotero.logError(e);
Zotero.debug(e, 1);
}
+ }
- //
- // Secondary Sort menu
- //
- if (this.collectionTreeRow.isSortable()) {
- try {
- const id = prefix + 'sort-menu';
- const primaryField = this.getSortField();
- const sortFields = this.getSortFields();
- let secondaryField = false;
- if (sortFields[1]) {
- secondaryField = sortFields[1];
- }
-
- const primaryFieldLabel = formatColumnName(columns.find(c => c.dataKey == primaryField));
-
- const sortMenu = document.createXULElement('menu');
- sortMenu.setAttribute('label',
- Zotero.getString('pane.items.columnChooser.secondarySort', primaryFieldLabel));
- sortMenu.setAttribute('anonid', id);
-
- const sortMenuPopup = document.createXULElement('menupopup');
- sortMenuPopup.setAttribute('anonid', id + '-popup');
-
- // Generate menuitems
- const sortOptions = [
- 'title',
- 'firstCreator',
- 'itemType',
- 'date',
- 'year',
- 'publisher',
- 'publicationTitle',
- 'dateAdded',
- 'dateModified'
- ];
- for (let field of sortOptions) {
- // Hide current primary field, and don't show Year for Date, since it would be a no-op
- if (field == primaryField || (primaryField == 'date' && field == 'year')) {
- continue;
- }
- let column = columns.find(c => c.dataKey == field);
- let label = formatColumnName(column);
-
- let sortMenuItem = document.createXULElement('menuitem');
- sortMenuItem.setAttribute('fieldName', field);
- sortMenuItem.setAttribute('label', label);
- sortMenuItem.setAttribute('type', 'checkbox');
- if (field == secondaryField) {
- sortMenuItem.setAttribute('checked', 'true');
- }
- sortMenuItem.addEventListener('command', async () => {
- if (this._setSecondarySortField(field)) {
- await this.sort();
- }
- })
- sortMenuPopup.appendChild(sortMenuItem);
- }
-
- sortMenu.appendChild(sortMenuPopup);
- menupopup.appendChild(sortMenu);
+ _buildSecondarySortMenu(menupopup, prefix, columns) {
+ try {
+ const id = prefix + 'sort-menu';
+ const primaryField = this.getSortField();
+ const sortFields = this.getSortFields();
+ let secondaryField = false;
+ if (sortFields[1]) {
+ secondaryField = sortFields[1];
}
- catch (e) {
- Zotero.logError(e);
- Zotero.debug(e, 1);
+
+ const primaryFieldLabel = formatColumnName(columns.find(c => c.dataKey == primaryField));
+
+ const sortMenu = document.createXULElement('menu');
+ sortMenu.setAttribute('label',
+ Zotero.getString('pane.items.columnChooser.secondarySort', primaryFieldLabel));
+ sortMenu.setAttribute('anonid', id);
+
+ const sortMenuPopup = document.createXULElement('menupopup');
+ sortMenuPopup.setAttribute('anonid', id + '-popup');
+
+ // Generate menuitems
+ const sortOptions = [
+ 'title',
+ 'firstCreator',
+ 'itemType',
+ 'date',
+ 'year',
+ 'publisher',
+ 'publicationTitle',
+ 'dateAdded',
+ 'dateModified'
+ ];
+ for (let field of sortOptions) {
+ // Hide current primary field, and don't show Year for Date, since it would be a no-op
+ if (field == primaryField || (primaryField == 'date' && field == 'year')) {
+ continue;
+ }
+ let column = columns.find(c => c.dataKey == field);
+ let label = formatColumnName(column);
+
+ let sortMenuItem = document.createXULElement('menuitem');
+ sortMenuItem.setAttribute('fieldName', field);
+ sortMenuItem.setAttribute('label', label);
+ sortMenuItem.setAttribute('type', 'checkbox');
+ if (field == secondaryField) {
+ sortMenuItem.setAttribute('checked', 'true');
+ }
+ sortMenuItem.addEventListener('command', async () => {
+ if (this._setSecondarySortField(field)) {
+ await this.sort();
+ }
+ })
+ sortMenuPopup.appendChild(sortMenuItem);
}
+
+ sortMenu.appendChild(sortMenuPopup);
+ menupopup.appendChild(sortMenu);
}
+ catch (e) {
+ Zotero.logError(e);
+ Zotero.debug(e, 1);
+ }
+ }
+ _buildMoveColumnMenu(menupopup, prefix, columns) {
let sep = document.createXULElement('menuseparator');
// sep.setAttribute('anonid', prefix + 'sep');
menupopup.appendChild(sep);
@@ -3062,6 +2064,18 @@ var ItemTree = class ItemTree extends LibraryTree {
menuitem.addEventListener('command', () => this.tree._columns.restoreDefaultOrder());
menupopup.appendChild(menuitem);
}
+
+ buildColumnPickerMenu(menupopup) {
+ if (!this.props.columnPicker) return;
+ const prefix = 'zotero-column-picker-';
+ const columns = this._getColumns();
+
+ const columnMenuitemElements = this._buildColumnPickerMenu(menupopup, prefix, columns);
+ if (this.isSortable) {
+ this._buildSecondarySortMenu(menupopup, prefix, columns);
+ }
+ this._buildMoveColumnMenu(menupopup, prefix, columns);
+ }
buildSortMenu(menupopup) {
this._getColumns()
@@ -3095,262 +2109,10 @@ var ItemTree = class ItemTree extends LibraryTree {
// Private methods
//
// //////////////////////////////////////////////////////////////////////////////
-
- _renderPrimaryCell(index, data, column) {
- let span = document.createElement('span');
- span.className = `cell ${column.className}`;
- span.classList.add('primary');
-
- const item = this.getRow(index).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: [] });
-
- // Add colored tags first
- 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);
- }
-
- // Add emoji tags after
- 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;
- try {
- // Special treatment for trashed collections or searches since they are not an actual
- // item and do not have an item type
- if (item instanceof Zotero.Collection) {
- itemTypeAriaLabel = Zotero.getString('search-conditions-collection') + '.';
- }
- else if (item instanceof Zotero.Search) {
- itemTypeAriaLabel = Zotero.getString('search-conditions-savedSearch') + '.';
- }
- else {
- var itemType = Zotero.ItemTypes.getName(item.itemTypeID);
- itemTypeAriaLabel = Zotero.getString(`itemTypes.${itemType}`) + '.';
- }
- }
- catch (e) {
- Zotero.debug('Error attempting to get a localized item type label for ' + itemType, 1);
- Zotero.debug(e, 1);
- }
-
- let textSpan = document.createElement('span');
- let textWithFullStop = Zotero.Utilities.Internal.renderItemTitle(data, textSpan);
- if (!textWithFullStop.match(/\.$/)) {
- textWithFullStop += '.';
- }
- let textSpanAriaLabel = [textWithFullStop, itemTypeAriaLabel, tagAriaLabel, retractedAriaLabel].join(' ');
- textSpan.className = "cell-text";
- if (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;
- }
-
- _renderHasAttachmentCell(index, data, column) {
- let span = document.createElement('span');
- span.className = `cell ${column.className}`;
-
- if (this.collectionTreeRow.isTrash()) return span;
-
- const item = this.getRow(index).ref;
-
- if ((!this.isContainer(index) || !this.isContainerOpen(index))) {
- 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;
- }
- }
-
- // TEMP: For now, we use the blue bullet for all non-PDF attachments, but there's
- // commented-out code for showing different icons for snapshots, files, and URL/DOI links
- if (this._canGetBestAttachmentState(item)) {
- const { type, exists } = item.getBestAttachmentStateCached();
- let icon = "";
- let ariaLabel;
- // If the item has a child attachment
- 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');
- }
- }
- //else if (type == 'none') {
- // if (item.getField('url') || item.getField('DOI')) {
- // icon = getCSSIcon('IconLink');
- // ariaLabel = Zotero.getString('pane.item.attachments.hasLink');
- // icon.classList.add('cell-icon');
- // }
- //}
- if (ariaLabel) {
- icon.setAttribute('aria-label', ariaLabel + '.');
- span.setAttribute('title', ariaLabel);
- }
- span.append(icon);
-
- // Don't run this immediately since it might cause a db check and disk access
- // but delay for some time and see if the item is still visible in the tree
- // (i.e. if we haven't scrolled right past it)
- setTimeout(() => {
- if (!this.tree.rowIsVisible(index)) return;
- item.getBestAttachmentState()
- // Refresh cell when promise is fulfilled
- .then(({ type: newType, exists: newExists }) => {
- if (newType !== type || newExists !== exists) {
- this.tree.invalidateRow(index);
- }
- });
- }, ATTACHMENT_STATE_LOAD_DELAY);
- }
-
- return span;
- }
-
- _renderCell(index, data, column, isFirstColumn) {
- let cell;
- if (column.primary) {
- cell = this._renderPrimaryCell(index, data, column);
- }
- else if (column.dataKey === 'hasAttachment') {
- cell = this._renderHasAttachmentCell(index, data, column);
- }
- else if (column.renderCell) {
- try {
- // Pass document to renderCell so that it can create elements
- cell = column.renderCell.apply(this, [...arguments, document]);
- // Ensure that renderCell returns an Element
- if (!(cell instanceof window.Element)) {
- cell = null;
- throw new Error('renderCell must return an Element');
- }
- }
- catch (e) {
- Zotero.logError(e);
- }
- }
-
- if (!cell) {
- cell = renderCell.apply(this, arguments);
- 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');
- }
-
- if (isFirstColumn) {
- // Add depth indent, twisty and icon
- const depth = this.getLevel(index);
- let indentSpan = document.createElement('span');
- indentSpan.className = "cell-indent";
- indentSpan.style.paddingInlineStart = (CHILD_INDENT * depth) + 'px';
-
- let twisty;
- if (this.isContainerEmpty(index)) {
- twisty = document.createElement('span');
- twisty.classList.add("spacer-twisty");
- }
- else {
- twisty = getCSSIcon("twisty");
- twisty.classList.add('twisty');
- if (this.isContainerOpen(index)) {
- twisty.classList.add('open');
- }
- twisty.addEventListener('mousedown', event => event.stopPropagation());
- twisty.addEventListener('mouseup', event => this.handleTwistyMouseUp(event, index),
- { passive: true });
- twisty.addEventListener('dblclick', event => event.stopImmediatePropagation(),
- { passive: true });
- }
-
- const icon = this._getIcon(index);
- icon.classList.add('cell-icon');
-
- if (cell.querySelector('.cell-text') === null) {
- // convert text-only cell to a cell with text and icon
- let textSpan = document.createElement('span');
- textSpan.className = "cell-text";
- textSpan.innerHTML = cell.innerHTML;
- cell.innerHTML = "";
- cell.append(textSpan);
- }
-
- cell.prepend(indentSpan, twisty, icon);
- cell.classList.add('first-column');
- }
- return cell;
- }
-
- _renderItem(index, selection, oldDiv=null, columns) {
+
+ _renderItem(index, selection, oldDiv = null, columns = []) {
+ let row = this.getRow(index);
+ let rowData = this._getRowData(index);
let div;
if (oldDiv) {
div = oldDiv;
@@ -3358,15 +2120,16 @@ var ItemTree = class ItemTree extends LibraryTree {
}
else {
div = document.createElement('div');
- div.className = "row";
+ div.className = 'row';
}
+ // Toggle these here rather than in ItemTreeRow.renderRow()
+ // because div elements are reused to reduce DOM churn and need resetting
div.classList.toggle('selected', selection.isSelected(index));
div.classList.toggle('first-selected', selection.isFirstRowOfSelectionBlock(index));
div.classList.toggle('last-selected', selection.isLastRowOfSelectionBlock(index));
div.classList.toggle('focused', selection.focused == index);
div.classList.remove('drop', 'drop-before', 'drop-after');
- const rowData = this._getRowData(index);
div.classList.toggle('context-row', !!rowData.contextRow);
div.classList.toggle('unread', !!rowData.unread);
div.classList.toggle('highlighted', this._highlightedRows.has(rowData.id));
@@ -3374,14 +2137,19 @@ var ItemTree = class ItemTree extends LibraryTree {
let prevRowID = this.getRow(index - 1)?.id;
div.classList.toggle('first-highlighted', this._highlightedRows.has(rowData.id) && !this._highlightedRows.has(prevRowID));
div.classList.toggle('last-highlighted', this._highlightedRows.has(rowData.id) && !this._highlightedRows.has(nextRowID));
+ div.classList.toggle('annotation-row', row.type === 'annotation');
+ if (row.type !== 'annotation') {
+ div.classList.remove('tight');
+ }
if (this._dropRow == index) {
let span;
if (Zotero.DragDrop.currentOrientation != 0) {
span = document.createElement('span');
- span.className = Zotero.DragDrop.currentOrientation < 0 ? "drop-before" : "drop-after";
+ span.className = Zotero.DragDrop.currentOrientation < 0 ? 'drop-before' : 'drop-after';
div.appendChild(span);
- } else {
+ }
+ else {
div.classList.add('drop');
}
}
@@ -3392,129 +2160,82 @@ var ItemTree = class ItemTree extends LibraryTree {
: acc;
}, { lowestOrdinal: Infinity, firstColumn: null });
- let item = this.getRow(index).ref;
- for (let column of columns) {
- if (column.hidden) continue;
- // Annotation rows have a single cell, created below
- if (item.isAnnotation()) continue;
-
- div.appendChild(this._renderCell(index, rowData[column.dataKey], column, column === firstColumn));
- }
+ this._renderCtx.firstColumn = firstColumn;
+ this._renderCtx.includeTrashed = this.rowProvider.includeTrashed;
+
+ row.renderRow(div, index, columns, rowData, this._renderCtx);
if (!oldDiv) {
- // No drag-drop for collections or searches in the trash
- if (this.props.dragAndDrop && rowData.isItem) {
+ if (this.props.dragAndDrop && row.isDraggable) {
div.setAttribute('draggable', true);
div.addEventListener('dragstart', e => this.onDragStart(e, index), { passive: true });
div.addEventListener('dragover', e => this.onDragOver(e, index));
- div.addEventListener('dragend', this.onDragEnd, { passive: true });
- div.addEventListener('dragleave', this.onDragLeave, { passive: true });
+ div.addEventListener('dragend', this.onDragEnd.bind(this), { passive: true });
+ div.addEventListener('dragleave', this.onDragLeave.bind(this), { passive: true });
div.addEventListener('drop', (e) => {
e.stopPropagation();
this.onDrop(e, index);
}, { passive: true });
}
- div.addEventListener('mousedown', this._handleRowMouseUpDown, { passive: true });
- div.addEventListener('mouseup', this._handleRowMouseUpDown, { passive: true });
+ div.addEventListener('mousedown', this._handleRowMouseUpDown.bind(this), { passive: true });
+ div.addEventListener('mouseup', this._handleRowMouseUpDown.bind(this), { passive: true });
}
- // Accessibility
- div.setAttribute('role', 'treeitem');
- div.setAttribute('aria-level', this.getLevel(index) + 1);
- if (this.isContainerEmpty(index)) {
- div.removeAttribute('aria-expanded');
- }
- else {
- div.setAttribute('aria-expanded', this.isContainerOpen(index));
- }
if (rowData.contextRow) {
div.setAttribute('aria-disabled', true);
}
- // since row has been re-rendered, if it has been toggled open/close, we need to force twisty animation
- if (this._lastToggleOpenStateIndex === index) {
- let twisty = div.querySelector('.twisty');
- if (twisty) {
- twisty.classList.toggle('open', !this.isContainerOpen(index));
- setTimeout(() => {
- twisty.classList.toggle('open', this.isContainerOpen(index));
- }, 0);
- }
- }
-
- // Render annotation rows as a single cell with title/text and comment
- div.classList.toggle("annotation-row", item.isAnnotation());
- div.classList.remove("tight");
- if (item.isAnnotation()) {
- // Use "title" column as a blueprint to render the first part of annotation row
- let titleRowData = Object.assign({}, columns.find(column => column.dataKey == "title"));
- titleRowData.className = "title";
- let title;
- // Strip html tags from annotation comment and text until the algorithm
- // for safe rendering of relevant html tags is carried over from the reader
- let parserUtils = Cc["@mozilla.org/parserutils;1"].getService(Ci.nsIParserUtils);
- let plainText = parserUtils.convertToPlainText(item.annotationText || "", Ci.nsIDocumentEncoder.OutputRaw, 0);
- let plainComment = parserUtils.convertToPlainText(item.annotationComment || "", Ci.nsIDocumentEncoder.OutputRaw, 0);
- if (["highlight", "underline"].includes(item.annotationType)) {
- title = this._renderCell(index, plainText, titleRowData, true);
- let titleCell = title.querySelector(".cell-text");
- // Quote text is in italics
- titleCell.classList.add("italics");
- // Add quotation marks around the quoted text
- titleCell.setAttribute("q-mark-open", Zotero.getString("punctuation.openingQMark"));
- title.setAttribute("q-mark-close", Zotero.getString("punctuation.closingQMark"));
- if (item.annotationComment) {
- let comment = renderCell(null, plainComment, { className: "annotation-comment" });
- div.appendChild(comment);
- }
- // only keep default wider spacing if there are CJK characters
- let containsCJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(item.annotationText);
- div.classList.toggle("tight", !containsCJK);
- }
- else if (item.annotationComment) {
- // If there is a comment for image, ink, note annotations, use that as the title
- title = this._renderCell(index, plainComment, titleRowData, true);
- }
- else {
- // Catch all - use annotation type as the title
- let annotationTypeName = Zotero.getString(`reader-${item.annotationType}-annotation`);
- title = this._renderCell(index, annotationTypeName, titleRowData, true);
- }
- div.prepend(title);
- // Special handling of icon for the citation dialog. Since annotations are
- // not rendered within the layout of columns, we have to check for column rendered here.
- let addToCitationColumn = columns.find(column => column.dataKey == "addToCitation");
- if (addToCitationColumn) {
- let data = this.props.getExtraField(item, addToCitationColumn.dataKey);
- let icon = addToCitationColumn.renderer(index, data, addToCitationColumn);
- if (icon) {
- div.append(icon);
- }
- }
- }
return div;
- };
-
- _handleRowMouseUpDown = (event) => {
- const modifierIsPressed = ['ctrlKey', 'metaKey', 'shiftKey', 'altKey'].some(key => event[key]);
- if (this.collectionTreeRow.isDuplicates() && !modifierIsPressed) {
- this.duplicateMouseSelection = true;
- }
}
- _handleSelectionChange = (selection, shouldDebounce) => {
- if (this.collectionTreeRow.isDuplicates() && selection.count == 1 && this.duplicateMouseSelection) {
- var itemID = this.getRow(selection.focused).ref.id;
- var setItemIDs = this.collectionTreeRow.ref.getSetItemsByItemID(itemID);
-
- // We are modifying the selection object directly here
- // which won't trigger item updates
- for (let id of setItemIDs) {
- selection.selected.add(this._rowMap[id]);
- this.tree.invalidateRow(this._rowMap[id]);
+ _renderCell(index, data, column, isFirstColumn) {
+ const row = this.getRow(index);
+ let cell;
+
+ if (column.renderCell) {
+ try {
+ // Pass document to renderCell so that it can create elements
+ cell = column.renderCell.apply(this, [index, data, column, isFirstColumn, document]);
+ if (!(cell instanceof window.Element)) {
+ cell = null;
+ throw new Error('renderCell must return an Element');
+ }
}
+ catch (e) {
+ Zotero.logError(e);
+ }
+ column = Object.assign({}, column, { renderCell: null });
}
- this.duplicateMouseSelection = false;
+
+ if (!cell) {
+ cell = row.renderCell(index, data, column, isFirstColumn, this._renderCtx);
+ }
+
+ if (isFirstColumn) {
+ const icon = row.getIcon();
+ icon.classList.add('cell-icon', 'item-icon');
+
+ if (cell.querySelector('.cell-text') === null) {
+ let textSpan = document.createElement('span');
+ textSpan.className = 'cell-text';
+ textSpan.innerHTML = cell.innerHTML;
+ cell.innerHTML = '';
+ cell.append(textSpan);
+ }
+
+ cell.prepend(icon);
+ cell.classList.add('first-column');
+ }
+
+ return cell;
+ }
+
+
+ _handleRowMouseUpDown(event) {
+ // Base implementation - override in subclasses for special behavior
+ }
+
+ _handleSelectionChange(selection, shouldDebounce) {
if (shouldDebounce) {
this._onSelectionChangeDebounced();
}
@@ -3523,46 +2244,6 @@ var ItemTree = class ItemTree extends LibraryTree {
}
}
- async _closeContainer(index, skipRowMapRefresh, dontEnsureRowsVisible=false) {
- // isContainer == false shouldn't happen but does if an item is dragged over a closed
- // container until it opens and then released, since the container is no longer in the same
- // place when the spring-load closes
- if (!this.isContainer(index)) return;
- if (!this.isContainerOpen(index)) return;
-
- if (!skipRowMapRefresh) {
- var savedSelection = this.getSelectedObjects();
- }
-
- var count = 0;
- var level = this.getLevel(index);
-
- // Remove child rows
- while ((index + 1 < this._rows.length) && (this.getLevel(index + 1) > level)) {
- // Skip the map update here and just refresh the whole map below,
- // since we might be removing multiple rows
- // Also, do not update the selection with each row removal for better performance
- // when attachment with many annotations is being closed if the selection
- // is already being restored in the end
- this._removeRow(index + 1, true, !skipRowMapRefresh);
- count++;
- }
-
- this._rows[index].isOpen = false;
-
- if (count == 0) {
- return;
- }
-
- if (!skipRowMapRefresh) {
- Zotero.debug('Refreshing item row map');
- this._refreshRowMap();
-
- await this._refreshPromise;
- this._restoreSelection(savedSelection, false, dontEnsureRowsVisible);
- this.tree.invalidate();
- }
- }
/**
* Returns an object describing the row data for each column.
@@ -3570,14 +2251,14 @@ var ItemTree = class ItemTree extends LibraryTree {
* @param index {Integer} the row index
* @returns {Object}
*/
- _getRowData = (index) => {
+ _getRowData(index) {
var treeRow = this.getRow(index);
if (!treeRow) {
throw new Error(`Attempting to get row data for a non-existant tree row ${index}`);
}
var itemID = treeRow.id;
- // If value is available, retrieve synchronously
+ // If value is available, retrieve immediatelly
if (this._rowCache[itemID]) {
return this._rowCache[itemID];
}
@@ -3599,9 +2280,7 @@ var ItemTree = class ItemTree extends LibraryTree {
row.unread = true;
}
- if (!(treeRow.ref instanceof Zotero.Collection || treeRow.ref instanceof Zotero.Search)) {
- row.itemType = Zotero.ItemTypes.getLocalizedString(treeRow.ref.itemTypeID);
- }
+ row.itemType = treeRow.getTypeLabel();
// Year column is just date field truncated
row.year = treeRow.getField('date', true).substr(0, 4);
if (row.year) {
@@ -3617,29 +2296,9 @@ var ItemTree = class ItemTree extends LibraryTree {
row.numNotes = treeRow.numNotes() || "";
row.feed = (treeRow.ref.isFeedItem && Zotero.Feeds.get(treeRow.ref.libraryID).name) || "";
row.lastRead = row.isItem ? treeRow.ref.getItemLastRead() : "";
- row.addedBy = row.isItem && treeRow.ref.createdByUserID
- ? Zotero.Users.getName(treeRow.ref.createdByUserID) : "";
- row.lastModifiedBy = row.isItem
- && (treeRow.ref.lastModifiedByUserID || treeRow.ref.createdByUserID)
- ? Zotero.Users.getName(treeRow.ref.lastModifiedByUserID
- || treeRow.ref.createdByUserID) : "";
-
- if (treeRow.ref.isFileAttachment()
- // TODO: Adjust this if we localize "Snapshot"
- && !(treeRow.ref.isSnapshotAttachment() && /snapshot/i.test(treeRow.ref.getField('title')))
- && Zotero.Prefs.get('showAttachmentFilenames')) {
- try {
- row.title = treeRow.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)
- row.title = treeRow.ref.attachmentPath;
- }
- }
- else {
- row.title = treeRow.ref.getDisplayTitle();
- }
+ row.addedBy = row.isItem && treeRow.getAddedBy();
+ row.lastModifiedBy = row.isItem && treeRow.getLastModifiedBy();
+ row.title = treeRow.getDisplayTitle();
const columns = this.getColumns();
for (let col of columns) {
@@ -3661,10 +2320,6 @@ var ItemTree = class ItemTree extends LibraryTree {
case 'dateAdded':
case 'dateModified':
case 'accessDate':
- case 'date':
- if (key == 'date' && !this.collectionTreeRow.isFeedsOrFeed()) {
- break;
- }
if (val) {
let date = Zotero.Date.sqlToDate(val, true);
if (date) {
@@ -3695,7 +2350,7 @@ var ItemTree = class ItemTree extends LibraryTree {
}
_getColumnPrefs = () => {
- if (!this.props.persistColumns) return {};
+ if (!this.props.columnPicker) return {};
return this._columnPrefs || {};
}
@@ -3705,7 +2360,7 @@ var ItemTree = class ItemTree extends LibraryTree {
this._columns = this._columns.map(column => Object.assign(column, prefs[column.dataKey]))
.sort((a, b) => a.ordinal - b.ordinal);
- if (!this.props.persistColumns) return;
+ if (!this.props.columnPicker) return;
Zotero.debug(`Storing itemTree ${this.id} column prefs`, 2);
// Preserve prefs for columns not active in the current view (e.g.,
// group-only columns when viewing a personal library)
@@ -3731,7 +2386,7 @@ var ItemTree = class ItemTree extends LibraryTree {
}
_loadColumnPrefsFromFile = async () => {
- if (!this.props.persistColumns) return;
+ if (!this.props.columnPicker) return;
try {
let columnPrefs = await Zotero.File.getContentsAsync(COLUMN_PREFS_FILEPATH);
let persistSettings = JSON.parse(columnPrefs);
@@ -3749,7 +2404,7 @@ var ItemTree = class ItemTree extends LibraryTree {
* @returns {Promise}
*/
_writeColumnPrefsToFile = async (force=false) => {
- if (!this.props.persistColumns) return;
+ if (!this.props.columnPicker) return;
var writeToFile = async () => {
try {
let persistSettingsString = await Zotero.File.getContentsAsync(COLUMN_PREFS_FILEPATH);
@@ -3804,11 +2459,7 @@ var ItemTree = class ItemTree extends LibraryTree {
}
_getColumns() {
- if (!this.collectionTreeRow) {
- return [];
- }
-
- const visibilityGroup = this.collectionTreeRow.visibilityGroup;
+ const visibilityGroup = this.visibilityGroup;
const prefKey = this.id;
// Include group status in cache key so groupLibrariesOnly columns
// are recalculated when switching between personal and group libraries
@@ -3818,7 +2469,7 @@ var ItemTree = class ItemTree extends LibraryTree {
return this._columns;
}
- this._columnsId = cacheKey;
+ this._columnsId = prefKey;
this._columns = [];
let columnsSettings = this._getColumnPrefs();
@@ -3827,7 +2478,7 @@ var ItemTree = class ItemTree extends LibraryTree {
const columns = this.getColumns();
let hasDefaultIn = columns.some(column => 'defaultIn' in column);
for (let column of columns) {
- if (this.props.persistColumns) {
+ if (this.props.columnPicker) {
if (column.disabledIn && column.disabledIn.includes(visibilityGroup)) continue;
if (column.groupLibrariesOnly && (!this.collectionTreeRow.isWithinGroup || !this.collectionTreeRow.isWithinGroup())) continue;
const columnSettings = columnsSettings[column.dataKey];
@@ -3858,6 +2509,9 @@ var ItemTree = class ItemTree extends LibraryTree {
if (column.sortDirection) {
this._sortedColumn = column;
}
+ if (!column.hidden && column.dependsOnChildren) {
+ this._hasDependOnChildrenColumn = true;
+ }
this._columns.push(column);
}
@@ -3886,128 +2540,24 @@ var ItemTree = class ItemTree extends LibraryTree {
return this._getColumns()[index];
}
- _updateIntroText() {
- if (!window.ZoteroPane) {
- return;
- }
-
- if (this.collectionTreeRow && !this.rowCount) {
- let doc = this._ownerDocument;
- let div;
-
- // My Library and no groups
- if (this.collectionTreeRow.isLibrary() && !Zotero.Groups.getAll().length) {
- div = doc.createElement('div');
- let p = doc.createElement('p');
- let html = Zotero.getString(
- 'pane.items.intro.text1',
- [
- Zotero.clientName
- ]
- );
- // Encode special chars, which shouldn't exist
- html = Zotero.Utilities.htmlSpecialChars(html);
- html = `${html}`;
- p.innerHTML = html;
- div.appendChild(p);
-
- p = doc.createElement('p');
- html = Zotero.getString(
- 'pane.items.intro.text2',
- [
- Zotero.getString('connector.name', Zotero.clientName),
- Zotero.clientName
- ]
- );
- // Encode special chars, which shouldn't exist
- html = Zotero.Utilities.htmlSpecialChars(html);
- html = html.replace(
- /\[([^\]]+)](.+)\[([^\]]+)]/,
- `$1`
- + '$2'
- + `$3`
- );
- p.innerHTML = html;
- div.appendChild(p);
-
- p = doc.createElement('p');
- html = Zotero.getString('pane.items.intro.text3', [Zotero.clientName]);
- // Encode special chars, which shouldn't exist
- html = Zotero.Utilities.htmlSpecialChars(html);
- html = html.replace(
- /\[([^\]]+)]/,
- '$1'
- );
- p.innerHTML = html;
- div.appendChild(p);
-
- // Activate text links
- for (let span of div.getElementsByTagName('span')) {
- if (span.classList.contains('text-link')) {
- span.setAttribute('role', 'link');
- if (span.hasAttribute('data-href')) {
- span.onclick = function () {
- doc.defaultView.ZoteroPane.loadURI(this.getAttribute('data-href'));
- };
- }
- else if (span.hasAttribute('data-action')) {
- if (span.getAttribute('data-action') == 'open-sync-prefs') {
- span.onclick = () => {
- Zotero.Utilities.Internal.openPreferences('zotero-prefpane-account');
- };
- }
- }
- }
- }
-
- div.dataset.allowdrop = true;
- }
- // My Publications
- else if (this.collectionTreeRow.isPublications()) {
- div = doc.createElement('div');
- div.className = 'publications';
- let p = doc.createElement('p');
- p.textContent = Zotero.getString('publications.intro.text1', ZOTERO_CONFIG.DOMAIN_NAME);
- div.appendChild(p);
-
- p = doc.createElement('p');
- p.textContent = Zotero.getString('publications.intro.text2');
- div.appendChild(p);
-
- p = doc.createElement('p');
- let html = Zotero.getString('publications.intro.text3');
- // Convert tags to placeholders
- html = html.replace('', ':b:').replace('', ':/b:');
- // Encode any other special chars, which shouldn't exist
- html = Zotero.Utilities.htmlSpecialChars(html);
- // Restore bold text
- html = html.replace(':b:', '').replace(':/b:', '');
- p.innerHTML = html; // AMO note: markup from hard-coded strings and filtered above
- div.appendChild(p);
- }
- if (div) {
- this._introText = true;
- doc.defaultView.ZoteroPane_Local.setItemsPaneMessage(div);
- return;
- }
- this._introText = null;
- }
-
- if (this._introText || this._introText === null) {
- window.ZoteroPane.clearItemsPaneMessage();
- this._introText = false;
- }
- }
/**
- * Restore a scroll position returned from _saveScrollPosition()
+ * Restore scroll position from either a provided object or the cached scroll position.
+ * If scrollPosition is null, restores from cache and clears it.
+ * If scrollPosition is provided, restores from it without touching the cache.
+ *
+ * @param {Object|null} scrollPosition - Scroll position to restore, or null to use cached
*/
- _rememberScrollPosition(scrollPosition) {
+ _restoreScrollPosition(scrollPosition = null) {
+ if (scrollPosition === null) {
+ scrollPosition = this._cachedScrollPosition;
+ this._cachedScrollPosition = null;
+ }
if (!scrollPosition || !scrollPosition.id || !this._treebox) {
return;
}
- var row = this.getRowIndexByID(scrollPosition.id);
- if (row === false) {
+ var row = this._rowMap[scrollPosition.id];
+ if (row === undefined) {
return;
}
this._treebox.scrollToRow(Math.max(row - scrollPosition.offset, 0), true);
@@ -4022,11 +2572,10 @@ var ItemTree = class ItemTree extends LibraryTree {
if (!this._treebox) return false;
var treebox = this._treebox;
var first = treebox.getFirstVisibleRow();
- if (!first) {
+ if (first === undefined || first === null) {
return false;
}
var last = treebox.getLastVisibleRow();
- var firstSelected = null;
for (let i = first; i <= last; i++) {
// If an object is selected, keep the first selected one in position
if (this.selection.isSelected(i)) {
@@ -4039,6 +2588,14 @@ var ItemTree = class ItemTree extends LibraryTree {
}
}
+ // With no selection to anchor to, don't save a scroll position when the
+ // view is already at the top of the list. Otherwise restoring after an
+ // insertion would pin the previously-top row in place (pushing the view
+ // down) instead of leaving the list scrolled to its new top.
+ if (!first) {
+ return false;
+ }
+
// Otherwise keep the first visible row in position
let row = this.getRow(first);
if (!row) return false;
@@ -4048,83 +2605,23 @@ var ItemTree = class ItemTree extends LibraryTree {
};
}
- _saveOpenState(close) {
- if (!this.tree) return [];
- var itemIDs = [];
- var toClose = [];
- if (close) {
- if (!this.selection.selectEventsSuppressed) {
- var unsuppress = this.selection.selectEventsSuppressed = true;
- }
- }
- for (var i=0; i= 0; i--) {
- let row = this._rowMap[toClose[i]];
- this._closeContainer(row, true);
- }
- this._refreshRowMap();
- if (unsuppress) {
- this.selection.selectEventsSuppressed = false;
- }
- }
- return itemIDs;
- }
-
- _rememberOpenState(itemIDs, secondLevel = false) {
- if (!this.tree) return;
- var rowsToOpen = [];
- var nextLevelToOpen = [];
- for (let id of itemIDs) {
- var row = this._rowMap[id];
- // Item may not still exist
- if (row == undefined) {
- if (!secondLevel) {
- nextLevelToOpen.push(id);
- }
- continue;
- }
- rowsToOpen.push(row);
- }
- rowsToOpen.sort(function (a, b) {
- return a - b;
- });
-
- if (!this.selection.selectEventsSuppressed) {
- var unsuppress = this.selection.selectEventsSuppressed = true;
- }
- // Reopen from bottom up
- for (var i=rowsToOpen.length-1; i>=0; i--) {
- this.toggleOpenState(rowsToOpen[i], true);
- }
- this._refreshRowMap();
-
- if (nextLevelToOpen.length) {
- this._rememberOpenState(nextLevelToOpen, true);
- }
- if (unsuppress) {
- this.selection.selectEventsSuppressed = false;
- }
- }
-
/**
+ * Restore selection from either a provided array or the cached selection.
+ * If selection is null, restores from cache and clears it.
+ * If selection is provided, restores from it without touching the cache.
*
- * @param selection
+ * @param {Array|null} selection - Selection to restore, or null to use cached selection
* @param {Boolean} expandCollapsedParents - if an item to select is in a collapsed parent
* will expand the parent, otherwise the item is ignored
- * @param {Boolean} dontEnsureRowsVisible - do not scroll the item tree after restoring selection
+ * @param {Boolean} ensureRowsAreVisible - scroll the item tree after restoring selection
* to ensure restored selection is visible
* @private
*/
- async _restoreSelection(selection, expandCollapsedParents=true, dontEnsureRowsVisible=false) {
+ async _restoreSelection(selection = null, expandCollapsedParents = true, ensureRowsAreVisible = true) {
+ if (selection === null) {
+ selection = this._cachedSelection;
+ this._cachedSelection = [];
+ }
if (!selection.length || !this._treebox) {
return;
}
@@ -4147,31 +2644,19 @@ var ItemTree = class ItemTree extends LibraryTree {
}).bind(this);
try {
for (let i = 0; i < selection.length; i++) {
- if (this._rowMap[selection[i].treeViewID] != null) {
+ if (this._rowMap[selection[i].treeViewID] !== undefined) {
+ toggleSelect(selection[i].treeViewID);
+ }
+ else if (expandCollapsedParents && this.rowProvider._expandToItem(selection[i].treeViewID)) {
+ // Try expanding to item
toggleSelect(selection[i].treeViewID);
}
- // Try the parent
else {
- let item = selection[i];
- if (!item) {
- continue;
- }
+ // Try selecting the parent (child gone in this view)
+ var parent = selection[i].parentItemID;
- var parent = item.parentItemID;
- if (!parent) {
- continue;
- }
-
- if (this._rowMap[parent] != null) {
- if (expandCollapsedParents) {
- await this._closeContainer(this._rowMap[parent]);
- await this.toggleOpenState(this._rowMap[parent]);
- toggleSelect(selection[i].treeViewID);
- }
- else {
- !this.selection.isSelected(this._rowMap[parent]) &&
- toggleSelect(parent);
- }
+ if (parent && this._rowMap[parent] !== undefined && !this.selection.isSelected(this._rowMap[parent])) {
+ toggleSelect(parent);
}
}
}
@@ -4184,7 +2669,7 @@ var ItemTree = class ItemTree extends LibraryTree {
Zotero.logError(e);
}
- if (!dontEnsureRowsVisible) {
+ if (ensureRowsAreVisible) {
this.ensureRowsAreVisible(Array.from(this.selection.selected));
}
@@ -4201,10 +2686,8 @@ var ItemTree = class ItemTree extends LibraryTree {
if (!this._cachedBestAttachmentStates) {
let t = new Date();
for (let i = 0; i < this._rows.length; i++) {
- let item = this.getRow(i).ref;
- if (this._canGetBestAttachmentState(item)) {
- await item.getBestAttachmentState();
- }
+ let row = this.getRow(i);
+ await row.getBestAttachmentState();
}
Zotero.debug("Cached best attachment states in " + (new Date - t) + " ms");
this._cachedBestAttachmentStates = true;
@@ -4230,7 +2713,6 @@ var ItemTree = class ItemTree extends LibraryTree {
}
}
- await this._refreshPromise;
this.selection.selectEventsSuppressed = true;
await this.sort();
this.forceUpdate(() => {
@@ -4262,8 +2744,8 @@ var ItemTree = class ItemTree extends LibraryTree {
popupset.appendChild(menupopup);
menupopup.openPopupAtScreen(
- window.screenX + event.clientX + 2,
- window.screenY + event.clientY + 2,
+ event.screenX + 1,
+ event.screenY + 1,
true
);
}
@@ -4307,94 +2789,28 @@ var ItemTree = class ItemTree extends LibraryTree {
return true;
}
- _getIcon(index) {
- var item = this.getRow(index).ref;
-
- // Non-item objects that can be appear in the trash
- if (item instanceof Zotero.Collection || item instanceof Zotero.Search) {
- let icon;
- if (item instanceof Zotero.Collection) {
- icon = getCSSIcon('collection');
- }
- else if (item instanceof Zotero.Search) {
- icon = getCSSIcon('search');
- }
- icon.classList.add('icon-item-type');
- return icon;
- }
-
- var itemType = item.getItemTypeIconName();
- if (item.isAnnotation()) {
- return getCSSItemTypeIcon(itemType, `annotation-${item.annotationType}-${item.annotationColor}`);
- }
- return getCSSItemTypeIcon(itemType);
- }
-
- _canGetBestAttachmentState(item) {
- return (item.isRegularItem() && item.numAttachments())
- || (item.isFileAttachment() && item.isTopLevelItem());
- }
-
- _getTagSwatch(tag, color) {
- let span = document.createElement('span');
- span.className = 'tag-swatch';
- let extractedEmojis = Zotero.Tags.extractEmojiForItemsList(tag);
- // If contains emojis, display directly
- //
- // TODO: Check for a maximum number of graphemes, which is hard to do
- // https://stackoverflow.com/a/54369605
- if (extractedEmojis) {
- span.textContent = extractedEmojis;
- span.className += ' emoji';
- }
- // Otherwise display color
- else {
- span.className += ' colored';
- span.dataset.color = color.toLowerCase();
- span.style.color = color;
- }
- return span;
- }
-
+ /**
+ * Reset column state: invalidate the derived-columns cache, re-render the
+ * item tree so fresh props flow down, and rebuild the VirtualizedTable's
+ * internal `Columns` object.
+ *
+ * Does NOT refresh rows or restore selection. Callers that need row data
+ * refreshed (e.g. because column visibility affects what's shown) must
+ * either follow this with a refresh or call `refreshAndMaintainSelection`
+ * explicitly (see e.g. the custom-column change path in `notify()`).
+ */
async _resetColumns(){
this._columnsId = null;
return new Promise((resolve) => this.forceUpdate(async () => {
await this.tree._resetColumns();
- await this.refreshAndMaintainSelection();
resolve();
}));
}
};
-var ItemTreeRow = function(ref, level, isOpen)
-{
- this.ref = ref; //the item associated with this
- this.level = level;
- this.isOpen = isOpen;
- this.id = ref.treeViewID;
-}
-
-ItemTreeRow.prototype.getField = function(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);
-}
-
-ItemTreeRow.prototype.numNotes = function() {
- if (this.ref.isNote()) {
- return 0;
- }
- if (this.ref.isAttachment()) {
- return this.ref.note !== '' ? 1 : 0;
- }
- return this.ref.numNotes(false, true) || 0;
-}
-
Zotero.Utilities.Internal.makeClassEventDispatcher(ItemTree);
+Zotero.Utilities.Internal.makeClassEventDispatcher(ItemTreeRowProvider);
module.exports = ItemTree;
+module.exports.ItemTreeRow = ItemTreeRow;
+module.exports.ItemTreeRowProvider = ItemTreeRowProvider;
diff --git a/chrome/content/zotero/itemTreeColumns.jsx b/chrome/content/zotero/itemTreeColumns.jsx
index 2d733bfa5d..f1b33c437f 100644
--- a/chrome/content/zotero/itemTreeColumns.jsx
+++ b/chrome/content/zotero/itemTreeColumns.jsx
@@ -188,6 +188,7 @@ const COLUMNS = [
},
{
dataKey: "lastRead",
+ sortReverse: true,
defaultSort: -1,
defaultIn: ["recentlyRead"],
disabledIn: ["feeds", "feed"],
diff --git a/chrome/content/zotero/itemTreeRow.js b/chrome/content/zotero/itemTreeRow.js
new file mode 100644
index 0000000000..09099c1548
--- /dev/null
+++ b/chrome/content/zotero/itemTreeRow.js
@@ -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;
diff --git a/chrome/content/zotero/libraryTree.js b/chrome/content/zotero/libraryTree.js
index 219274d026..5c682ba20c 100644
--- a/chrome/content/zotero/libraryTree.js
+++ b/chrome/content/zotero/libraryTree.js
@@ -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() {
diff --git a/chrome/content/zotero/selectItemsDialog.js b/chrome/content/zotero/selectItemsDialog.js
index ee0669e107..d69e3a122a 100644
--- a/chrome/content/zotero/selectItemsDialog.js
+++ b/chrome/content/zotero/selectItemsDialog.js
@@ -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,
diff --git a/chrome/content/zotero/xpcom/collectionTreeRow.js b/chrome/content/zotero/xpcom/collectionTreeRow.js
index a9e75151c8..38ded96839 100644
--- a/chrome/content/zotero/xpcom/collectionTreeRow.js
+++ b/chrome/content/zotero/xpcom/collectionTreeRow.js
@@ -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;
- }
-}
diff --git a/chrome/content/zotero/xpcom/data/item.js b/chrome/content/zotero/xpcom/data/item.js
index f53e15bf08..93e198c41d 100644
--- a/chrome/content/zotero/xpcom/data/item.js
+++ b/chrome/content/zotero/xpcom/data/item.js
@@ -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) {
diff --git a/chrome/content/zotero/xpcom/utilities_internal.js b/chrome/content/zotero/xpcom/utilities_internal.js
index b7d587fdbd..7e96a57336 100644
--- a/chrome/content/zotero/xpcom/utilities_internal.js
+++ b/chrome/content/zotero/xpcom/utilities_internal.js
@@ -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);
}
diff --git a/chrome/content/zotero/zoteroPane.js b/chrome/content/zotero/zoteroPane.js
index 13c9a6b85d..412e75d0fc 100644
--- a/chrome/content/zotero/zoteroPane.js
+++ b/chrome/content/zotero/zoteroPane.js
@@ -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);
});
diff --git a/resource/concurrentCaller.mjs b/resource/concurrentCaller.mjs
index 18be395d13..598e19110b 100644
--- a/resource/concurrentCaller.mjs
+++ b/resource/concurrentCaller.mjs
@@ -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");
diff --git a/scss/components/_item-tree.scss b/scss/components/_item-tree.scss
index f16bf2974e..e432c1df5a 100644
--- a/scss/components/_item-tree.scss
+++ b/scss/components/_item-tree.scss
@@ -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;
}
diff --git a/scss/components/_virtualized-table.scss b/scss/components/_virtualized-table.scss
index 14c9bf1c14..215a751ad5 100644
--- a/scss/components/_virtualized-table.scss
+++ b/scss/components/_virtualized-table.scss
@@ -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 {
diff --git a/test/tests/citationDialogTest.js b/test/tests/citationDialogTest.js
index d2c3093023..6c325357e1 100644
--- a/test/tests/citationDialogTest.js
+++ b/test/tests/citationDialogTest.js
@@ -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
diff --git a/test/tests/collectionTreeRowTest.js b/test/tests/collectionTreeRowTest.js
index 6fdbe0eb4d..f7bf4ae284 100644
--- a/test/tests/collectionTreeRowTest.js
+++ b/test/tests/collectionTreeRowTest.js
@@ -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
diff --git a/test/tests/collectionTreeTest.js b/test/tests/collectionTreeTest.js
index 6ec655af02..6a31e182e9 100644
--- a/test/tests/collectionTreeTest.js
+++ b/test/tests/collectionTreeTest.js
@@ -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');
diff --git a/test/tests/itemTreeTest.js b/test/tests/collectionViewItemTreeTest.js
similarity index 82%
rename from test/tests/itemTreeTest.js
rename to test/tests/collectionViewItemTreeTest.js
index b2604dc5fe..d0daf04983 100644
--- a/test/tests/itemTreeTest.js
+++ b/test/tests/collectionViewItemTreeTest.js
@@ -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();
+ }
+ });
+ });
})
diff --git a/test/tests/itemTreeRowTest.js b/test/tests/itemTreeRowTest.js
new file mode 100644
index 0000000000..f7095e05bc
--- /dev/null
+++ b/test/tests/itemTreeRowTest.js
@@ -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'));
+ });
+});
diff --git a/test/tests/tagSelectorTest.js b/test/tests/tagSelectorTest.js
index 22a33c5c9b..965de0c9e5 100644
--- a/test/tests/tagSelectorTest.js
+++ b/test/tests/tagSelectorTest.js
@@ -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();
+ }
+ });
+ });
})
diff --git a/test/tests/virtualized-tableTest.js b/test/tests/virtualized-tableTest.js
new file mode 100644
index 0000000000..f2e5301226
--- /dev/null
+++ b/test/tests/virtualized-tableTest.js
@@ -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);
+ });
+ });
+});