diff --git a/chrome/content/zotero/advancedSearch.js b/chrome/content/zotero/advancedSearch.js index 06634fbfd9..b28c441147 100644 --- a/chrome/content/zotero/advancedSearch.js +++ b/chrome/content/zotero/advancedSearch.js @@ -24,7 +24,7 @@ */ -import ItemTree from 'zotero/itemTree'; +import CollectionViewItemTree from 'zotero/collectionViewItemTree'; import { COLUMNS } from 'zotero/itemTreeColumns'; @@ -39,6 +39,7 @@ var ZoteroAdvancedSearch = new function () { var _searchBox; var _libraryID; + var _searchCounter = 0; async function onLoad() { _searchBox = document.getElementById('zotero-search-box'); @@ -61,16 +62,16 @@ var ZoteroAdvancedSearch = new function () { column.hidden = !['title', 'firstCreator', 'year', 'hasAttachment'].includes(column.dataKey); return column; }); - this.itemsView = await ItemTree.init(elem, { + this.itemsView = await CollectionViewItemTree.init(elem, { id: "advanced-search", dragAndDrop: true, - persistColumns: true, columnPicker: true, onActivate: this.onItemActivate.bind(this), columns, }); await this.itemsView.changeCollectionTreeRow({ + id: 'advanced-search-' + _searchCounter++, ref: _searchBox.search, visibilityGroup: 'default', isSearchMode: () => true, @@ -103,7 +104,8 @@ var ZoteroAdvancedSearch = new function () { _searchBox.updateSearch(); _searchBox.active = true; - var collectionTreeRow = { + return this.itemsView.changeCollectionTreeRow({ + id: 'advanced-search-' + _searchCounter++, ref: _searchBox.search, visibilityGroup: 'default', isSearchMode: () => true, @@ -115,21 +117,8 @@ var ZoteroAdvancedSearch = new function () { search.libraryID = _libraryID; var ids = await search.search(); return Zotero.Items.get(ids); - }, - isLibrary: () => false, - isCollection: () => false, - isPublications: () => false, - isDuplicates: () => false, - isFeed: () => false, - isFeeds: () => false, - isFeedsOrFeed: () => false, - isRecentlyRead: () => false, - isSortable: () => true, - isShare: () => false, - isTrash: () => false - }; - - return this.itemsView.changeCollectionTreeRow(collectionTreeRow); + } + }); } diff --git a/chrome/content/zotero/collectionTree.jsx b/chrome/content/zotero/collectionTree.jsx index 5a264f19c0..94ffbacdc3 100644 --- a/chrome/content/zotero/collectionTree.jsx +++ b/chrome/content/zotero/collectionTree.jsx @@ -26,7 +26,7 @@ const React = require('react'); const ReactDOM = require('react-dom'); const LibraryTree = require('./libraryTree'); -const VirtualizedTable = require('components/virtualized-table'); +const VirtualizedTree = require('components/virtualized-table').VirtualizedTree; const { getCSSIcon } = require('components/icons'); const { getDragTargetOrient } = require('components/utils'); const { noop } = require("./components/utils"); @@ -70,6 +70,8 @@ var CollectionTree = class CollectionTree extends LibraryTree { this.type = 'collection'; this.name = "CollectionTree"; this.id = "collection-tree"; + this._rows = []; + this._rowMap = {}; this._highlightedRows = new Set(); this._unregisterID = Zotero.Notifier.registerObserver( this, @@ -300,12 +302,6 @@ var CollectionTree = class CollectionTree extends LibraryTree { // Div creation and content let div = oldDiv || document.createElement('div'); div.innerHTML = ""; - // When a hidden focused row is added last during filtering, it - // is removed on focus change, which can happen at the same time as rendering. - // In this case, just return empty div. - if (index >= this._rows.length) { - return div; - } // Classes div.className = "row"; @@ -463,7 +459,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { } render() { - return React.createElement(VirtualizedTable, + return React.createElement(VirtualizedTree, { getRowCount: () => this._rows.length, id: this.id, @@ -478,7 +474,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { isContainer: this.isContainer, isContainerEmpty: this.isContainerEmpty, isContainerOpen: this.isContainerOpen, - toggleOpenState: this.toggleOpenState, + onToggleOpenState: this.toggleOpenState, getRowString: this.getRowString.bind(this), onItemContextMenu: (...args) => this.props.onContextMenu && this.props.onContextMenu(...args), @@ -486,7 +482,6 @@ var CollectionTree = class CollectionTree extends LibraryTree { onKeyDown: this.handleKeyDown, onActivate: (...args) => (this.props.onActivate ? this.props.onActivate(...args) : this.handleActivate(...args)), - role: 'tree', label: Zotero.getString('pane.collections.title') } ); diff --git a/chrome/content/zotero/collectionViewItemTree.jsx b/chrome/content/zotero/collectionViewItemTree.jsx new file mode 100644 index 0000000000..4399c88fe6 --- /dev/null +++ b/chrome/content/zotero/collectionViewItemTree.jsx @@ -0,0 +1,1918 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2024 Corporation for Digital Scholarship + Vienna, Virginia, USA + http://zotero.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + +/** + * CollectionViewItemTree - Item tree for collection-based views. + * + * This class extends ItemTree with behaviors for views backed by a CollectionTreeRow: + * - Drag and drop with library/collection awareness + * - Duplicates set selection + * - Feed-specific sorting + * - Colored tag keyboard shortcuts + * - Intro/welcome text + * - Collection-aware delete behavior + * - Quick search integration + * + * Used in ZoteroPane, Advanced Search, and other collection-based contexts. + */ + +const { getDragTargetOrient } = require("components/utils"); +const React = require('react'); +const ReactDOM = require('react-dom'); +const ItemTree = require('zotero/itemTree'); +const { ItemTreeRowProvider } = ItemTree; + +const { OS } = ChromeUtils.importESModule("chrome://zotero/content/osfile.mjs"); +const { ZOTERO_CONFIG } = ChromeUtils.importESModule('resource://zotero/config.mjs'); + +const COLORED_TAGS_RE = new RegExp("^(?:Numpad|Digit)([0-" + Zotero.Tags.MAX_COLORED_TAGS + "]{1})$"); + +// Minimal CollectionTreeRow-like object for callers that pass plain objects to +// changeCollectionTreeRow()/setCollectionTreeRow() (e.g. advanced search). +const STUB_COLLECTION_TREE_ROW = { + // Properties set by CollectionTreeRow constructor + view: null, + type: null, + ref: {}, + level: 0, + isOpen: false, + onUnload: null, + searchText: "", + searchMode: "search", + tags: [], + + // Extra props/methods expected by CollectionViewItemTree + visibilityGroup: "", + getItems: async () => [], + isSearchMode: () => false, + isLibrary: () => false, + isCollection: () => false, + isSearch: () => false, + isPublications: () => false, + isDuplicates: () => false, + isFeed: () => false, + isFeeds: () => false, + isFeedsOrFeed: () => false, + isShare: () => false, + isTrash: () => false, + isBucket: () => false, + isUnfiled: () => false, + isRetracted: () => false, + isRecentlyRead: () => false, + isSortable: () => true, + setSearch: () => false, + setTags: () => false, + clearCache: () => {} +}; + +class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider { + constructor(itemTree) { + super(itemTree); + this.collectionTreeRow = null; + } + + /** + * Check if the quick search box has a search term. + * @returns {boolean} + */ + hasQuickSearch() { + return this.collectionTreeRow?.searchText.length > 0; + } + + /** + * Set a new collectionTreeRow and refresh items. + * This handles the data/model logic; UI orchestration stays in ItemTree. + * @param {Object} collectionTreeRow - The collection tree row to set + * @returns {Promise} + */ + async setCollectionTreeRow(collectionTreeRow) { + // Normalize stub objects to include default CollectionTreeRow methods + if (collectionTreeRow.constructor.name == "Object") { + collectionTreeRow = Object.assign({}, STUB_COLLECTION_TREE_ROW, collectionTreeRow); + } + // No-op if same collection + if (this.collectionTreeRow && this.collectionTreeRow.id === collectionTreeRow.id) { + return; + } + this.collectionTreeRow = collectionTreeRow; + + // Set ID based on visibilityGroup + const visibilityGroup = collectionTreeRow.visibilityGroup || 'default'; + let treeID = "item-tree-" + this.itemTree.props.id; + if (visibilityGroup != 'default') { + treeID += "-" + visibilityGroup; + } + // Needs to be called after this.collectionTreeRow is set so that this.itemTree.visibilityGroup is correct + let idChanged = await this.itemTree.setId(treeID); + + this._includeTrashed = collectionTreeRow.isTrash(); + // Emit loading state - only setCollectionTreeRow shows loading UI + await this.runListeners('update', null, { loading: true }); + await this.itemTree._ensureSortContextReady(); + this.itemTree._getColumns(); + await this.refresh({ forceSortAll: idChanged }); + } + + /** + * Set a filter on the item tree. + * This handles the data/model logic; UI orchestration stays in ItemTree. + * @param {string} type - Filter type ('search', 'citation-search', 'tags') + * @param {*} data - Filter data + * @returns {Promise} + */ + async setFilter(type, data) { + let changed; + switch (type) { + case 'search': + changed = this.collectionTreeRow.setSearch(data); + break; + case 'citation-search': + changed = this.collectionTreeRow.setSearch(data, 'fields'); + break; + case 'tags': + changed = this.collectionTreeRow.setTags(data); + break; + default: + throw ('Invalid filter type in setFilter'); + } + if (changed) { + await this.refresh({ restoreSelection: true }); + } + } + + /** + * Core refresh logic - data work only, no update emissions. + * Handles _refreshPromise so sort waits for refresh to complete. + * + * @param {Object} options - Additional options + * @param {boolean} options.forceSortAll - Whether to force sorting of all items. + */ + async _refresh(options = {}) { + Zotero.debug('Refreshing items list for ' + this.itemTree.id); + + var deferred = Zotero.Promise.defer(); + this.itemTree._refreshPromise = deferred.promise; + + try { + this.collectionTreeRow.clearCache(); + // Get the full set of items we want to show + let newSearchItems = await this.collectionTreeRow.getItems(); + if (this.collectionTreeRow.isTrash()) { + // When in trash, also fetch trashed collections and searched + // So that they are displayed among deleted items + newSearchItems = newSearchItems + .concat(await this.collectionTreeRow.getTrashedCollections()) + .concat(await Zotero.Searches.getDeleted(this.collectionTreeRow.ref.libraryID)); + } + // Remove notes and attachments if necessary + if (this.itemTree.props.regularOnly) { + newSearchItems = newSearchItems.filter((item) => { + return item instanceof Zotero.Collection + || item instanceof Zotero.Search + || item.isRegularItem(); + }); + } + let newSearchItemIDs = new Set(newSearchItems.map(item => item.treeViewID)); + // Find the items that aren't yet in the tree + let itemsToAdd = newSearchItems.filter(item => this._rowMap[item.treeViewID] === undefined); + // Find the parents of search matches + let newSearchParentIDs = new Set( + this.itemTree.props.regularOnly + ? [] + : newSearchItems.filter(item => !!item.parentItemID).map(item => item.parentItemID) + ); + this._searchParentIDs = newSearchParentIDs; + + var newCellTextCache = {}; + var newSearchMode = this.collectionTreeRow.isSearchMode(); + var newRows = []; + var allItemIDs = new Set(); + var addedItemIDs = new Set(); + + // Copy old rows to new array, omitting top-level items not in the new set and their children + // + // This doesn't add new child items to open parents or remove child items that no longer exist, + // which is done by toggling all open containers below. + var skipChildren; + for (let i = 0; i < this._rows.length; i++) { + let row = this._rows[i]; + // Top-level items + if (row.level == 0) { + // A top-level attachment moved into a parent. Don't copy, it will be added + // via this loop for the parent item. + if (row.ref instanceof Zotero.Item && row.ref.parentID) { + continue; + } + let attachments = (!this.itemTree.props.regularOnly && row.ref.isRegularItem()) + ? row.ref.getAttachments() + : []; + let isSearchParent = newSearchParentIDs.has(row.ref.treeViewID) || attachments.some(id => newSearchParentIDs.has(id)); + // If not showing children or no children match the search, close + if (this.itemTree.props.regularOnly || !isSearchParent) { + row.isOpen = false; + skipChildren = true; + } + else { + skipChildren = false; + } + // Skip items that don't match the search and don't have children that do + if (!newSearchItemIDs.has(row.ref.treeViewID) && !isSearchParent) { + continue; + } + } + else if (row.level == 1 && !row.ref.parentID) { + // A child attachment moved into top-level. It needs to be added anew in a different + // location. + itemsToAdd.push(row.ref); + continue; + } + // Child items + else if (skipChildren) { + continue; + } + if (!allItemIDs.has(row.ref.id)) { + newRows.push(row); + allItemIDs.add(row.ref.treeViewID); + } + } + + // Add new items + for (let i = 0; i < itemsToAdd.length; i++) { + let item = itemsToAdd[i]; + + // If child item matches search and parent hasn't yet been added, add parent + let parentItemID = item.parentItemID; + if (parentItemID) { + if (allItemIDs.has(parentItemID)) { + continue; + } + item = Zotero.Items.get(parentItemID); + // Go up one more level to check for parents of annotation rows + let parentsParent = item.parentItemID; + if (parentsParent) { + if (allItemIDs.has(parentsParent)) { + continue; + } + item = Zotero.Items.get(parentsParent); + } + } + // Parent item may have already been added from child + else if (allItemIDs.has(item.treeViewID)) { + continue; + } + + // Add new top-level items + let row = this.createRow(item, 0, false); + if (!allItemIDs.has(item.treeViewID)) { + newRows.push(row); + allItemIDs.add(item.treeViewID); + addedItemIDs.add(item.treeViewID); + } + } + + this._rows = newRows; + this.refreshRowMap(); + await this.itemTree._ensureSortContextReady(); + this._sort(options.forceSortAll ? null : [...addedItemIDs]); + + // Toggle all open containers closed and open to refresh child items + var t = new Date(); + for (let i = this.rows.length - 1; i >= 0; i--) { + if (this.isContainerOpen(i)) { + this._refreshContainer(i, true); + } + } + this.refreshRowMap(); + Zotero.debug(`Refreshed open parents in ${new Date() - t} ms`); + + this._searchMode = newSearchMode; + this._searchItemIDs = newSearchItemIDs; // items matching the search + this.itemTree.invalidateRowCache(true); + + if (!this.collectionTreeRow.isPublications()) { + this._expandMatchParents(newSearchParentIDs); + } + + deferred.resolve(); + } + catch (e) { + this._rows = []; + this.refreshRowMap(); + deferred.reject(e); + throw e; + } + } + + _expandMatchParents() { + const searchParentIDs = this.searchParentIDs; + // Expand parents of child matches + if (!this._searchMode || this.itemTree.props.regularOnly) { + return; + } + + let rowsToOpen = []; + for (let i = 0; i < this.rowCount; i++) { + if (!this.isContainer(i) || this.isContainerOpen(i)) { + continue; + } + let item = this.getRow(i).ref; + let attachments = item.isRegularItem() ? item.getAttachments() : []; + // expand item row if it is a parent of a match + // OR if it has a child that is a parent of a match + let shouldBeOpened = searchParentIDs.has(item.id) || attachments.some(id => searchParentIDs.has(id)); + if (shouldBeOpened) { + rowsToOpen.push(i); + } + } + this._expandRows(rowsToOpen); + } + + expandMatchParents() { + this._expandMatchParents(); + this.runListeners('update', true, { restoreSelection: true, ensureRowsAreVisible: true }); + } + + /** + * Public refresh - calls _refresh() and emits update. + * Does NOT emit loading state - caller is responsible for that. + * @param {Object} options - Additional options + * @param {boolean} options.restoreSelection - Whether to restore the cached selection. + * @param {boolean} options.forceSortAll - Whether to force sorting of all items. + */ + refresh = Zotero.serial(async function (options = {}) { + try { + await this._refresh(options); + + this.runListeners('update', true, options); + await this.itemTree.waitForLoad(); + this.itemTree.runListeners('refresh'); + } + catch (e) { + // SearchError is thrown by CollectionTreeRow.getSearchResults() when the + // underlying search query fails (e.g., a saved search with invalid conditions like + // "too many SQL variables"). We show a load-error message but don't re-throw, so + // the UI stays functional and the user can still edit/delete the broken search + // from the collection tree. See Zotero.CollectionTreeRow.SearchError and the + // constructor comment in CollectionTreeRow for the full caching/error design. + if (e instanceof Zotero.CollectionTreeRow.SearchError) { + this.runListeners('update', true, { + message: Zotero.getString('pane.items.loadError') + }); + return; + } + await Zotero.Promise.delay(); + this.runListeners('update', true, { + message: Zotero.getString('pane.items.loadError') + }); + throw e; + } + }) + + /* + * Called by Zotero.Notifier on any changes to items in the data layer. + * ZoteroPane-specific implementation with full add/remove/modify handling. + */ + async notify(action, type, ids, extraData) { + // Wait for any in-progress refresh to complete (including view update) + await this.itemTree._refreshPromise; + + const cachedSelection = this.itemTree._cachedSelection; + const collectionTreeRow = this.collectionTreeRow; + + var madeChanges = false; + var refresh = false; + var sort = false; + + // Selection strategy + let firstAffectedRowIdx = this._rowMap[ + // 'collection-item' ids are in the form - + // 'item' events are just integers + type == 'collection-item' ? ids[0].split('-')[1] : ids[0] + ]; + + let selectInActiveWindow = false; + let restoreSelection = true; + let restoreScroll = true; + let rowsToSelect = null; + let items = null; + + // 'collection-item' ids are in the form collectionID-itemID + if (type == 'collection-item') { + if (!collectionTreeRow.isCollection()) { + return; + } + + var visibleSubcollections = Zotero.Prefs.get('recursiveCollections') + ? collectionTreeRow.ref.getDescendents(false, 'collection') + : []; + var splitIDs = []; + for (let id of ids) { + var split = id.split('-'); + // Include if an item in this collection or a visible subcollection + if (split[0] == collectionTreeRow.ref.id + || visibleSubcollections.some(c => split[0] == c.id)) { + splitIDs.push(split[1]); + } + } + ids = splitIDs; + } + + if (type == 'item' && action == 'add') { + if (!items) items = Zotero.Items.get(ids); + + // When an image is pasted into a note, an invisible attachment child + // of that note is created. Filter out such items, since they + // do not appear in the itemTree and should not cause a refresh. + items = items.filter(item => !item.isEmbeddedImageAttachment()); + // If there are no other items, just stop. + if (items.length == 0) return; + } + + if (action == 'refresh') { + // Clear row display cache and invalidate rows for refreshed items + let rowsToInvalidate = []; + for (let id of ids) { + let row = this._rowMap[id]; + if (row === undefined) continue; + rowsToInvalidate.push(row); + } + + // For a refresh on an item in the trash, check if the item hadn't been restored + if (type == 'item' && collectionTreeRow.isTrash()) { + let rows = []; + for (let id of ids) { + let row = this.getRowIndexByID(id); + if (row === false) continue; + let item = Zotero.Items.get(id); + let isParentTrashed = item.parentItemID + ? Zotero.Items.get(item.parentItemID).deleted + : false; + // Remove parent row if it isn't deleted, its parent isn't deleted, and it + // doesn't have any deleted children (shown by numChildren including deleted + // being the same as numChildren not including deleted) + if (!item.deleted && !isParentTrashed + && (!item.isRegularItem() || item.numChildren(true) == item.numChildren(false))) { + rows.push(row); + // And all its children in the tree + for (let child = row + 1; child < this.getRowCount() && this.itemTree.getLevel(child) > this.itemTree.getLevel(row); child++) { + rows.push(child); + } + } + } + if (rows.length) { + this._removeRows(rows); + rowsToInvalidate = true; // all rows + this.runListeners('update', true); + } + } + + this.itemTree.invalidateRowCache(ids); + if (rowsToInvalidate) { + await this.runListeners('update', rowsToInvalidate); + } + return; + } + + if ((action == 'remove' && !collectionTreeRow.isLibrary(true)) + || action == 'delete' || action == 'trash' + || (action == 'removeDuplicatesMaster' && collectionTreeRow.isDuplicates())) { + // Since a remove involves shifting of rows, we have to do it in order, + // so sort the ids by row + var rows = []; + let push = action == 'delete' || action == 'trash' || action == 'removeDuplicatesMaster'; + for (var i = 0, len = ids.length; i < len; i++) { + if (!push) { + push = !collectionTreeRow.ref.hasItem(ids[i]); + } + // Row might already be gone (e.g. if this is a child and + // 'modify' was sent to parent) + let row = this._rowMap[ids[i]]; + if (push && row !== undefined) { + // Don't remove child items from collections, because it's handled by 'modify' + if (action == 'remove' && this.itemTree.getParentIndex(row) != -1) { + continue; + } + rows.push(row); + + // Remove child items of removed parents + if (this.itemTree.isContainer(row) && this.itemTree.isContainerOpen(row)) { + while (++row < this.getRowCount() && this.itemTree.getLevel(row) > 0) { + rows.push(row); + } + } + } + } + + if (rows.length > 0) { + this._removeRows(rows); + madeChanges = true; + } + } + else if (collectionTreeRow.isSearchMode() && ['item', 'collection', 'search'].includes(type) && ['add', 'modify'].includes(action)) { + // If search mode, just re-run search + if (action == 'add' && this.hasQuickSearch()) { + // For item adds, clear the quick search, unless all the new items have + // skipSelect or are child items + if (!items) items = Zotero.Items.get(ids); + let clear = false; + for (let item of items) { + if (!extraData[item.id].skipSelect && item.isTopLevelItem()) { + clear = true; + break; + } + } + if (clear) { + var search = document.getElementById('zotero-tb-search'); + if (search) { + search.searchTextbox.value = ''; + } + this.collectionTreeRow.setSearch(''); + } + } + this.itemTree.invalidateRowCache(ids); + refresh = true; + madeChanges = true; + // refresh automatically sorts newly added items, so only need to sort on modify + sort = action == 'modify' ? (ids.length === 1 ? ids[0] : true) : false; + } + else if (type === 'item' && action == 'modify') { + if (!items) items = Zotero.Items.get(ids); + + for (let i = 0; i < items.length; i++) { + let item = items[i]; + let id = item.id; + + let row = this._rowMap[id]; + + // Deleted items get a modify that we have to ignore when + // not viewing the trash + if (item.deleted) { + continue; + } + + // Item already exists in this view + if (row !== undefined) { + let parentItemID = this.getRow(row).ref.parentItemID; + let parentIndex = this.itemTree.getParentIndex(row); + + // If item moved from top level to under another item, remove the old row + if (parentIndex == -1 && parentItemID) { + // Close container to remove any sub-items + this._closeContainer(row, true); + this._removeRow(row); + } + // If moved from under another item to top level, remove old row and add new one + else if (parentIndex != -1 && !parentItemID) { + this._closeContainer(row, true); + this._removeRow(row, true); + + let beforeRow = this.getRowCount(); + this._addRow(this.createRow(item, 0, false), beforeRow); + + sort = id; + } + // If moved from one parent to another, remove from old parent + else if (parentItemID && parentIndex != -1 && this._rowMap[parentItemID] != parentIndex) { + this._refreshContainer(parentIndex); + + const newParentIndex = this._rowMap[parentItemID]; + if (newParentIndex !== undefined) { + this._refreshContainer(newParentIndex); + } + } + // If Unfiled Items and item was added to a collection, remove from view + else if (this.itemTree.isContainer(row) && collectionTreeRow.isUnfiled() && item.getCollections().length) { + this._closeContainer(row); + this._removeRow(row); + } + else { + // If not moved from under one item to another, just resort the row + sort = id; + } + + madeChanges = true; + } + // Otherwise, for a top-level item in a library root or a collection + // containing the item, the item has to be added + else if (item.isTopLevelItem()) { + // Root view + let add = collectionTreeRow.isLibrary(true) + && collectionTreeRow.ref.libraryID == item.libraryID; + // Collection containing item + if (!add && collectionTreeRow.isCollection()) { + add = item.inCollection(collectionTreeRow.ref.id); + } + if (add) { + // Most likely, the note or attachment's parent was removed. + let beforeRow = this.getRowCount(); + this._addRow(this.createRow(item, 0, false), beforeRow); + madeChanges = true; + sort = id; + } + } + // If a trashed child item is restored while its parent's row is expanded, + // collapse and re-open the parent to have that child item row added. + else { + let parentItemRowIndex = this._rowMap[item.parentItemID]; + if (parentItemRowIndex === undefined) continue; + if (this.isContainerOpen(parentItemRowIndex)) { + this._refreshContainer(parentItemRowIndex); + } + } + + if (sort && ids.length != 1) { + sort = true; + } + } + + this.itemTree.invalidateRowCache(ids); + } + else if (type == 'item' && action == 'add') { + if (!items) items = Zotero.Items.get(ids); + for (let item of items) { + // if the item belongs in this collection + if (((collectionTreeRow.isLibrary(true) + && collectionTreeRow.ref.libraryID == item.libraryID) + || (collectionTreeRow.isCollection() && item.inCollection(collectionTreeRow.ref.id))) + // if we haven't already added it to our hash map + && !this._rowMap[item.id] + // Regular item or standalone note/attachment + && item.isTopLevelItem()) { + let beforeRow = this.getRowCount(); + this._addRow(this.createRow(item, 0, false), beforeRow); + madeChanges = true; + } + } + if (madeChanges) { + sort = (items.length == 1) ? [items[0].id] : true; + } + } + + + // Additional handling for modification of child items when dependsOnChildren column is visible. + if (type == 'item' && action == 'modify' && this.itemTree.hasDependOnChildrenColumn) { + if (!items) items = Zotero.Items.get(ids); + + let parentItemIDs = []; + for (let item of items) { + item.parentItemID && parentItemIDs.push(item.parentItemID); + } + if (parentItemIDs.length) { + this.itemTree.invalidateRowCache(parentItemIDs); + // If we're sorting by a dependsOnChildren column, also re-sort + if (this.itemTree._sortedColumn?.dependsOnChildren) { + sort = true; + } + // In Recently Read, remove parent items that no longer have any lastRead attachments + // But only if a refresh isn't scheduled already anyway + if (!refresh && collectionTreeRow.isRecentlyRead()) { + let rowsToRemove = []; + for (let parentID of parentItemIDs) { + let parentRow = this._rowMap[parentID]; + if (parentRow === undefined) continue; + let parentItem = Zotero.Items.get(parentID); + if (!parentItem.getItemLastRead()) { + rowsToRemove.push(parentRow); + } + } + if (rowsToRemove.length) { + this._removeRows(rowsToRemove); + } + } + madeChanges = true; + } + } + + if (refresh) { + await this._refresh(); + } + if (sort) { + await this.itemTree._ensureSortContextReady(); + this._sort(typeof sort == 'number' ? [sort] : false); + } + + if (madeChanges) { + this.refreshRowMap(); + + // If we refreshed, we have to clear the cache + if (!refresh) { + this.collectionTreeRow.clearCache(); + } + + var singleSelect = false; + // If adding a single top-level item and this is the active window, select it + if (action == 'add') { + if (ids.length == 1) { + singleSelect = ids[0]; + } + // If there's only one parent item in the set of added items, + // mark that for selection in the UI + // + // Only bother checking for single parent item if 1-5 total items, + // since a translator is unlikely to save more than 4 child items + else if (ids.length <= 5) { + if (!items) items = Zotero.Items.get(ids); + if (items) { + let itemTypeAttachment = Zotero.ItemTypes.getID('attachment'); + let itemTypeNote = Zotero.ItemTypes.getID('note'); + + var found = false; + for (let item of items) { + // Check for attachment and note types, since it's quicker + // than checking for parent item + if (item.itemTypeID == itemTypeAttachment || item.itemTypeID == itemTypeNote) { + continue; + } + + // We already found a top-level item, so cancel the + // single selection + if (found) { + singleSelect = false; + break; + } + found = true; + singleSelect = item.id; + } + } + } + } + + if (singleSelect) { + if (!extraData[singleSelect] || !extraData[singleSelect].skipSelect) { + selectInActiveWindow = true; + rowsToSelect = singleSelect; + restoreSelection = false; + restoreScroll = false; + } + } + // If a single item was selected, got modified, and still belongs in this view + // (e.g. it wasn't filtered out by getting moved to a different collection + // or no longer belongs under current search filter), select it + else if (action == 'modify' && ids.length == 1 + && cachedSelection.length == 1 && cachedSelection[0].id === ids[0] + && this.getRowIndexByID(ids[0]) !== false) { + selectInActiveWindow = true; + rowsToSelect = ids; + } + // On removal of a selected row, select item at previous position + else if (cachedSelection.length) { + if ((action == 'remove' + || action == 'trash' + || action == 'delete' + || action == 'removeDuplicatesMaster') + && cachedSelection.some(o => this.getRowIndexByID(o.id) === false)) { + // In duplicates view, select the next set on delete + if (collectionTreeRow.isDuplicates()) { + if (this._rows[firstAffectedRowIdx]) { + var itemID = this._rows[firstAffectedRowIdx].ref.id; + var setItemIDs = collectionTreeRow.ref.getSetItemsByItemID(itemID); + rowsToSelect = setItemIDs; + restoreSelection = false; + } + } + 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 && firstAffectedRowIdx > 0) { + let previousItem = Zotero.Items.get(ids[0]); + if (previousItem && !previousItem.isTopLevelItem()) { + if (this._rows[firstAffectedRowIdx] + && this.getLevel(firstAffectedRowIdx) == 0) { + firstAffectedRowIdx--; + } + } + } + + if (firstAffectedRowIdx !== undefined && firstAffectedRowIdx in this._rows) { + rowsToSelect = this._rows[firstAffectedRowIdx].id; + restoreSelection = false; + } + // If no item at previous position, select last item in list + else if (this._rows.length > 0 && this._rows[this._rows.length - 1]) { + rowsToSelect = this._rows[this._rows.length - 1].id; + restoreSelection = false; + } + } + } + } + + await this.runListeners('update', true, { + restoreSelection, + restoreScroll, + selectInActiveWindow, + selection: rowsToSelect + }); + } + } +} + + +class CollectionViewItemTree extends ItemTree { + constructor(props) { + super(props); + // Set on changeCollectionTreeRow(); + this._id = null; + this._refreshPromise = Zotero.Promise.resolve(); + this.duplicateMouseSelection = false; + + this.rowProvider = new CollectionViewItemTreeRowProvider(this); + this._setRowProviderUpdateHandler(); + + // Triggered when the item tree is refreshed: + // - Collection/view changed (changeCollectionTreeRow) + // - Search/filter updated (setFilter) + // - Items added/removed/modified (notify -> refresh) + this.onRefresh = this.createEventBinding('refresh'); + } + + get collectionTreeRow() { return this.rowProvider.collectionTreeRow; } + + get visibilityGroup() { + return this.collectionTreeRow?.visibilityGroup ?? 'default'; + } + + get isSortable() { + return this.collectionTreeRow.isSortable(); + } + + _getColumns() { + if (!this.collectionTreeRow) { + this._columns = []; + return this._columns; + } + return super._getColumns(); + } + + async changeCollectionTreeRow(collectionTreeRow) { + if (this._locked) return; + if (!collectionTreeRow) { + this.tree = null; + this._treebox = null; + return this.clearItemsPaneMessage(); + } + Zotero.debug(`CollectionViewItemTree.changeCollectionTreeRow(): ${collectionTreeRow.id}`); + + if (collectionTreeRow.view) { + collectionTreeRow.view.itemTreeView = this; + } + await this.rowProvider.setCollectionTreeRow(collectionTreeRow); + return this.waitForLoad(); + } + + async sort(itemIDs, awaitRefresh = true) { + awaitRefresh && await this._refreshPromise; + return super.sort(itemIDs); + } + + render() { + const showMessage = !this.collectionTreeRow || this._itemsPaneMessage; + + // If no collectionTreeRow yet, render stub div instead of VirtualizedTable. + // This prevents VirtualizedTable from trying to use undefined ID. + if (!this.collectionTreeRow) { + return [ + this._renderItemsPaneMessage(showMessage), +
+ ]; + } + + // Otherwise, use parent render which creates full VirtualizedTable + return super.render(); + } + + async handleRowModelUpdate(rows, options = {}) { + const completed = await super.handleRowModelUpdate(rows, options); + if (completed) { + await this._updateIntroText(); + } + return completed; + } + + async notify(action, type, ids, extraData) { + // If a collection with subcollections is deleted/restored, ids will include subcollections + // though they are not showing in itemTree. + // Filter subcollections out to treat it as single selected row + if (type == 'collection' && action == "modify") { + let deletedParents = new Set(); + let collections = []; + for (let id of ids) { + let collection = Zotero.Collections.get(id); + deletedParents.add(collection.key); + collections.push(collection); + } + ids = collections.filter(c => !c.parentKey || !deletedParents.has(c.parentKey)).map(c => c.id); + } + + // Add C or S prefix to match .treeViewID + if (type == 'collection' || type == 'search') { + let prefix = type == 'collection' ? 'C' : 'S'; + ids = ids.map(id => prefix + id); + } + + return super.notify(action, type, ids, extraData); + } + + 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 collectionTreeRow for later + if (!this._rowMap && this.collectionTreeRow) { + this.collectionTreeRow.itemsToSelect = ids; + Zotero.debug("_rowMap not yet set; not selecting items"); + return 0; + } + // Filter out deleted items if not in trash + if (!this.collectionTreeRow.isTrash()) { + ids = ids.filter(id => !Zotero.Items.get(id).deleted); + } + return super.selectItems(ids, noRecurse, noScroll); + } + + /** + * @param index {Integer} + * @param selectAll {Boolean} Whether the selection is part of a select-all event + * @returns {Boolean} + */ + isSelectable(index, selectAll=false) { + // Every listed item is selectable individually. There are exceptions + // for select-all selections. + if (!selectAll) return true; + + // Every item is selectable in publications (even when not in a search) + // or when the tree is not in search mode + if (!this._searchMode || this.collectionTreeRow.isPublications()) return true; + + let row = this.getRow(index); + if (!row) return false; + + // Only deleted items are selectable in trash + if (this.collectionTreeRow.isTrash()) { + return row.ref.deleted; + } + else { + return this._searchItemIDs.has(row.id); + } + } + + _getRowData(index) { + var treeRow = this.getRow(index); + if (!treeRow) { + throw new Error(`Attempting to get row data for a non-existant tree row ${index}`); + } + let itemID = treeRow.id; + + // If value is available, retrieve immediately + if (this._rowCache[itemID]) { + return this._rowCache[itemID]; + } + + let row = super._getRowData(index); + // Don't change the format of date in feeds + if (this.collectionTreeRow.isFeedsOrFeed() && row.date) { + let val; + let customRowValue = this.props.getExtraField(treeRow.ref, 'date'); + if (customRowValue !== undefined) { + val = customRowValue; + } + else { + val = treeRow.getField('date'); + } + row.date = val; + } + this._rowCache[itemID] = row; + return row; + } + + async setFilter(type, data) { + if (this._locked) return; + this._cacheState(); + await this.rowProvider.setFilter(type, data); + await this.waitForLoad(); + }; + + // /////////////////////////////////////////////////////////////////////////// + // + // Drag and Drop + // + // /////////////////////////////////////////////////////////////////////////// + + /** + * Start a drag using HTML 5 Drag and Drop + */ + onDragStart(event, index) { + Zotero.DragDrop.currentDragSource = this.collectionTreeRow; + return super.onDragStart(event, index); + }; + + /** + * We use this to set the drag action, which is used by view.canDrop(), + * based on the view's canDropCheck() and modifier keys. + */ + 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); + } + } + }; + + /** + * Called by treeRow.onDragOver() before setting the dropEffect + */ + 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.isContainerOpen()) 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.rowProvider.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 < items.length; i++) { + let item = items[i]; + item.parentID = rowItem.id; + await item.save(); + } + }); + } + + // Dropped outside of a row + else { + // Remove from parent and make top-level + if (collectionTreeRow.isLibrary(true)) { + await Zotero.DB.executeTransaction(async function () { + for (let i = 0; i < items.length; i++) { + let item = items[i]; + if (!item.isRegularItem()) { + item.parentID = false; + await item.save(); + } + } + }); + } + // Add to collection + else { + await Zotero.DB.executeTransaction(async function () { + for (let i = 0; i < items.length; i++) { + let item = items[i]; + var source = item.isRegularItem() ? false : item.parentItemID; + // Top-level item + if (source) { + item.parentID = false; + item.addToCollection(collectionTreeRow.ref.id); + await item.save(); + } + else { + item.addToCollection(collectionTreeRow.ref.id); + await item.save(); + } + toMove.push(item.id); + } + }); + } + } + } + else if (dataType == 'application/x-moz-file') { + // Disallow drop into read-only libraries + if (!collectionTreeRow.editable) { + window.ZoteroPane.displayCannotEditLibraryMessage(); + return; + } + + // See note in onDragOver() above + if (Zotero.isMac) { + if (event.metaKey) { + if (event.altKey) { + dropEffect = 'link'; + } + else { + dropEffect = 'move'; + } + } + else { + dropEffect = 'copy'; + } + } + + var targetLibraryID = collectionTreeRow.ref.libraryID; + + var parentItemID = false; + var parentCollectionID = false; + + if (orient == 0) { + let treerow = this.getRow(row); + parentItemID = treerow.ref.id; + } + else if (collectionTreeRow.isCollection()) { + var parentCollectionID = collectionTreeRow.ref.id; + } + + let addedItems = []; + var notifierQueue = new Zotero.Notifier.Queue; + try { + // If there's a single file being added to a parent, automatic renaming is enabled, + // and there are no other non-HTML attachments, we'll rename the file as long as it's + // an allowed type. The dragged data could be a URL, so we don't yet know the file type. + // This should be kept in sync with ZoteroPane.addAttachmentFromDialog(). + let renameIfAllowedType = false; + let parentItem; + if (parentItemID + && data.length == 1 + && Zotero.Attachments.shouldAutoRenameFile(dropEffect == 'link', targetLibraryID)) { + parentItem = Zotero.Items.get(parentItemID); + if (!parentItem.numNonHTMLFileAttachments()) { + renameIfAllowedType = true; + } + } + + // If we have more than one file, we only want to call setAutoAttachmentTitle() + // at the end, once the attachments know whether they have siblings + let delaySetAutoAttachmentTitle = data.length > 1; + + for (var i = 0; i < data.length; i++) { + var file = data[i].path; + + // Rename file if it's an allowed type + let fileBaseName = false; + if (renameIfAllowedType) { + fileBaseName = await Zotero.Attachments.getRenamedFileBaseNameIfAllowedType( + parentItem, file + ); + } + + let item; + if (dropEffect == 'link') { + // Rename linked file, with unique suffix if necessary + try { + if (fileBaseName) { + let ext = Zotero.File.getExtension(file); + let newName = await Zotero.File.rename( + file, + fileBaseName + (ext ? '.' + ext : ''), + { + unique: true + } + ); + // Update path in case the name was changed to be unique + file = PathUtils.join(PathUtils.parent(file), newName); + } + } + catch (e) { + Zotero.logError(e); + } + + item = await Zotero.Attachments.linkFromFile({ + file, + title: delaySetAutoAttachmentTitle ? '' : undefined, + parentItemID, + collections: parentCollectionID ? [parentCollectionID] : undefined, + saveOptions: { + notifierQueue + } + }); + } + else { + if (file.endsWith(".lnk")) { + window.ZoteroPane.displayCannotAddShortcutMessage(file); + continue; + } + + item = await Zotero.Attachments.importFromFile({ + file, + title: delaySetAutoAttachmentTitle ? '' : undefined, + fileBaseName, + libraryID: targetLibraryID, + parentItemID, + collections: parentCollectionID ? [parentCollectionID] : undefined, + saveOptions: { + notifierQueue + } + }); + // If moving, delete original file + if (dropEffect == 'move') { + try { + await OS.File.remove(file); + } + catch (e) { + Zotero.logError("Error deleting original file " + file + " after drag"); + } + } + } + + if (item) { + addedItems.push(item); + } + } + if (delaySetAutoAttachmentTitle) { + for (let item of addedItems) { + item.setAutoAttachmentTitle(); + await item.saveTx({ notifierQueue }); + } + } + // Select children created after drag-drop onto a top-level item + if (parentItemID && addedItems.length) { + await this.selectItems(addedItems.map(item => item.id)); + } + } + finally { + await Zotero.Notifier.commit(notifierQueue); + } + + // Automatically retrieve metadata for PDFs and ebooks + if (!parentItemID) { + Zotero.RecognizeDocument.autoRecognizeItems(addedItems); + } + } + }; + + // /////////////////////////////////////////////////////////////////////////// + // + // Duplicates handling + // + // /////////////////////////////////////////////////////////////////////////// + + handleActivate(event, indices) { + let items = indices.map(index => this.getRow(index).ref); + // Ignore double-clicks in duplicates view on everything except attachments + if (event.button == 0 && this.collectionTreeRow?.isDuplicates()) { + if (items.length != 1 || !items[0].isAttachment()) { + return false; + } + } + return super.handleActivate(event, indices); + } + + _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]); + } + } + this.duplicateMouseSelection = false; + return super._handleSelectionChange(selection, shouldDebounce); + }; + + // /////////////////////////////////////////////////////////////////////////// + // + // Feed-specific sorting + // + // /////////////////////////////////////////////////////////////////////////// + + getSortDirection(sortFields) { + if (this.collectionTreeRow?.isFeedsOrFeed()) { + return Zotero.Prefs.get('feeds.sortAscending') ? 1 : -1; + } + if (this.collectionTreeRow.isRecentlyRead()) { + return -1; + } + return super.getSortDirection(sortFields); + } + + getSortField() { + if (this.collectionTreeRow?.isFeedsOrFeed()) { + return 'id'; + } + if (this.collectionTreeRow.isRecentlyRead()) { + return 'lastRead'; + } + return super.getSortField(); + } + + // /////////////////////////////////////////////////////////////////////////// + // + // Colored tag keyboard shortcuts + // + // /////////////////////////////////////////////////////////////////////////// + + handleKeyDown(event) { + const result = super.handleKeyDown(event); + // False means the operation was handled by the base class + if (!result) { + return result; + } + + // In search when performing selectAll, expand parents of matches + if (event.key == 'a' + && !event.altKey + && !event.shiftKey + && (Zotero.isMac ? (event.metaKey && !event.ctrlKey) : event.ctrlKey) + && !this.collectionTreeRow.isPublications()) { + this.rowProvider.expandMatchParents(); + return true; + } + + // Colored tag handling (ZoteroPane-specific) + if (!event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey && COLORED_TAGS_RE.test(event.code)) { + let libraryID = this.collectionTreeRow?.ref?.libraryID; + if (!libraryID) { + return true; + } + 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; + } + + let items = this.getSelectedItems(); + // Check for toggle + // If tag is assigned to any of the selected items, remove from all + // selected items. + // Otherwise, add to all selected items. + let tagRemove = items.some(item => item.hasTag(colorData.name)); + + // Async but no need to wait + (async () => { + for (let item of items) { + if (tagRemove) { + item.removeTag(colorData.name); + } + else { + item.addTag(colorData.name); + } + await item.saveTx(); + } + })(); + + // We handled this + return false; + } + + // Duplicates view arrow key handling + if (this.collectionTreeRow?.isDuplicates() && ["ArrowUp", "ArrowDown"].includes(event.key) + && !event.ctrlKey && !event.metaKey && !event.shiftKey + && this.selection.count > 1) { + let focused = this.selection.focused; + let nextItem = this.getRow(focused + (event.key == "ArrowUp" ? -1 : 1)); + if (nextItem) { + var setItemIDs = this.collectionTreeRow.ref.getSetItemsByItemID(nextItem.id); + // If next item is part of the set, we skip the whole set + if (this.selection.isSelected(this._rowMap[nextItem.id])) { + let newIndex; + if (event.key == "ArrowDown") { + newIndex = Math.max(...setItemIDs.map(id => this._rowMap[id])) + 1; + } + else { + newIndex = Math.min(...setItemIDs.map(id => this._rowMap[id])) - 1; + } + if (newIndex >= 0 && newIndex < this.rowProvider.rowCount) { + this.selection.select(newIndex); + this.ensureRowIsVisible(newIndex); + return false; + } + } + } + } + return true; + }; + + // /////////////////////////////////////////////////////////////////////////// + // + // Collection-aware delete + // + // /////////////////////////////////////////////////////////////////////////// + + async deleteSelection(force) { + 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); + } + } + this.rowProvider.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; + } + } + + // /////////////////////////////////////////////////////////////////////////// + // + // Intro text (welcome message, publications intro) + // + // /////////////////////////////////////////////////////////////////////////// + + async _updateIntroText() { + 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; + await this.setItemsPaneMessage(div); + return; + } + this._introText = null; + } + + if (this._introText || this._introText === null) { + await this.clearItemsPaneMessage(); + this._introText = false; + } + } +} + +module.exports = CollectionViewItemTree; + diff --git a/chrome/content/zotero/components/virtualized-table.jsx b/chrome/content/zotero/components/virtualized-table.jsx index 89a01191cc..3379318fe1 100644 --- a/chrome/content/zotero/components/virtualized-table.jsx +++ b/chrome/content/zotero/components/virtualized-table.jsx @@ -143,9 +143,10 @@ class TreeSelection { * @returns {boolean} False if nothing to select and select handlers won't be called */ select(index, shouldDebounce) { - if (!this._tree.props.isSelectable(index)) return; index = Math.max(0, index); + if (!this._tree.props.isSelectable(index)) return; if (this.selected.size == 1 && this.isSelected(index)) { + this._updateTree(shouldDebounce); return false; } @@ -161,7 +162,12 @@ class TreeSelection { this._tree.scrollToRow(index); this._updateTree(shouldDebounce); if (this._tree.invalidate) { - toInvalidate.forEach(this._tree.invalidateRow.bind(this._tree)); + const rowCount = this._tree.props.getRowCount(); + toInvalidate.forEach((idx) => { + // this._updateTree() may change row count + if (idx >= rowCount) return; + this._tree.invalidateRow(idx); + }); } return true; } @@ -272,8 +278,9 @@ class TreeSelection { } set selectEventsSuppressed(val) { + let valChanged = val !== this._selectEventsSuppressed; this._selectEventsSuppressed = val; - if (!val) { + if (!val && valChanged) { this._updateTree(); if (this._tree.invalidate) { this._tree.invalidate(); @@ -323,6 +330,8 @@ class VirtualizedTable extends React.Component { this._typingString = ""; this._jsWindowID = `virtualized-table-list-${Zotero.Utilities.randomString(5)}`; this._containerWidth = props.containerWidth || window.innerWidth; + this.className = props.className || ""; + this.firstColumnExtraWidth = props.firstColumnExtraWidth || 0; this._columns = new Columns(this); @@ -362,6 +371,8 @@ class VirtualizedTable extends React.Component { staticColumns: false, alternatingRowColors: Zotero.isMac ? ['-moz-OddTreeRow', '-moz-EvenTreeRow'] : null, + firstColumnExtraWidth: 0, + // Render with display: none hide: false, @@ -419,6 +430,8 @@ class VirtualizedTable extends React.Component { staticColumns: PropTypes.bool, // Used for initial column widths calculation containerWidth: PropTypes.number, + // If first column is injected with extra stuff, like an item icon + // and we need to reserve extra min-width for it, set this prop firstColumnExtraWidth: PropTypes.number, // Internal windowed-list ref @@ -640,7 +653,7 @@ class VirtualizedTable extends React.Component { if (this.props.isContainer(this.selection.focused) && !this.props.isContainerEmpty(this.selection.focused) && this.props.isContainerOpen(this.selection.focused)) { - this.props.toggleOpenState(this.selection.focused); + this.toggleOpenState(this.selection.focused); } else if (parentIndex != -1) { this.onSelection(parentIndex); @@ -651,7 +664,7 @@ class VirtualizedTable extends React.Component { if (this.props.isContainer(this.selection.focused) && !this.props.isContainerEmpty(this.selection.focused)) { if (!this.props.isContainerOpen(this.selection.focused)) { - this.props.toggleOpenState(this.selection.focused); + this.toggleOpenState(this.selection.focused); } else { this.onSelection(this.selection.focused + 1); @@ -864,10 +877,7 @@ class VirtualizedTable extends React.Component { event.stopPropagation(); const result = this._getResizeColumns(); if (!result) return; - const columns = this._getVisibleColumns(); const [aColumn, bColumn, resizingColumn] = result; - const isFirstColumn = columns[0].dataKey === aColumn.dataKey; - const firstColumnExtraWidth = isFirstColumn ? (this.props.firstColumnExtraWidth || 0) : 0; const a = document.querySelector(`#${this.props.id} .virtualized-table-header .cell.${window.CSS.escape(aColumn.dataKey)}`); const b = document.querySelector(`#${this.props.id} .virtualized-table-header .cell.${window.CSS.escape(bColumn.dataKey)}`); const resizing = document.querySelector(`#${this.props.id} .virtualized-table-header .cell.${window.CSS.escape(resizingColumn.dataKey)}`); @@ -881,9 +891,12 @@ class VirtualizedTable extends React.Component { const widthSum = aRect.width + bRect.width; const aColumnPadding = aColumn.iconLabel ? 0 : COLUMN_PADDING; const bColumnPadding = bColumn.iconLabel ? 0 : COLUMN_PADDING; - const aSpacingOffset = (aColumn.minWidth ? aColumn.minWidth : COLUMN_MIN_WIDTH) + aColumnPadding + firstColumnExtraWidth; - const bSpacingOffset = (bColumn.minWidth ? bColumn.minWidth : COLUMN_MIN_WIDTH) + bColumnPadding; - const aColumnWidth = Math.min(widthSum - bSpacingOffset, Math.max(aSpacingOffset, event.clientX - (RESIZER_WIDTH / 2) - offset)); + const aMinWidth = (aColumn.minWidth ? aColumn.minWidth : COLUMN_MIN_WIDTH) + aColumnPadding; + const bMinWidth = (bColumn.minWidth ? bColumn.minWidth : COLUMN_MIN_WIDTH) + bColumnPadding; + const aMaxWidth = widthSum - bMinWidth; + const aDragWidth = event.clientX - (RESIZER_WIDTH / 2) - offset; + // Constrain the drag position to the min and max widths + const aColumnWidth = Math.min(aMaxWidth, Math.max(aMinWidth, aDragWidth)); const bColumnWidth = widthSum - aColumnWidth; let onResizeData = {}; onResizeData[aColumn.dataKey] = aColumnWidth; @@ -1055,7 +1068,7 @@ class VirtualizedTable extends React.Component { this._setXulTooltip(); - this._topDiv.style.setProperty("--firstColumnExtraWidth", `${this.props.firstColumnExtraWidth || 0}px`); + this._topDiv.style.setProperty("--first-column-extra-width", `${this.firstColumnExtraWidth}px`); window.addEventListener("resize", () => { this._debouncedRerender(); }); @@ -1113,13 +1126,13 @@ class VirtualizedTable extends React.Component { return { getItemCount: this.props.getRowCount, itemHeight: this._rowHeight, - renderItem: this._renderItem, + renderItem: this._renderItem.bind(this), targetElement: document.getElementById(this._jsWindowID), customRowHeights: this.props.customRowHeights ?? [] }; } - - _renderItem = (index, oldElem = null) => { + + _renderItem(index, oldElem = null) { let node = this.props.renderItem(index, this.selection, oldElem, this._getColumns()); if (!node.dataset.eventHandlersAttached) { node.dataset.eventHandlersAttached = true; @@ -1184,7 +1197,7 @@ class VirtualizedTable extends React.Component { if (!column.iconLabel && column.sortDirection) { sortIndicator = ; } - const className = cx("cell", column.className, { 'first-column': index === 0, dragging: this.state.draggingColumn == index }, + const className = cx("cell", column.className, { dragging: this.state.draggingColumn == index }, { "cell-icon": !!column.iconLabel }); return ( this._topDiv = ref, tabIndex: 0, @@ -1408,6 +1423,11 @@ class VirtualizedTable extends React.Component { && row <= this._jsWindow.getLastVisibleRow(); } + toggleOpenState(index, ...args) { + let onToggleOpenState = this.props.toggleOpenState; + if (typeof onToggleOpenState == 'function') return onToggleOpenState(index, ...args); + } + async _resetColumns() { this.invalidate(); this._columns = new Columns(this); @@ -1424,6 +1444,143 @@ class VirtualizedTable extends React.Component { } } +/** + * VirtualizedTree wraps VirtualizedTable to provide common tree affordances: + * - Adds an indent spacer based on depth to the first visible cell + * - Adds a twisty for non-empty containers + * - Sets tree-specific ARIA attributes on rows and the container + * - Wires twisty mouse handlers to toggle container open state + * + * Consumers should provide isContainer/isContainerEmpty/isContainerOpen/onToggleOpenState + * and getParentIndex(index) to compute ancestry. + */ +class VirtualizedTree extends VirtualizedTable { + static propTypes = { ...VirtualizedTable.propTypes, + getParentIndex: PropTypes.func.isRequired, + isContainer: PropTypes.func.isRequired, + isContainerEmpty: PropTypes.func.isRequired, + isContainerOpen: PropTypes.func.isRequired, + onToggleOpenState: PropTypes.func.isRequired, + } + + _toggledOpenStateIndex = null; + + constructor(props) { + super(props); + this.className += " virtualized-tree"; + this.firstColumnExtraWidth += 16; // 16px for twisty + } + + toggleOpenState(index, ...args) { + this._toggledOpenStateIndex = index; + return this.props.onToggleOpenState(index, ...args); + } + + _renderItem(index, oldElem=null) { + let node = super._renderItem(index, oldElem); + if (!(node instanceof (node?.ownerDocument?.defaultView || window).Element)) { + return node; + } + node = this._addIndentAndTwisty(node, index); + this._setRowAria(node, index); + return node; + } + + _getDepth(index) { + let depth = 0; + try { + let parent = typeof this.props.getParentIndex == 'function' ? this.props.getParentIndex(index) : -1; + while (parent != -1 && typeof parent == 'number') { + depth++; + parent = this.props.getParentIndex(parent); + } + } + catch (e) {} + return depth; + } + + /** + * Adds an indent spacer and twisty to the first cell of the node + * + * We add it to the first cell instead of as a separate pseudo-cell or just elements before + * the first cell because otherwise it messes with column spacing. + * + * @param node {HTMLElement} The rendered item row + * @param index {number} The index of the node being rendered + * @returns {HTMLElement} + */ + _addIndentAndTwisty(node, index) { + let firstCell = node.querySelector('.cell'); + if (!firstCell) return node; + + let twisty; + if (this.props.isContainerEmpty(index)) { + twisty = firstCell.querySelector('.spacer-twisty'); + if (!twisty) { + twisty = node.ownerDocument.createElement('span'); + firstCell.prepend(twisty); + twisty.classList.add('spacer-twisty'); + } + firstCell.querySelector(`:scope > .twisty`)?.remove(); + } + else { + twisty = firstCell.querySelector('.twisty'); + if (!twisty) { + twisty = getCSSIcon('twisty'); + twisty.classList.add('twisty'); + twisty.style.pointerEvents = 'auto'; + twisty.addEventListener('mousedown', (event) => event.stopPropagation()); + twisty.addEventListener('mouseup', (event) => { + this.toggleOpenState(index); + event.stopPropagation(); + }, { passive: true }); + twisty.addEventListener('dblclick', (event) => event.stopImmediatePropagation(), { passive: true }); + firstCell.prepend(twisty); + } + firstCell.querySelector(`:scope > .spacer-twisty`)?.remove(); + + // Apply the twisty animation + if (this._toggledOpenStateIndex == index) { + twisty.classList.toggle('open', !this.props.isContainerOpen(index)); + requestAnimationFrame(() => { + twisty.classList.toggle('open', this.props.isContainerOpen(index)); + this._toggledOpenStateIndex = null; + }); + } + else { + twisty.classList.toggle('open', this.props.isContainerOpen(index)); + } + } + + let indentSpan = firstCell.querySelector('.cell-indent'); + if (!indentSpan) { + indentSpan = node.ownerDocument.createElement('span'); + indentSpan.className = 'cell-indent'; + firstCell.prepend(indentSpan); + } + // Use padding for indent similar to ItemTree + const CHILD_INDENT = 16; + indentSpan.style.paddingInlineStart = (CHILD_INDENT * this._getDepth(index)) + 'px'; + + return node; + } + + _setRowAria(node, index) { + const depth = this._getDepth(index); + node.setAttribute('role', 'treeitem'); + node.setAttribute('aria-level', depth + 1); + if (!this.props.isContainerEmpty(index)) { + node.setAttribute('aria-expanded', !!this.props.isContainerOpen(index)); + } + else { + node.removeAttribute('aria-expanded'); + } + } +} + +VirtualizedTree.propTypes = Object.assign({}, VirtualizedTable.propTypes); +VirtualizedTree.defaultProps = Object.assign({}, VirtualizedTable.defaultProps, { role: 'tree' }); + /** * Create a function that calls the given function `fn` only once per animation * frame. @@ -1514,7 +1671,6 @@ var Columns = class { // Storing back persist settings to account for legacy upgrades this._storePrefs(columnsSettings); - this._adjustColumnWidths(); // Set column width CSS rules this.onResize(columnWidths); // Whew, all this just to get a list of columns @@ -1581,24 +1737,6 @@ var Columns = class { this._virtualizedTable.props.storeColumnPrefs(prefs); } - _adjustColumnWidths = () => { - if (!this._virtualizedTable.props.firstColumnExtraWidth) { - return; - } - - const extraWidth = this._virtualizedTable.props.firstColumnExtraWidth; - this._columns.filter(c => !c.hidden).forEach((column, index) => { - const isFirstColumn = index === 0; - if (column.fixedWidth) { - column.width = isFirstColumn ? parseInt(column.originalWidth) + extraWidth : column.originalWidth; - } - if (column.staticWidth) { - column.minWidth = isFirstColumn ? (column.originalMinWidth ?? 20) + extraWidth : column.originalMinWidth; - column.width = isFirstColumn ? Math.max(parseInt(column.width) ?? 0, column.minWidth) : column.width; - } - }); - }; - /** * Programatically sets the injected CSS width rules for each column. * This is necessary for performance reasons @@ -1610,11 +1748,14 @@ var Columns = class { var prefs = this._getPrefs(); } + let visibleColumns = this.getAsArray().filter(column => !column.hidden); + for (let [dataKey, width] of Object.entries(columnWidths)) { if (typeof dataKey == "number") { dataKey = this._columns[dataKey].dataKey; } const column = this._columns.find(column => column.dataKey == dataKey); + if (column.hidden) continue; const styleIndex = this._columnStyleMap[window.CSS.escape(dataKey)]; const columnPadding = column.iconLabel ? 0 : COLUMN_PADDING; if (storePrefs && !column.fixedWidth) { @@ -1626,12 +1767,16 @@ var Columns = class { } if (column.fixedWidth && column.width || column.staticWidth) { this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('flex', `0 0`, `important`); - this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('max-width', `${width}px`, 'important'); - this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('min-width', `${width}px`, 'important'); + this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('max-width', `calc(var(--extra-width, 0px) + ${width}px`, 'important'); + this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('min-width', `calc(var(--extra-width, 0px) + ${width}px`, 'important'); } else { + // It's set in CSS, so we subtract it here to prevent sliding + if (column.dataKey === visibleColumns[0].dataKey) { + width -= this._virtualizedTable.firstColumnExtraWidth; + } width = (width - columnPadding); Zotero.debug(`Columns ${dataKey} width ${width}`); - this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('flex-basis', `${width}px`); + this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('flex-basis', `calc(var(--extra-width, 0px) + ${width}px`); } } if (storePrefs) { @@ -1649,7 +1794,6 @@ var Columns = class { return a.ordinal - b.ordinal; }); - this._adjustColumnWidths(); this.onResize(Object.fromEntries(this._columns.map(c => [c.dataKey, c.width]))); let prefs = this._getPrefs(); @@ -1689,7 +1833,6 @@ var Columns = class { this._columns.find(c => c.dataKey === 'title').hidden = false; } - this._adjustColumnWidths(); this.onResize(Object.fromEntries(this._columns.map(c => [c.dataKey, c.width]))); this._storePrefs(prefs); this._updateVirtualizedTable(); @@ -1699,15 +1842,35 @@ var Columns = class { const column = this._columns[index]; column.hidden = !column.hidden; + if (!column.hidden && !column.width) { + column.width = this._computeFlexWidth(column); + } + let prefs = this._getPrefs(); if (prefs[column.dataKey]) { prefs[column.dataKey].hidden = column.hidden; } - this._adjustColumnWidths(); this.onResize(Object.fromEntries(this._columns.map(c => [c.dataKey, c.width]))); this._storePrefs(prefs); this._updateVirtualizedTable(); } + + _computeFlexWidth(column) { + const containerWidth = this._virtualizedTable._containerWidth; + const visibleColumns = this._columns.filter(c => !c.hidden); + let fixedWidth = 0; + let totalFlex = 0; + for (let col of visibleColumns) { + if (col.fixedWidth || col.staticWidth || !col.flex) { + fixedWidth += parseFloat(col.width) || col.minWidth || 0; + } + else { + totalFlex++; + } + } + let availableWidth = containerWidth - fixedWidth; + return availableWidth / totalFlex * (column.flex || 1); + } toggleSort(sortIndex) { if (!this._virtualizedTable.props.onColumnSort) return; @@ -1727,8 +1890,9 @@ var Columns = class { } } }); - this._virtualizedTable.props.onColumnSort(sortIndex, sortedColumn.sortDirection); + let result = this._virtualizedTable.props.onColumnSort(sortIndex, sortedColumn.sortDirection); this._virtualizedTable.forceUpdate(); + return result; } getAsArray() { @@ -1738,8 +1902,8 @@ var Columns = class { function renderCell(index, data, column, dir = null) { column = column || { dataKey: "" }; - if (column.renderer) { - return column.renderer(index, data, column, dir); + if (column.renderCell) { + return column.renderCell(index, data, column, dir); } let span = document.createElement('span'); span.className = `cell ${column.className}`; @@ -1881,6 +2045,8 @@ function formatColumnName(column) { } module.exports = VirtualizedTable; +module.exports.VirtualizedTree = VirtualizedTree; + module.exports.TreeSelection = TreeSelection; module.exports.TreeSelectionStub = TreeSelectionStub; module.exports.renderCell = renderCell; diff --git a/chrome/content/zotero/components/windowed-list.js b/chrome/content/zotero/components/windowed-list.js index 4a755a123a..e99b971916 100644 --- a/chrome/content/zotero/components/windowed-list.js +++ b/chrome/content/zotero/components/windowed-list.js @@ -214,11 +214,18 @@ module.exports = class { index = Math.max(0, Math.min(index, itemCount - 1)); let startPosition = this._getItemPosition(index); let endPosition = this._getItemPosition(index + 1); + // If forceScrollToTop is set, always scroll to the start position even if the row is + // already visible. This is used when restoring scroll position, where we need an exact + // first-visible-row rather than just ensuring the row is within view. + if (forceScrollToTop) { + this.scrollTo(startPosition); + return; + } if (startPosition < scrollOffset) { this.scrollTo(startPosition); } else if (endPosition > scrollOffset + height) { - this.scrollTo(forceScrollToTop ? startPosition : endPosition - height - 1); + this.scrollTo(endPosition - height - 1); } } diff --git a/chrome/content/zotero/containers/tagSelectorContainer.jsx b/chrome/content/zotero/containers/tagSelectorContainer.jsx index 6416e24372..45afdc0736 100644 --- a/chrome/content/zotero/containers/tagSelectorContainer.jsx +++ b/chrome/content/zotero/containers/tagSelectorContainer.jsx @@ -114,6 +114,27 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent { return null; } + /** + * Safely fetch tags from the current collection tree row, returning [] on search error. + * CollectionTreeRow.getTags() calls getSearchResults() under the hood, which throws + * Zotero.CollectionTreeRow.SearchError if the underlying search query fails (e.g., a + * saved search with invalid conditions). The tag selector should degrade gracefully in + * that case — showing no tags — rather than throwing upwards and breaking the UI. + * Real bugs (TypeError, etc.) are re-thrown so they surface in tests and logs. + */ + async _safeGetTags(...args) { + try { + return await this.collectionTreeRow.getTags(...args); + } + catch (e) { + if (e instanceof Zotero.CollectionTreeRow.SearchError) { + Zotero.logError(e); + return []; + } + throw e; + } + } + // Update trigger #1 (triggered by ZoteroPane) async onItemViewChanged({ collectionTreeRow, libraryID }) { Zotero.debug('Updating tag selector from current view'); @@ -192,7 +213,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent { } // Check tags for each tag type to see if they're in view/scope for (let [type, tagIDs] of tagsByType) { - changedTagsInScope.push(...await this.collectionTreeRow.getTags([type], tagIDs)); + changedTagsInScope.push(...await this._safeGetTags([type], tagIDs)); if (this.displayAllTags) { changedTagsInView.push( ...await Zotero.Tags.getAllWithin({ libraryID: this.libraryID, tagIDs }) @@ -315,7 +336,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent { } async getTagsAndScope() { - var tags = await this.collectionTreeRow.getTags(); + var tags = await this._safeGetTags(); // The scope is all visible tags, not all tags in the library var scope = new Set(tags.map(t => t.tag)); if (this.displayAllTags) { diff --git a/chrome/content/zotero/elements/duplicatesMergePane.js b/chrome/content/zotero/elements/duplicatesMergePane.js index 01c62dfb1e..e0b7d6a14d 100644 --- a/chrome/content/zotero/elements/duplicatesMergePane.js +++ b/chrome/content/zotero/elements/duplicatesMergePane.js @@ -179,7 +179,6 @@ async merge() { let itembox = document.getElementById('zotero-duplicates-merge-info-box'); - Zotero.CollectionTreeCache.clear(); // Update master item with any field alternatives from the item box let json = this._masterItem.toJSON(); // Exclude certain properties that are empty in the cloned object, so we don't clobber them diff --git a/chrome/content/zotero/integration/citationDialog.js b/chrome/content/zotero/integration/citationDialog.js index 59aa715198..9f8145ee57 100644 --- a/chrome/content/zotero/integration/citationDialog.js +++ b/chrome/content/zotero/integration/citationDialog.js @@ -24,7 +24,7 @@ */ -const ItemTree = require('zotero/itemTree'); +const CollectionViewItemTree = require('zotero/collectionViewItemTree'); const { getCSSIcon } = require('components/icons'); const { COLUMNS } = require('zotero/itemTreeColumns'); var doc, io, ioReadyPromise, ioIsReady, accepted; @@ -660,10 +660,11 @@ class LibraryLayout extends Layout { label: columnLabel, htmlLabel: ' ', // space for column label to appear empty width: 26, + hidden: false, staticWidth: true, fixedWidth: true, showInColumnPicker: false, - renderer: (index, inCitation, column) => { + renderCell: (index, inCitation, column) => { let cell = Helpers.createNode("span", {}, `cell ${column.className} clickable`); if (inCitation === null) { // no icon should be shown when an item cannot be added @@ -688,7 +689,7 @@ class LibraryLayout extends Layout { return cell; } }); - this.itemsView = await ItemTree.init(itemsTree, { + this.itemsView = await CollectionViewItemTree.init(itemsTree, { id: "citationDialog", dragAndDrop: DIALOG_STATE.isCitingItems(), persistColumns: true, @@ -717,7 +718,7 @@ class LibraryLayout extends Layout { if (!isClick) { let lastItemID = items[items.length - 1].id; let rowIndex = this.itemsView.getRowIndexByID(lastItemID); - row = doc.querySelector(`#item-tree-citationDialog-row-${rowIndex}`) || row; + row = doc.getElementById(`${this.itemsView.id}-row-${rowIndex}`) || row; } let rowTopBeforeRefresh = row.getBoundingClientRect().top; IOManager.addItemsToCitation(items, { noInputRefocus: true }).then(() => { @@ -848,9 +849,14 @@ class LibraryLayout extends Layout { id: collectionTreeRow.id, getItems: async () => { let items = await collectionTreeRow.getItems(); - // when citing notes, only keep notes or note parents + // In add-note mode, note parent checks call item.getNotes(), which requires childItems if (DIALOG_STATE.isAddingNote()) { - items = items.filter(item => SearchHandler.isItemWithNotes(item)); + let regularItems = items.filter(item => SearchHandler.isItemWithNotes(item)); + if (regularItems.length) { + await Zotero.Items.loadDataTypes(regularItems, ['childItems']); + } + // when citing notes, only keep notes or note parents + items = items.filter(item => item.isNote() || item.getNotes().length); } // when adding annotations, only keep annotations, their attachments, and their top-level items if (DIALOG_STATE.isAddingAnnotations()) { @@ -861,9 +867,10 @@ class LibraryLayout extends Layout { isSearch: () => true, isSearchMode: () => true, setSearch: (searchText, mode) => collectionTreeRow.setSearch(searchText, mode), + clearCache: () => collectionTreeRow.clearCache(), ref: collectionTreeRow.ref }); - await this.itemsView.setFilter('search', SearchHandler.searchValue); + await this.itemsView.setFilter('citation-search', SearchHandler.searchValue); this.itemsView.clearItemsPaneMessage(); } @@ -883,7 +890,7 @@ class LibraryLayout extends Layout { // click on + icon will add the item to the citation _handleItemsViewIconClick(index) { - let rowNode = doc.querySelector(`#item-tree-citationDialog-row-${index}`); + let rowNode = doc.getElementById(`${this.itemsView.id}-row-${index}`); let rowTopBeforeRefresh = rowNode.getBoundingClientRect().top; this.itemsView.selection.clearSelection(); let row = this.itemsView.getRow(index); diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index 492711154d..f92d0879bb 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -28,6 +28,10 @@ const ReactDOM = require('react-dom'); const diff = require('diff'); const VirtualizedTable = require('components/virtualized-table'); const { getCSSIcon, IconAttachSmall } = require('components/icons'); +// TODO: Create a custom row provider for citationExplorer to use with base ItemTree. +// Currently uses changeCollectionTreeRow which only exists on CollectionViewItemTree, +// so this is broken until we either switch to CollectionViewItemTree or create a +// simple row provider that can display arbitrary items. const ItemTree = require('zotero/itemTree'); const { getColumnDefinitionsByDataKey } = require('zotero/itemTreeColumns'); const { makeRowRenderer } = VirtualizedTable; @@ -48,7 +52,7 @@ const citationColumns = [ width: 26, staticWidth: true, fixedWidth: true, - renderer: (index, data, column) => { + renderCell: (index, data, column) => { let icon = getCSSIcon('IconCross'); if (data) { icon = getCSSIcon('IconTick'); @@ -72,7 +76,7 @@ itemColumns.push({ width: 26, staticWidth: true, fixedWidth: true, - renderer: (index, data, column) => { + renderCell: (index, data, column) => { let icon = getCSSIcon('IconCross'); if (data) { icon = getCSSIcon('IconTick'); diff --git a/chrome/content/zotero/itemTree.jsx b/chrome/content/zotero/itemTree.jsx index 426fe4a56e..54fa0af766 100644 --- a/chrome/content/zotero/itemTree.jsx +++ b/chrome/content/zotero/itemTree.jsx @@ -29,53 +29,816 @@ const React = require('react'); const ReactDOM = require('react-dom'); const LibraryTree = require('./libraryTree'); const VirtualizedTable = require('components/virtualized-table'); -const { renderCell, formatColumnName } = VirtualizedTable; -const Icons = require('components/icons'); -const { getCSSIcon, getCSSItemTypeIcon } = Icons; +const { VirtualizedTree, formatColumnName } = VirtualizedTable; const { COLUMNS } = require("zotero/itemTreeColumns"); +const { ItemTreeRow } = require('zotero/itemTreeRow'); const { OS } = ChromeUtils.importESModule("chrome://zotero/content/osfile.mjs"); -const { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); const { ZOTERO_CONFIG } = ChromeUtils.importESModule('resource://zotero/config.mjs'); -const lazy = {}; -XPCOMUtils.defineLazyPreferenceGetter( - lazy, - "BIDI_BROWSER_UI", - "bidi.browser.ui", - false -); - /** * @typedef {import("./itemTreeColumns.jsx").ItemTreeColumnOptions} ItemTreeColumnOptions */ const CHILD_INDENT = 16; -const COLORED_TAGS_RE = new RegExp("^(?:Numpad|Digit)([0-" + Zotero.Tags.MAX_COLORED_TAGS + "]{1})$"); const COLUMN_PREFS_FILEPATH = OS.Path.join(Zotero.Profile.dir, "treePrefs.json"); -const ATTACHMENT_STATE_LOAD_DELAY = 150; //ms -const STUB_COLLECTION_TREE_ROW = { - view: {}, - ref: {}, - visibilityGroup: "", - isSearchMode: () => false, - getItems: async () => [], - isLibrary: () => false, - isCollection: () => false, - isSearch: () => false, - isPublications: () => false, - isDuplicates: () => false, - isFeed: () => false, - isFeeds: () => false, - isFeedsOrFeed: () => false, - isRecentlyRead: () => false, - isSortable: () => true, - isShare: () => false, - isTrash: () => false -}; + +/** + * Base row data provider for ItemTree. + * + * Methods follow a private/public pattern: + * - Private methods perform data operations without emitting + * update events. This allows them to be freely composed within other methods + * without triggering redundant UI updates. + * - Public methods call one or more private methods, then emit update events + * via runListeners(). + * + * Subclasses (e.g. CollectionViewItemTreeRowProvider) should follow the same + * pattern when adding new functionality. + */ +class ItemTreeRowProvider { + constructor(itemTree) { + this.itemTree = itemTree; + this._rows = []; + this._rowMap = {}; + this._searchMode = false; + this._searchItemIDs = new Set(); + this._searchParentIDs = new Set(); + this._includeTrashed = false; + this.onUpdate = this.createEventBinding('update'); + } + + /** + * Create an ItemTreeRow for a reference object. + * Subclasses can override to return custom row types. + * + * @param {Object} ref - The reference object (e.g. Zotero.Item, Zotero.Collection) + * @param {number} level - The nesting level + * @param {boolean} isOpen - Whether the row is open (if a container) + * @returns {ItemTreeRow} + */ + createRow(ref, level, isOpen) { + return ItemTreeRow.create(ref, level, isOpen); + } + + /** + * Whether a container row's children should be sorted with the current + * tree comparator when the container is opened. + * + * Default delegates to the row's sortChildren property. Subclasses can + * override for view-specific behavior. + * + * @param {ItemTreeRow} row + * @returns {boolean} + */ + shouldSortChildren(row) { + return !!row.sortChildren; + } + + get includeTrashed() { + return this._includeTrashed; + } + + get rows() { + return this._rows; + } + + get rowCount() { + return this._rows.length; + } + + get rowMap() { + return this._rowMap; + } + + get searchMode() { + return this._searchMode; + } + + get searchItemIDs() { + return this._searchItemIDs; + } + + get searchParentIDs() { + return this._searchParentIDs; + } + + getRowCount() { + return this._rows.length; + } + + /** + * Return a reference to the tree row at a given row + * + * @return {TreeRow} + */ + getRow(index) { + return this._rows[index]; + } + + /** + * Return the index of the row with a given ID (e.g., "C123" for collection 123) + * + * @param {String} - Row id + * @return {Integer|false} + */ + getRowIndexByID(id) { + if (!(id in this._rowMap)) { + Zotero.debug(`${this.itemTree.id}: Trying to access a row with invalid ID ${id}`); + return false; + } + return this._rowMap[id]; + } + + getLevel(index) { + return this._rows[index].level; + } + + isContainer(index) { + let row = this.getRow(index); + if (!row) return false; + return row.isContainer(); + } + + isContainerOpen(index) { + return this.getRow(index)?.isContainerOpen() ?? false; + } + + isContainerEmpty(index) { + if (this.itemTree.props.isContainerEmpty) { + return this.itemTree.props.isContainerEmpty(index); + } + if (this.itemTree.props.regularOnly) { + return true; + } + + let row = this.getRow(index); + if (!row) { + return true; + } + return row.isContainerEmpty({ + searchMode: this._searchMode, + searchItemIDs: this._searchItemIDs, + includeTrashed: this._includeTrashed, + }); + } + + _closeContainer(index, skipRowMapRefresh = false) { + if (this.isContainerOpen(index)) { + this._toggleOpenState(index, skipRowMapRefresh); + } + } + + _openContainer(index, skipRowMapRefresh = false) { + if (!this.isContainerOpen(index)) { + this._toggleOpenState(index, skipRowMapRefresh); + } + } + + _refreshContainer(index, skipRowMapRefresh = false) { + if (!this.isContainer(index)) return; + this._closeContainer(index, true); + this._openContainer(index, true); + if (!skipRowMapRefresh) { + this.refreshRowMap(); + } + } + + _toggleOpenState(index, skipRowMapRefresh = false) { + if (!this.isContainer(index)) { + return false; + } + + let count = 0; + + if (this.isContainerOpen(index)) { + // Close + let level = this.getLevel(index); + // Remove child rows + while ((index + 1 < this._rows.length) && (this.getLevel(index + 1) > level)) { + this._removeRow(index + 1, true); + count++; + } + this._rows[index].isOpen = false; + } + else if (!this.isContainerEmpty(index)) { + // Open + let row = this.getRow(index); + let level = this.getLevel(index); + let childRefs = row.getChildItems({ + searchMode: this._searchMode, + searchItemIDs: this._searchItemIDs, + includeTrashed: this._includeTrashed, + filterChildItems: this.itemTree.props.filterChildItems, + }); + + let childRows = childRefs.map(ref => this.createRow(ref, level + 1, false)); + if (this._sortFields && this.shouldSortChildren(row)) { + childRows.sort((a, b) => this._compareRows(a, b)); + } + + for (let i = 0; i < childRows.length; i++) { + count++; + this._addRow(childRows[i], index + i + 1, true); + } + + this._rows[index].isOpen = true; + } + if (!skipRowMapRefresh && count > 0) { + this.refreshRowMap(); + } + } + + toggleOpenState(index, skipRowMapRefresh = false) { + this.itemTree._cacheState(); + this._toggleOpenState(index, skipRowMapRefresh); + // Preserve viewport when toggling a container instead of jumping to the current selection. + this.runListeners('update', true, { + restoreSelection: true, + expandCollapsedParents: false, + restoreScroll: true, + }); + } + + /** + * Expand rows level-by-level, or across all levels when requested. + * + * @param {boolean} [acrossAllLevels=false] + */ + expandAllRows(acrossAllLevels = false) { + if (!this.rowCount) { + return; + } + + this.itemTree._cacheState(); + + let deepestExpandedLevel = 0; + for (let i = 0; i < this.rowCount; i++) { + if (this.isContainer(i) && this.isContainerOpen(i) && this.getLevel(i) > deepestExpandedLevel) { + deepestExpandedLevel = this.getLevel(i); + } + } + + let rowsToExpandAtThatLevel = false; + for (let i = 0; i < this.rowCount; i++) { + if (this.isContainer(i) + && !this.isContainerOpen(i) + && !this.isContainerEmpty(i) + && this.getLevel(i) == deepestExpandedLevel) { + rowsToExpandAtThatLevel = true; + break; + } + } + if (!rowsToExpandAtThatLevel) { + deepestExpandedLevel++; + } + + for (let i = 0; i < this.rowCount; i++) { + if (this.isContainer(i) + && !this.isContainerOpen(i) + && !this.isContainerEmpty(i) + && (acrossAllLevels || this.getLevel(i) <= deepestExpandedLevel)) { + this._toggleOpenState(i, true); + } + } + this.refreshRowMap(); + this.runListeners('update', true, { + restoreSelection: true, + expandCollapsedParents: false, + restoreScroll: true, + }); + } + + /** + * Collapse rows one level at a time, or across all levels when requested. + * + * @param {boolean} [acrossAllLevels=false] + */ + collapseAllRows(acrossAllLevels = false) { + if (!this.rowCount) { + return; + } + + this.itemTree._cacheState(); + + let maxLevelWithExpanded = -1; + for (let i = 0; i < this.rowCount; i++) { + if (this.isContainer(i) && this.isContainerOpen(i) && this.getLevel(i) > maxLevelWithExpanded) { + maxLevelWithExpanded = this.getLevel(i); + } + } + + for (let i = 0; i < this.rowCount; i++) { + if (this.isContainer(i) + && this.isContainerOpen(i) + && (acrossAllLevels || this.getLevel(i) === maxLevelWithExpanded)) { + this._toggleOpenState(i, true); + } + } + this.refreshRowMap(); + this.runListeners('update', true, { + restoreSelection: true, + expandCollapsedParents: false, + restoreScroll: true, + }); + } + + _expandRows(indices) { + // Reverse sort so that as we open indices don't change. + indices.sort((a, b) => b - a); + for (let index of indices) { + if (!this.isContainerOpen(index)) { + this._toggleOpenState(index, true); + } + } + this.refreshRowMap(); + } + + expandRows(indices) { + this.itemTree._cacheState(); + this._expandRows(indices); + this.runListeners('update', true, { restoreSelection: true, ensureRowsAreVisible: true }); + } + + collapseRows(indices) { + this.itemTree._cacheState(); + indices.sort((a, b) => b - a); + for (let index of indices) { + if (this.isContainerOpen(index)) { + this._toggleOpenState(index, true); + } + } + this.refreshRowMap(); + this.runListeners('update', true, { restoreSelection: true, expandCollapsedParents: false }); + } + + /** + * Expand all ancestors of the specified item id to make it visible. + * Issues a single update at the end. + * @param {number} id - The item ID to expand to + * @returns {boolean} True if the item is now in the tree + */ + _expandToItem(id) { + // Stop if the row already exists or if the item is not found + if (this._rowMap[id] !== undefined) return true; + + let item = Zotero.Items.get(id); + if (!item) return false; + + let toExpand = []; + // Collect all ancestors of the item that are not in the tree + while (item.parentItemID && this._rowMap[item.id] === undefined) { + item = Zotero.Items.get(item.parentItemID); + toExpand.push(item.id); + } + + // Check if the top-most ancestor is in the tree + if (this._rowMap[item.id] === undefined) return false; + + // Go through ancestors starting from the top-most one + // and expand them + while (toExpand.length > 0) { + let ancestorID = toExpand.pop(); + let ancestorRow = this._rowMap[ancestorID]; + + // Close and re-open the ancestor to refresh children and reveal the next row + this._refreshContainer(ancestorRow); + } + return true; + } + + /** + * Expand all ancestors of the specified item id to make it visible. + * Issues a single update at the end. + * @param {number} id - The item ID to expand to + */ + expandToItem(id) { + this.itemTree._cacheState(); + this._expandToItem(id); + this.runListeners('update', true, { restoreSelection: true, ensureRowsAreVisible: true }); + } + + refreshRowMap() { + var rowMap = {}; + for (let i = 0; i < this._rows.length; i++) { + let id = this._rows[i].id; + if (rowMap[id] !== undefined) { + Zotero.debug(`WARNING: refreshRowMap(): item row ${rowMap[id]} already found for item ${id} at ${i}`, 2); + Zotero.debug(new Error().stack, 2); + } + rowMap[id] = i; + } + this._rowMap = rowMap; + } + + _addRow(row, index, skipRowMapRefresh = false) { + this._rows.splice(index, 0, row); + if (!skipRowMapRefresh) { + this.refreshRowMap(); + } + } + + _removeRow(index, skipRowMapRefresh = false) { + this._rows.splice(index, 1); + if (!skipRowMapRefresh) { + this.refreshRowMap(); + } + } + + _removeRows(indices) { + indices.sort((a, b) => b - a); + for (let index of indices) { + this._rows.splice(index, 1); + } + this.refreshRowMap(); + } + + /** + * Save which containers are open and close them. + * @returns {number[]} - Array of item IDs that were open + */ + _saveOpenState() { + var openIDs = []; + var toClose = []; + for (var i = 0; i < this._rows.length; i++) { + if (this.isContainer(i) && this.isContainerOpen(i)) { + let row = this.getRow(i); + openIDs.push(row.id); + if (row.level == 0) { + toClose.push(row.id); + } + } + } + // Close top-level containers from bottom up + for (let i = toClose.length - 1; i >= 0; i--) { + let row = this._rowMap[toClose[i]]; + if (row !== undefined && this.isContainerOpen(row)) { + this._toggleOpenState(row, true); + } + } + this.refreshRowMap(); + return openIDs; + } + + /** + * Restore previously open containers. + * @param {Array} ids - Array of row IDs (treeViewIDs) to reopen + * @param {boolean} secondLevel - Internal flag for recursive call + */ + _restoreOpenState(ids, secondLevel = false) { + var rowsToOpen = []; + var nextLevelToOpen = []; + for (let id of ids) { + var row = this._rowMap[id]; + if (row === undefined) { + if (!secondLevel) { + nextLevelToOpen.push(id); + } + continue; + } + rowsToOpen.push(row); + } + rowsToOpen.sort((a, b) => a - b); + + // Reopen from bottom up + for (var i = rowsToOpen.length - 1; i >= 0; i--) { + if (!this.isContainerOpen(rowsToOpen[i])) { + this._toggleOpenState(rowsToOpen[i], true); + } + } + this.refreshRowMap(); + + if (nextLevelToOpen.length) { + this._restoreOpenState(nextLevelToOpen, true); + } + } + + /** + * Initialize sort state from current tree settings. + * Called at the start of _sort(). The state is stored on the instance + * so that _compareRows() can be used by both _sort() and _toggleOpenState(). + */ + _initSortState() { + this._sortFields = this.itemTree.getSortFields(); + this._sortDirection = this.itemTree.getSortDirection(this._sortFields); + this._sortCollation = Zotero.getLocaleCollation(); + this._sortCreatorAsString = Zotero.Prefs.get('sortCreatorAsString'); + this._sortCache = {}; + this._sortFields.forEach(x => this._sortCache[x] = {}); + this._sortCreatorCache = {}; + } + + /** + * Get a sortable field value for a row. + * Uses this._sortCache for memoization. + */ + _getSortField(field, row) { + let aID = row.id; + if (this._sortCache[field] && this._sortCache[field][aID] !== undefined) { + return this._sortCache[field][aID]; + } + + let val; + switch (field) { + case 'title': + val = Zotero.Items.getSortTitle(row.getDisplayTitle()); + break; + + case 'hasAttachment': + val = row.getBestAttachmentStateCached() || 0; + break; + + case 'numNotes': + val = row.numNotes() || 0; + break; + + case 'date': + val = row.ref.getField('date', true, true); + if (val) { + val = val.substr(0, 10); + if (val.indexOf('0000') == 0) { + val = ""; + } + } + break; + + case 'year': + val = row.ref.getField('date', true, true); + if (val) { + val = val.substr(0, 4); + if (val == '0000') { + val = ""; + } + } + break; + + case 'feed': + val = (row.ref.isFeedItem && Zotero.Feeds.get(row.ref.libraryID).name) || ""; + break; + + case 'lastRead': + val = row.ref.getItemLastRead() || ''; + break; + + case 'addedBy': + val = row.ref.createdByUserID + ? Zotero.Users.getName(row.ref.createdByUserID) : ''; + break; + + case 'lastModifiedBy': + let userID = row.ref.lastModifiedByUserID || row.ref.createdByUserID; + val = userID ? Zotero.Users.getName(userID) : ''; + break; + + default: { + let extraField = this.itemTree.props.getExtraField(row.ref, field); + if (extraField !== undefined) { + val = extraField; + } + else { + val = row.getField(field, false, true); + } + break; + } + } + + if (this._sortCache[field]) { + this._sortCache[field][aID] = val; + } + return val; + } + + /** + * Compare two rows on a single sort field. + */ + _compareField(a, b, sortField) { + // Set whether rows with empty values should sort at the beginning + var emptyFirst = { title: true, date: true, year: true, lastRead: true }; + + switch (sortField) { + case 'firstCreator': { + let prop = this._sortCreatorAsString ? 'firstCreator' : 'sortCreator'; + let cache = this._sortCreatorCache; + let fieldA = cache[a.id]; + let fieldB = cache[b.id]; + if (fieldA === undefined) { + let s = a.ref[prop]; + if (!s) s = a.ref.getField('firstCreator'); + cache[a.id] = fieldA = Zotero.Items.getSortTitle(s || ''); + } + if (fieldB === undefined) { + let s = b.ref[prop]; + if (!s) s = b.ref.getField('firstCreator'); + cache[b.id] = fieldB = Zotero.Items.getSortTitle(s || ''); + } + if (fieldA === '' && fieldB === '') return 0; + if (fieldA === '' && fieldB !== '') return 1; + if (fieldA !== '' && fieldB === '') return -1; + return this._sortCollation.compareString(1, fieldA, fieldB); + } + + case 'itemType': { + let typeA = a.getTypeLabel(); + let typeB = b.getTypeLabel(); + return (typeA > typeB) ? 1 : (typeA < typeB) ? -1 : 0; + } + + default: { + let fieldA = this._getSortField(sortField, a); + let fieldB = this._getSortField(sortField, b); + + if (!emptyFirst[sortField]) { + if (fieldA === '' && fieldB !== '') return 1; + if (fieldA !== '' && fieldB === '') return -1; + } + + if (sortField == 'hasAttachment') { + 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 this._sortCollation.compareString(1, String(fieldA), String(fieldB)); + } + } + } + + /** + * Compare two rows using the current sort state. + * + * Uses sort state initialized by _initSortState() (called from _sort()). + * Also used by _toggleOpenState() to sort child rows when containers are + * opened, ensuring the same ordering is applied at every tree level. + * + * @param {ItemTreeRow} a + * @param {ItemTreeRow} b + * @returns {number} + */ + _compareRows(a, b) { + let cmp = this.itemTree.props.compareItems(a, b, this._sortDirection); + if (cmp !== 0) return cmp; + for (let i = 0; i < this._sortFields.length; i++) { + cmp = this._compareField(a, b, this._sortFields[i]); + if (cmp !== 0) { + return cmp * this._sortDirection; + } + } + return 0; + } + + /** + * Core sorting logic without view updates. + * Saves and restores open state internally. + * + * Initializes sort state so that _compareRows() is available to both + * the top-level sort and _toggleOpenState() when containers are reopened. + * + * @param {number[]|null} itemIDs - Specific items to sort, or null for full sort + */ + _sort(itemIDs) { + // Initialize sort state + this._initSortState(); + + // 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]; + if (row === undefined) continue; + 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((a, b) => b - a); + + for (let row of parentRows) { + this._refreshContainer(row, 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`); + } + } + + // Save open state and close containers before sorting + var openIDs = this._saveOpenState(); + + Zotero.debug(`Sorting items list by ${this._sortFields.join(", ")} ` + + `${this._sortDirection == 1 ? "ascending" : "descending"} ` + + (itemIDs && itemIDs.length + ? `for ${itemIDs.length} ` + Zotero.Utilities.pluralize(itemIDs.length, ['item', 'items']) + : "")); + + // Sort specific items or all + try { + if (itemIDs) { + let idsToSort = new Set(itemIDs); + this._rows.sort((a, b) => { + if (!idsToSort.has(a.ref.id) && !idsToSort.has(b.ref.id)) return 0; + return this._compareRows(a, b); + }); + } + else { + this._rows.sort((a, b) => this._compareRows(a, b)); + } + } + catch (e) { + Zotero.logError("Error sorting fields: " + e.message); + Zotero.debug(e, 1); + Zotero.Prefs.clear('secondarySort.' + this._sortFields[0]); + Zotero.Prefs.clear('fallbackSort'); + } + + // Restore open state — _toggleOpenState() sorts children + // using _compareRows() with the same sort state + this.refreshRowMap(); + this._restoreOpenState(openIDs); + } + + /** + * Sort rows and trigger view update. + * @param {number[]|null} itemIDs - Specific items to sort, or null for full sort + */ + sort(itemIDs) { + this.itemTree._cacheState(); + this._sort(itemIDs); + this.runListeners('update', true, { restoreSelection: true }); + } + + /** + * Called by ItemTree.notify() for changes that require data/row mutations + * (e.g. adding, removing, or restructuring rows). Visual-only updates + * (redraws, tag color changes, column resets) are handled by ItemTree.notify() + * directly. + * + * Base implementation handles cache clearing for refresh/modify actions, + * and on modify also re-sorts changed rows and invalidates the whole view. + * Subclasses should override to handle adds, + * removes, and collection-specific logic if required. + */ + async notify(action, type, ids, extraData) { + if (action == 'refresh') { + // Clear row display cache and invalidate rows for refreshed items + let rowsToInvalidate = []; + let idsToInvalidate = []; + for (let id of ids) { + let row = this._rowMap[id]; + if (row === undefined) continue; + idsToInvalidate.push(id); + rowsToInvalidate.push(row); + } + this.itemTree.invalidateRowCache(idsToInvalidate); + await this.runListeners('update', rowsToInvalidate); + return; + } + + if (['item', 'collection', 'search'].includes(type) && action == 'modify') { + // Clear row display cache, re-sort modified rows, and redraw the whole tree. + // A modified row can move, which shifts many surrounding rows. + this.itemTree.invalidateRowCache(ids); + await this.itemTree._ensureSortContextReady(); + this._sort(ids); + await this.runListeners('update', true, { + restoreSelection: true, + restoreScroll: true + }); + return; + } + + // For remove/delete/trash: log if items are currently displayed + if (action == 'remove' || action == 'delete' || action == 'trash') { + let displayedIds = ids.filter(id => this._rowMap[id] !== undefined); + if (displayedIds.length) { + Zotero.debug(`ItemTreeRowProvider.notify: ${action} on displayed items: ${displayedIds.join(', ')}. Subclass should handle removal.`); + } + return; + } + } +} var ItemTree = class ItemTree extends LibraryTree { static async init(domEl, opts={}) { - Zotero.debug(`Initializing React ItemTree ${opts.id}`); + Zotero.debug(`Initializing React ${this.name} ${opts.id}`); var ref; opts.domEl = domEl; let itemTreeMenuBar = null; @@ -85,7 +848,7 @@ var ItemTree = class ItemTree extends LibraryTree { document.documentElement.prepend(itemTreeMenuBar); } await new Promise((resolve) => { - ReactDOM.createRoot(domEl).render( { + ReactDOM.createRoot(domEl).render( { ref = c; resolve(); } } {...opts} />); @@ -94,13 +857,12 @@ var ItemTree = class ItemTree extends LibraryTree { if (itemTreeMenuBar) { itemTreeMenuBar.init(ref); } - Zotero.debug(`React ItemTree ${opts.id} initialized`); + Zotero.debug(`React ${this.name} ${opts.id} initialized`); return ref; } static defaultProps = { dragAndDrop: false, - persistColumns: false, columnPicker: false, regularOnly: false, multiSelect: true, @@ -110,14 +872,14 @@ var ItemTree = class ItemTree extends LibraryTree { onActivate: noop, emptyMessage: '', getExtraField: noop, - filterChildItems: null + filterChildItems: null, + compareItems: () => 0, }; static propTypes = { id: PropTypes.string.isRequired, dragAndDrop: PropTypes.bool, - persistColumns: PropTypes.bool, columnPicker: PropTypes.bool, regularOnly: PropTypes.bool, multiSelect: PropTypes.bool, @@ -128,13 +890,18 @@ var ItemTree = class ItemTree extends LibraryTree { onActivate: PropTypes.func, emptyMessage: PropTypes.string, getExtraField: PropTypes.func, + // TODO: This is a bad pattern after item tree refactor and needs to be fixed (should not be used as an example) + filterChildItems: PropTypes.func, + // A master sorting function. If it returns 0 then default sorting priorities take over + compareItems: PropTypes.func, }; constructor(props) { super(props); - + this.type = 'item'; this.name = 'ItemTree'; + this._id = "item-tree-" + props.id; this._skipKeypress = false; this._initialized = false; @@ -142,14 +909,22 @@ var ItemTree = class ItemTree extends LibraryTree { this._needsSort = false; this._introText = null; - this._rowCache = {}; this._highlightedRows = new Set(); + this._cachedSelection = []; + this._cachedScrollPosition = null; this._modificationLock = Zotero.Promise.resolve(); - this._refreshPromise = Zotero.Promise.resolve(); - this._dropRow = null; + this._rowCache = {}; + this.rowProvider = new ItemTreeRowProvider(this); + this._renderCtx = { + renderCell: (...args) => this._renderCell(...args), + firstColumn: null, + includeTrashed: false, + invalidateRow: (index) => this.tree?.invalidateRow(index), + }; + if (props.shouldListenForNotifications) { this._unregisterID = Zotero.Notifier.registerObserver( this, @@ -161,24 +936,136 @@ var ItemTree = class ItemTree extends LibraryTree { this._prefsObserverIDs = [ Zotero.Prefs.registerObserver('recursiveCollections', this.refreshAndMaintainSelection.bind(this)), Zotero.Prefs.registerObserver('showAttachmentFilenames', () => { - this._rowCache = {}; + this.invalidateRowCache(true); this.tree.invalidate(); }), Zotero.Prefs.registerObserver('hideContextAnnotationRows', async () => { await this.refresh(); this.tree.invalidate(); - }) + }), ]; this._itemsPaneMessage = null; this._columnsId = null; + this._sortContextReadyPromise = Zotero.Promise.resolve(); - if (this.collectionTreeRow) { - this.collectionTreeRow.view.itemTreeView = this; - } - + // Initial deferred to be resolved on componentDidMount() this._itemTreeLoadingDeferred = Zotero.Promise.defer(); + this._loadingDeferredResolved = false; + + this._setRowProviderUpdateHandler(); + } + + get id() { + return this._id; + } + + /** + * Set a new ID for the item tree. + * @param newId + * @returns {Promise} True if the ID was changed + */ + async setId(newId) { + if (this._id === newId) { + await this._ensureSortContextReady(); + return false; + } + + // Save current columns (only if we have an existing id) + if (this._id != null && this.props.columnPicker) { + await this._writeColumnPrefsToFile(true); + } + + this._id = newId; + + if (!this.props.columnPicker) { + this._sortContextReadyPromise = Zotero.Promise.resolve(); + return; + } + + this._sortContextReadyPromise = (async () => { + await this._loadColumnPrefsFromFile(); + // Force columns/sort metadata to be rebuilt from newly loaded prefs + this._columnsId = null; + this._sortedColumn = null; + // Initialize columns once so _sortedColumn is ready before first sort + this._getColumns(); + })(); + await this._sortContextReadyPromise; + return true; + } + + async _ensureSortContextReady() { + await this._sortContextReadyPromise; + } + + get visibilityGroup() { + return 'default'; + } + + get isSortable() { + return true; + } + + get hasDependOnChildrenColumn() { + return this._hasDependOnChildrenColumn; + } + + /** + * Invalidate cached row display data. + * @param {number[]|boolean} ids - Array of item IDs to invalidate, + * or `true` to clear the entire cache. + */ + invalidateRowCache(ids) { + if (ids === true) { + this._rowCache = {}; + } + else { + for (let id of ids) { + delete this._rowCache[id]; + } + } + } + + // Backward compatibility proxies + get _rows() { return this.rowProvider.rows; } + get _rowMap() { return this.rowProvider.rowMap; } + get _searchMode() { return this.rowProvider.searchMode; } + get _searchItemIDs() { return this.rowProvider.searchItemIDs; } + + // Row access proxies + getRow(index) { return this.rowProvider.getRow(index); } + getRowCount() { return this.rowProvider.getRowCount(); } + getRowIndexByID(id) { return this.rowProvider.getRowIndexByID(id); } + getLevel(index) { return this.rowProvider.getLevel(index); } + isContainer(index) { return this.rowProvider.isContainer(index); } + isContainerOpen(index) { return this.rowProvider.isContainerOpen(index); } + isContainerEmpty(index) { return this.rowProvider.isContainerEmpty(index); } + expandAllRows(acrossAllLevels = false) { + return this.rowProvider.expandAllRows(acrossAllLevels); + } + collapseAllRows(acrossAllLevels = false) { + return this.rowProvider.collapseAllRows(acrossAllLevels); + } + + _setRowProviderUpdateHandler() { + this.rowProvider.onUpdate.addListener(async (...args) => { + // Create a new deferred if the view is currently settled. + // This ensures waitForLoad() has something to wait on. + // Leave any existing unresolved deferred in place + if (this._loadingDeferredResolved) { + this._loadingDeferredResolved = false; + this._itemTreeLoadingDeferred = Zotero.Promise.defer(); + } + const result = await this.handleRowModelUpdate(...args); + + if (result) { + this._loadingDeferredResolved = true; + this._itemTreeLoadingDeferred.resolve(); + } + return result; + }); } unregister() { @@ -192,6 +1079,7 @@ var ItemTree = class ItemTree extends LibraryTree { componentDidMount() { this._initialized = true; + this._loadingDeferredResolved = true; this._itemTreeLoadingDeferred.resolve(); // Create an element where we can create drag images to be displayed next to the cursor while dragging // since for multiple item drags we need to display all the elements @@ -260,257 +1148,128 @@ var ItemTree = class ItemTree extends LibraryTree { } async clearItemsPaneMessage() { + Zotero.debug('clearItemsPaneMessage called, current message: ' + !!this._itemsPaneMessage); const shouldRerender = this._itemsPaneMessage; this._itemsPaneMessage = null; return shouldRerender && new Promise(resolve => this.forceUpdate(resolve)); } - - /** - * @param {Boolean} [options.forceSortAll] Sort all items instead of only added items - * @return {Promise} - */ - refresh = Zotero.serial(async function (options = {}) { - Zotero.debug('Refreshing items list for ' + this.id); - - var resolve, reject; - this._refreshPromise = new Zotero.Promise(function () { - resolve = arguments[0]; - reject = arguments[1]; - }); - - try { - Zotero.CollectionTreeCache.clear(); - // Get the full set of items we want to show - let newSearchItems = await this.collectionTreeRow.getItems(); - if (this.collectionTreeRow.isTrash()) { - // When in trash, also fetch trashed collections and searched - // So that they are displayed among deleted items - newSearchItems = newSearchItems - .concat(await this.collectionTreeRow.getTrashedCollections()) - .concat(await Zotero.Searches.getDeleted(this.collectionTreeRow.ref.libraryID)); - } - // Remove notes and attachments if necessary - if (this.props.regularOnly) { - newSearchItems = newSearchItems.filter((item) => { - return item instanceof Zotero.Collection - || item instanceof Zotero.Search - || item.isRegularItem(); - }); - } - let newSearchItemIDs = new Set(newSearchItems.map(item => item.treeViewID)); - // Find the items that aren't yet in the tree - let itemsToAdd = newSearchItems.filter(item => this._rowMap[item.treeViewID] === undefined); - // Find the parents of search matches - let newSearchParentIDs = new Set( - this.props.regularOnly - ? [] - : newSearchItems.filter(item => !!item.parentItemID).map(item => item.parentItemID) - ); - this._searchParentIDs = newSearchParentIDs; - - var newCellTextCache = {}; - var newSearchMode = this.collectionTreeRow.isSearchMode(); - var newRows = []; - var allItemIDs = new Set(); - var addedItemIDs = new Set(); - - // Copy old rows to new array, omitting top-level items not in the new set and their children - // - // This doesn't add new child items to open parents or remove child items that no longer exist, - // which is done by toggling all open containers below. - var skipChildren; - for (let i = 0; i < this._rows.length; i++) { - let row = this._rows[i]; - // Top-level items - if (row.level == 0) { - // A top-level attachment moved into a parent. Don't copy, it will be added - // via this loop for the parent item. - if (row.ref instanceof Zotero.Item && row.ref.parentID) { - continue; - } - let attachments = row.ref.isRegularItem() ? row.ref.getAttachments() : []; - let isSearchParent = newSearchParentIDs.has(row.ref.treeViewID) || attachments.some(id => newSearchParentIDs.has(id)); - // If not showing children or no children match the search, close - if (this.props.regularOnly || !isSearchParent) { - row.isOpen = false; - skipChildren = true; - } - else { - skipChildren = false; - } - // Skip items that don't match the search and don't have children that do - if (!newSearchItemIDs.has(row.ref.treeViewID) && !isSearchParent) { - continue; - } - } - else if (row.level == 1 && !row.ref.parentID) { - // A child attachment moved into top-level. It needs to be added anew in a different - // location. - itemsToAdd.push(row.ref); - continue; - } - // Child items - else if (skipChildren) { - continue; - } - if (!allItemIDs.has(row.ref.id)) { - newRows.push(row); - allItemIDs.add(row.ref.treeViewID); - } - } - - // Add new items - for (let i = 0; i < itemsToAdd.length; i++) { - let item = itemsToAdd[i]; - - // If child item matches search and parent hasn't yet been added, add parent - let parentItemID = item.parentItemID; - if (parentItemID) { - if (allItemIDs.has(parentItemID)) { - continue; - } - item = Zotero.Items.get(parentItemID); - // Go up one more level to check for parents of annotation rows - let parentsParent = item.parentItemID; - if (parentsParent) { - if (allItemIDs.has(parentsParent)) { - continue; - } - item = Zotero.Items.get(parentsParent); - } - } - // Parent item may have already been added from child - else if (allItemIDs.has(item.treeViewID)) { - continue; - } - - // Add new top-level items - let row = new ItemTreeRow(item, 0, false); - if (!allItemIDs.has(item.treeViewID)) { - newRows.push(row); - allItemIDs.add(item.treeViewID); - addedItemIDs.add(item.treeViewID); - } - } - - this._rows = newRows; - this._refreshRowMap(); - // Sort only the new items - // - // This still results in a lot of extra work (e.g., when clearing a quick search, we have to - // re-sort all items that didn't match the search), so as a further optimization we could keep - // a sorted list of items for a given column configuration and restore items from that. - await this.sort(options.forceSortAll ? [...allItemIDs] : [...addedItemIDs]); - - // Update search results before collapse/expand of containers so that - // if hideContextAnnotationRows pref is true, child rows appear/disappear properly - this._searchItemIDs = newSearchItemIDs; // items matching the search - this._searchMode = newSearchMode; - // Toggle all open containers closed and open to refresh child items - // - // This could be avoided by making sure that items in notify() that aren't present are always - // added. - var t = new Date(); - for (let i = 0; i < this._rows.length; i++) { - if (this.isContainer(i) && this.isContainerOpen(i)) { - this.toggleOpenState(i, true); - this.toggleOpenState(i, true); - } - } - Zotero.debug(`Refreshed open parents in ${new Date() - t} ms`); - - this._refreshRowMap(); - - this._rowCache = {}; - - if (!this.collectionTreeRow.isPublications()) { - this.expandMatchParents(newSearchParentIDs); - } - - // Clear My Publications intro text on a refresh with items - if (this.collectionTreeRow.isPublications() && this.rowCount) { - this.clearItemsPaneMessage(); - } - - await this.runListeners('refresh'); - - await Zotero.Promise.delay(); - resolve(); - } - catch (e) { - await Zotero.Promise.delay(); - reject(e); - throw e; - } + + refresh = Zotero.serial(async function (options) { + return this.rowProvider.refresh(options); }) - /* - * Called by Zotero.Notifier on any changes to items in the data layer + _cacheState() { + this._cachedSelection = this.getSelectedObjects(); + this._cachedScrollPosition = this._saveScrollPosition(); + } + + /** + * NOTE: This method must not trigger further update events (e.g. by calling + * sort() or refresh()) to avoid recursive update loops and UI flashing. + * + * @param {Object[]|boolean} rows - The rows that need redrawing/invalidating. If true, invalidate the whole tree. + * @param {Object} options + * @param {Object[]|Object} options.selection - The selection to restore. + * @param {boolean} options.selectInActiveWindow - Whether to select the items in the active window. + * @param {boolean} options.restoreSelection - Whether to restore the cached selection. + * @param {boolean} options.ensureRowsAreVisible - Whether to ensure selected rows are visible. + * @param {boolean} options.restoreScroll - Whether to restore the cached scroll position. + * @param {boolean} options.loading - Whether to show loading state (hides tree, shows message). + * @param {string} options.message - Optional message to display (for loading, errors, intro text). + */ + async handleRowModelUpdate(rows, options = { + selection: null, + selectInActiveWindow: false, + restoreSelection: false, + expandCollapsedParents: true, + ensureRowsAreVisible: true, + restoreScroll: false, + loading: false, + message: null, + }) { + // Handle loading/message state + if (options.loading) { + options.message ||= Zotero.getString('pane.items.loading'); + this.selection.clearSelection(); + this.selection.focused = 0; + } + if (options.message) { + await this.setItemsPaneMessage(options.message); + return false; // Not complete and deferred unresolved — completion call will come later + } + + if (this._itemsPaneMessage) { + await this.clearItemsPaneMessage(); + // Reset scrollbar to top (at end of loading/showing message) + this._treebox && this._treebox.scrollTo(0); + } + + if (rows === true) { + if (this.tree) { + this.tree.invalidate(); + } + } + else if (Array.isArray(rows)) { + rows.forEach(row => this.tree.invalidateRow(row)); + } + + const itemsViewInActiveWindow = Zotero.getActiveZoteroPane()?.itemsView == this; + const prioritizeRestore = !(options.selectInActiveWindow && itemsViewInActiveWindow); + const ensureVisible = options.restoreScroll ? false : options.ensureRowsAreVisible; + + if (prioritizeRestore && options.restoreSelection) { + this._restoreSelection(null, options.expandCollapsedParents, ensureVisible); + } + else if (options.selection) { + if (Array.isArray(options.selection)) { + this.selectItems(options.selection, options.expandCollapsedParents, !ensureVisible); + } + else { + this.selectItem(options.selection, options.expandCollapsedParents, !ensureVisible); + } + } + + if (options.restoreScroll) { + this._restoreScrollPosition(); + } + + // Allow selection events to propagate and redraw the needed rows + this.selection.selectEventsSuppressed = false; + + return true; + } + + /** + * Called by Zotero.Notifier on any changes to items in the data layer. + * + * Handles visual-only updates (redraws, tag colors, column resets) directly. + * Delegates data/row mutations to rowProvider.notify() for changes that + * require adding, removing, or restructuring rows. */ async notify(action, type, ids, extraData) { - Zotero.debug("Yielding for refresh promise"); // TEMP - await this._refreshPromise; - - if (!this._treebox) { - Zotero.debug("Treebox didn't exist in itemTree.notify()"); - return; - } - - if (!this._rowMap) { - Zotero.debug("Item row map didn't exist in itemTree.notify()"); - return; - } - // Reset columns on custom column change - if(type === "itemtree" && action === "refresh") { + if (type === "itemtree" && action === "refresh") { await this._resetColumns(); + await this.refreshAndMaintainSelection(); return; } - // If a collection with subcollections is deleted/restored, ids will include subcollections - // though they are not showing in itemTree. - // Filter subcollections out to treat it as single selected row - if (type == 'collection' && action == "modify") { - let deletedParents = new Set(); - let collections = []; - for (let id of ids) { - let collection = Zotero.Collections.get(id); - deletedParents.add(collection.key); - collections.push(collection); - } - ids = collections.filter(c => !c.parentKey || !deletedParents.has(c.parentKey)).map(c => c.id); - } - - // Add C or S prefix to match .treeViewID - if (type == 'collection' || type == 'search') { - let prefix = type == 'collection' ? 'C' : 'S'; - ids = ids.map(id => prefix + id); - } - // Clear item type icon and tag colors when a tag is added to or removed from an item if (type == 'item-tag') { // TODO: Only update if colored tag changed? - ids.map(val => val.split("-")[0]).forEach(function (val) { - this.tree.invalidateRow(this._rowMap[val]); - }.bind(this)); + let rowsToInvalidate = ids.map(val => val.split("-")[0]) + .map(val => this._rowMap[val]) + .filter(row => row !== undefined); + rowsToInvalidate.forEach(row => this.tree.invalidateRow(row)); return; } - const collectionTreeRow = this.collectionTreeRow; - - if (collectionTreeRow.isFeedsOrFeed() && action == 'modify') { - for (const id of ids) { - this.tree.invalidateRow(this._rowMap[id]); - } - } - // Redraw the tree (for tag color and progress changes) if (action == 'redraw') { // Redraw specific rows if (type == 'item' && ids.length) { - for (let id of ids) { - this.tree.invalidateRow(this._rowMap[id]); - } + let rowsToInvalidate = ids.map(id => this._rowMap[id]).filter(row => row !== undefined); + rowsToInvalidate.forEach(row => this.tree.invalidateRow(row)); } // Redraw the whole tree else { @@ -518,501 +1277,14 @@ var ItemTree = class ItemTree extends LibraryTree { } return; } - - var madeChanges = false; - var refreshed = false; - var sort = false; - - var savedSelection = this.getSelectedObjects(); - var previousFirstSelectedRow = this._rowMap[ - // 'collection-item' ids are in the form - - // 'item' events are just integers - type == 'collection-item' ? ids[0].split('-')[1] : ids[0] - ]; - - // If there's not at least one new item to be selected, get a scroll position to restore later - var scrollPosition = false; - if (action != 'add' || ids.every(id => extraData[id] && extraData[id].skipSelect)) { - scrollPosition = this._saveScrollPosition(); - } - - if (action == 'refresh') { - if (type == 'share-items') { - if (collectionTreeRow.isShare()) { - await this.refresh(); - refreshed = true; - } - } - else if (type == 'bucket') { - if (collectionTreeRow.isBucket()) { - await this.refresh(); - refreshed = true; - } - } - // If refreshing a single item, clear caches and then deselect and reselect row - else if (savedSelection.length == 1 && savedSelection[0].id == ids[0]) { - let id = ids[0]; - let row = this._rowMap[id]; - delete this._rowCache[id]; - this.tree.invalidateRow(row); - - this.selection.clearSelection(); - this._restoreSelection(savedSelection); - } - else { - for (let id of ids) { - let row = this._rowMap[id]; - if (row === undefined) continue; - delete this._rowCache[id]; - this.tree.invalidateRow(row); - } - } - - // For a refresh on an item in the trash, check if the item still belongs - if (type == 'item' && collectionTreeRow.isTrash()) { - let rows = []; - for (let id of ids) { - let row = this.getRowIndexByID(id); - if (row === false) continue; - let item = Zotero.Items.get(id); - let isParentTrashed = item.parentItemID - ? Zotero.Items.get(item.parentItemID).deleted - : false; - // Remove parent row if it isn't deleted, its parent isn't deleted, and it - // doesn't have any deleted children (shown by numChildren including deleted - // being the same as numChildren not including deleted) - if (!item.deleted && !isParentTrashed - && (!item.isRegularItem() || item.numChildren(true) == item.numChildren(false))) { - rows.push(row); - // And all its children in the tree - for (let child = row + 1; child < this.rowCount && this.getLevel(child) > this.getLevel(row); child++) { - rows.push(child); - } - } - } - if (rows.length) { - this._removeRows(rows); - this.tree.invalidate(); - } - } - - return; - } - - if (collectionTreeRow.isShare()) { - return; - } - - // See if we're in the active window - var zp = Zotero.getActiveZoteroPane(); - var activeWindow = zp && zp.itemsView == this; - - var quickSearch = this._ownerDocument.getElementById('zotero-tb-search'); - var hasQuickSearch = quickSearch && quickSearch.searchTextbox.value != ''; - - // 'collection-item' ids are in the form collectionID-itemID - if (type == 'collection-item') { - if (!collectionTreeRow.isCollection()) { - return; - } - - var visibleSubcollections = Zotero.Prefs.get('recursiveCollections') - ? collectionTreeRow.ref.getDescendents(false, 'collection') - : []; - var splitIDs = []; - for (let id of ids) { - var split = id.split('-'); - // Include if an item in this collection or a visible subcollection - if (split[0] == collectionTreeRow.ref.id - || visibleSubcollections.some(c => split[0] == c.id)) { - splitIDs.push(split[1]); - } - } - ids = splitIDs; - } - - this.selection.selectEventsSuppressed = true; - - if ((action == 'remove' && !collectionTreeRow.isLibrary(true)) - || action == 'delete' || action == 'trash' - || (action == 'removeDuplicatesMaster' && collectionTreeRow.isDuplicates())) { - // Since a remove involves shifting of rows, we have to do it in order, - // so sort the ids by row - var rows = []; - let push = action == 'delete' || action == 'trash' || action == 'removeDuplicatesMaster'; - for (var i=0, len=ids.length; i 0) { - rows.push(row); - } - } - } - } - - if (rows.length > 0) { - this._removeRows(rows); - madeChanges = true; - } - } - else if (['item', 'collection', 'search'].includes(type) && action == 'modify') - { - // Clear row caches - for (const id of ids) { - delete this._rowCache[id]; - } - - // If saved search, publications, recently read, or trash, just re-run search - if (collectionTreeRow.isSearch() - || collectionTreeRow.isPublications() - || collectionTreeRow.isRecentlyRead() - || collectionTreeRow.isTrash() - || hasQuickSearch) { - await this.refresh(); - refreshed = true; - madeChanges = true; - // Don't bother re-sorting in trash, since it's probably just a modification of a parent - // item that's about to be deleted - if (!collectionTreeRow.isTrash()) { - sort = true; - } - } - else if (collectionTreeRow.isFeedsOrFeed()) { - // Moved to itemPane CE - } - // If not a search, process modifications manually - else { - var items = Zotero.Items.get(ids); - - for (let i = 0; i < items.length; i++) { - let item = items[i]; - let id = item.id; - - let row = this._rowMap[id]; - - // Deleted items get a modify that we have to ignore when - // not viewing the trash - if (item.deleted) { - continue; - } - - // Item already exists in this view - if (row !== undefined) { - let parentItemID = this.getRow(row).ref.parentItemID; - let parentIndex = this.getParentIndex(row); - - // If item moved from top level to under another item, remove the old row - if (parentIndex == -1 && parentItemID) { - this._closeContainer(row); - this._removeRow(row); - } - // If moved from under another item to top level, remove old row and add new one - else if (parentIndex != -1 && !parentItemID) { - this._closeContainer(row); - this._removeRow(row); - - let beforeRow = this.rowCount; - this._addRow(new ItemTreeRow(item, 0, false), beforeRow); - - sort = true; - } - // If moved from one parent to another, remove from old parent - else if (parentItemID && parentIndex != -1 && this._rowMap[parentItemID] != parentIndex) { - this._closeContainer(row); - this._removeRow(row); - } - // If Unfiled Items and item was added to a collection, remove from view - else if (this.isContainer(row) && collectionTreeRow.isUnfiled() && item.getCollections().length) { - this._closeContainer(row); - this._removeRow(row); - } - // Resort everything if a container is updated, just in case - else if (this.isContainer(row)) { - sort = true; - } - // If not moved from under one item to another, just resort the row, - // which also invalidates it and refreshes it - else { - sort = id; - } - - madeChanges = true; - } - // Otherwise, for a top-level item in a library root or a collection - // containing the item, the item has to be added - else if (item.isTopLevelItem()) { - // Root view - let add = collectionTreeRow.isLibrary(true) - && collectionTreeRow.ref.libraryID == item.libraryID; - // Collection containing item - if (!add && collectionTreeRow.isCollection()) { - add = item.inCollection(collectionTreeRow.ref.id); - } - if (add) { - //most likely, the note or attachment's parent was removed. - let beforeRow = this.rowCount; - this._addRow(new ItemTreeRow(item, 0, false), beforeRow); - madeChanges = true; - sort = id; - } - } - // If a trashed child item is restored while its parent's row is expanded, - // collapse and re-open the parent to have that child item row added. - else { - let parentItemRowIndex = this.getRowIndexByID(item.parentItemID); - if (parentItemRowIndex === false) continue; - if (this.isContainerOpen(parentItemRowIndex)) { - this.toggleOpenState(parentItemRowIndex, true); - await this.toggleOpenState(parentItemRowIndex); - } - } - - if (item.parentItemID && this._getColumns().some(col => !col.hidden && col.dependsOnChildren)) { - delete this._rowCache[item.parentItemID]; - if (this._rowMap[item.parentItemID] !== undefined) { - this.tree.invalidateRow(this._rowMap[item.parentItemID]); - // If we're sorting by a dependsOnChildren column, also re-sort - // (We don't look at secondary sort here because no dependsOnParent columns can be secondary sorts. - // If that changes, we need to be more thorough here.) - let sortField = this.getSortField(); - if (this._getColumns().find(col => col.dataKey == sortField)?.dependsOnChildren) { - madeChanges = true; - sort = true; - } - } - } - } - - if (sort && ids.length != 1) { - sort = true; - } - } - } - else if(type == 'item' && action == 'add') - { - let items = Zotero.Items.get(ids); - - // When an image is pasted into a note, an invisible attachment child - // of that note is created. Filter out such items, since they - // do not appear in the itemTree and should not cause a refresh. - items = items.filter(item => !item.isEmbeddedImageAttachment()); - // If there are no other items, just stop. - if (items.length == 0) return; - - // In some modes, just re-run search - if (collectionTreeRow.isSearch() - || collectionTreeRow.isPublications() - || collectionTreeRow.isTrash() - || collectionTreeRow.isUnfiled() - || collectionTreeRow.isRecentlyRead() - || hasQuickSearch) { - if (hasQuickSearch) { - // For item adds, clear the quick search, unless all the new items have - // skipSelect or are child items - if (activeWindow && type == 'item') { - let clear = false; - for (let i=0; i 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); + }); + }); +});