collection search

This commit is contained in:
Bogdan Abaev 2023-11-01 14:50:50 -04:00 committed by Dan Stillman
parent 7da00957ef
commit f8a6b82c63
6 changed files with 742 additions and 89 deletions

View file

@ -85,10 +85,13 @@ var CollectionTree = class CollectionTree extends LibraryTree {
this._editingInput = null;
this._dropRow = null;
this._typingTimeout = null;
this._customRowHeights = [];
this._separatorHeight = 8;
this._filter = "";
this._filterResultsCache = {};
this._hiddenFocusedRow = null;
this.onLoad = this.createEventBinding('load', true, true);
}
@ -150,6 +153,28 @@ var CollectionTree = class CollectionTree extends LibraryTree {
else if (event.key == "F2" && !Zotero.isMac && treeRow.isCollection()) {
this.handleActivate(event, [this.selection.focused]);
}
else if (["ArrowDown", "ArrowUp"].includes(event.key)) {
// Specific logic for keypress navigation during collection filtering
// that skips context-rows
if (!this._isFilterEmpty()) {
this.focusNextMatchingRow(this.selection.focused, event.key == "ArrowUp", false);
return false;
}
}
else if (["ArrowRight", "ArrowLeft"].includes(event.key)) {
// No collapsing rows with arrows to avoid focusing on context rows
if (!this._isFilterEmpty()) {
return false;
}
}
else if (event.key == "End" && !this._isFilterEmpty()) {
this.focusLastMatchingRow();
return false;
}
else if (event.key == "Home" && !this._isFilterEmpty()) {
this.focusFirstMatchingRow(true);
return false;
}
return true;
}
@ -162,6 +187,23 @@ var CollectionTree = class CollectionTree extends LibraryTree {
this.commitEditingName(this._editing);
this._editing = null;
}
// If the filter is on, the last row can be a previously focused
// row that does not match the filter. If the focus moves
// away to another row, we can delete it.
if (!this._isFilterEmpty() && this._hiddenFocusedRow && treeRow) {
if (this._hiddenFocusedRow.isCollection()
|| this._hiddenFocusedRow.isGroup()
|| this._hiddenFocusedRow.isSearch()
|| this._hiddenFocusedRow.isFeed()) {
if (!this._includedInTree(this._hiddenFocusedRow.ref) && treeRow.id !== this._hiddenFocusedRow.id) {
let indexToDelete = this.getRowIndexByID(this._hiddenFocusedRow.id);
if (indexToDelete) {
this._removeRow(indexToDelete);
this._hiddenFocusedRow = null;
}
}
}
}
// Update aria-activedescendant on the tree
this.forceUpdate();
if (shouldDebounce) {
@ -224,6 +266,12 @@ 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";
@ -231,6 +279,16 @@ var CollectionTree = class CollectionTree extends LibraryTree {
div.classList.toggle('highlighted', this._highlightedRows.has(treeRow.id));
div.classList.toggle('drop', this._dropRow == index);
div.classList.toggle('unread', treeRow.ref && treeRow.ref.unreadCount > 0);
let { matchesFilter, hasChildMatchingFilter } = this._matchesFilter(treeRow.ref);
div.classList.toggle('context-row', !matchesFilter && hasChildMatchingFilter);
// Hide currently focused but filtered out row to avoid confusing itemTree
if (this._hiddenFocusedRow && this._hiddenFocusedRow.id == treeRow.id) {
div.style.display = "none";
}
else if (div.style.display == "none") {
// Make sure we unhide the div if the row matches filter conditions
div.style.display = "";
}
// Depth indent
let depth = treeRow.level;
@ -241,6 +299,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
&& treeRow.ref && treeRow.ref.libraryID != Zotero.Libraries.userLibraryID) {
depth--;
}
// Ensures the feeds row has no padding
if (treeRow.isFeeds()) {
depth = 0;
}
div.style.paddingInlineStart = (CHILD_INDENT * depth) + 'px';
// Create a single-cell for the row (for the single-column layout)
@ -249,7 +311,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
// Twisty/spacer
let twisty;
if (this.isContainerEmpty(index)) {
if (this.isContainerEmpty(index) || !hasChildMatchingFilter) {
twisty = document.createElement('span');
if (Zotero.isMac && treeRow.isHeader()) {
twisty.classList.add("spacer-header");
@ -385,7 +447,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
async refresh() {
try {
Zotero.debug("Refreshing collections pane");
if (this.hideSources.indexOf('duplicates') == -1) {
this._virtualCollectionLibraries.duplicates =
Zotero.Prefs.getVirtualCollectionState('duplicates');
@ -397,19 +459,26 @@ var CollectionTree = class CollectionTree extends LibraryTree {
var newRows = [];
var added = 0;
this._filterResultsCache = {};
let libraryIncluded, groupsIncluded, feedsIncluded;
//
// Add "My Library"
//
newRows.splice(added++, 0,
new Zotero.CollectionTreeRow(this, 'library', { libraryID: Zotero.Libraries.userLibraryID }));
newRows[0].isOpen = true;
added += await this._expandRow(newRows, 0);
libraryIncluded = this._includedInTree({ libraryID: Zotero.Libraries.userLibraryID });
if (libraryIncluded) {
newRows.splice(added++, 0,
new Zotero.CollectionTreeRow(this, 'library', { libraryID: Zotero.Libraries.userLibraryID }));
newRows[0].isOpen = true;
added += await this._expandRow(newRows, 0);
}
// Add groups
var groups = Zotero.Groups.getAll();
if (groups.length) {
newRows.splice(added++, 0, new Zotero.CollectionTreeRow(this, 'separator', false));
groupsIncluded = groups.some(group => this._includedInTree(group));
if (groups.length && groupsIncluded) {
if (libraryIncluded) {
newRows.splice(added++, 0, new Zotero.CollectionTreeRow(this, 'separator', false, 0));
}
let groupHeader = new Zotero.CollectionTreeRow(this, 'header', {
id: "group-libraries-header",
label: Zotero.getString('pane.collections.groupLibraries'),
@ -417,6 +486,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
});
newRows.splice(added++, 0, groupHeader);
for (let group of groups) {
if (!this._includedInTree(group)) continue;
newRows.splice(added++, 0,
new Zotero.CollectionTreeRow(this, 'group', group, 1),
);
@ -424,29 +494,39 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
}
// Add feeds
if (this.hideSources.indexOf('feeds') == -1 && Zotero.Feeds.haveFeeds()) {
let feeds = {
get unreadCount() {
return Zotero.Feeds.totalUnreadCount();
},
async updateFeed() {
for (let feed of Zotero.Feeds.getAll()) {
await feed.updateFeed();
}
}
};
feedsIncluded = this._includedInTree(feeds);
if (this.hideSources.indexOf('feeds') == -1 && Zotero.Feeds.haveFeeds() && feedsIncluded) {
if (groupsIncluded || libraryIncluded) {
newRows.splice(added++, 0,
new Zotero.CollectionTreeRow(this, 'separator', false),
);
}
newRows.splice(added++, 0,
new Zotero.CollectionTreeRow(this, 'separator', false),
);
newRows.splice(added++, 0,
new Zotero.CollectionTreeRow(this, 'feeds', {
get unreadCount() {
return Zotero.Feeds.totalUnreadCount();
},
async updateFeed() {
for (let feed of Zotero.Feeds.getAll()) {
await feed.updateFeed();
}
}
})
new Zotero.CollectionTreeRow(this, 'feeds', feeds)
);
added += await this._expandRow(newRows, added - 1);
}
this.selection.selectEventsSuppressed = true;
// If the focused row does not match the filter, create a hidden dummy row at the bottom
// of the tree to focus on to prevent itemTree from changing selection
this._hiddenFocusedRow = this._createFocusedFilteredRow(newRows);
if (this._hiddenFocusedRow) {
newRows.splice(added++, 0,
this._hiddenFocusedRow
);
}
this._rows = newRows;
this._refreshRowMap();
} catch (e) {
@ -465,7 +545,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
this.tree.invalidate();
}
async selectByID(id) {
async selectByID(id, ensureRowVisible = true) {
var type = id[0];
id = parseInt(('' + id).substr(1));
@ -490,10 +570,12 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
var row = this.getRowIndexByID(type + id);
if (!row) {
if (row === false) {
return false;
}
this.ensureRowIsVisible(row);
if (ensureRowVisible) {
this.ensureRowIsVisible(row);
}
await this.selectWait(row);
return true;
@ -664,6 +746,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
if (action == 'delete') {
let selectedIndex = this.selection.focused;
let feedDeleted = false;
var offset = 0;
// Since a delete involves shifting of rows, we have to do it in reverse order
let rows = [];
@ -672,6 +755,9 @@ var CollectionTree = class CollectionTree extends LibraryTree {
switch (type) {
case 'collection':
if (this._rowMap['C' + id] !== undefined) {
// During filtering, calculate by how many rows focus needs to be shifted.
// e.g. Shift focus by 2 if a child is deleted and it's parent does not match the filter
offset = Math.max(this._calculateOffsetForRowSelection('C' + id), offset);
rows.push(this._rowMap['C' + id]);
}
break;
@ -714,8 +800,12 @@ var CollectionTree = class CollectionTree extends LibraryTree {
this._removeRow(row - 1);
}
}
this._selectAfterRowRemoval(selectedIndex);
// If there's an active filter, we can have a child matching filter be deleted
// which means the non-matching parent needs to be removed, so the tree is rebuilt
if (!this._isFilterEmpty()) {
await this.reload();
}
this._selectAfterRowRemoval(selectedIndex - offset);
}
else if (action == 'modify') {
let row;
@ -723,6 +813,20 @@ var CollectionTree = class CollectionTree extends LibraryTree {
let rowID = "C" + id;
let selectedIndex = this.selection.focused;
let handleFocusDuringSearch = async (type) => {
let object = type == 'collection' ? Zotero.Collections.get(id) : Zotero.Searches.get(id);
// If collections/searches are being filtered, some rows
// need to be (un-)greyed out or removed, so reload.
if (!this._isFilterEmpty()) {
let offset = 0;
if (!this._includedInTree(object, true)) {
offset = this._calculateOffsetForRowSelection(type[0].toUpperCase() + id);
}
await this.reload();
this._selectAfterRowRemoval(selectedIndex - offset);
}
};
switch (type) {
case 'collection':
let collection = Zotero.Collections.get(id);
@ -765,6 +869,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
this.tree.invalidateRow(parentRow);
}
}
await handleFocusDuringSearch('collection');
break;
case 'search':
@ -802,6 +907,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
}
}
await handleFocusDuringSearch('search');
break;
case 'feed':
@ -854,6 +960,19 @@ var CollectionTree = class CollectionTree extends LibraryTree {
break;
}
}
// After new collections were added, we need to update parents' info on if they have
// a child matching the filter to show the arrow or not.
if (!this._isFilterEmpty() && type == "collection") {
for (let id of ids) {
let rowIndex = this.getRowIndexByID("C" + id);
let parentIndex = rowIndex ? this.getParentIndex(rowIndex) : -1;
while (parentIndex > 0) {
let parent = this.getRow(parentIndex);
this._matchesFilter(parent.ref, true);
parentIndex = this.getParentIndex(parentIndex);
}
}
}
}
else if (action == 'refresh' && type == 'trash') {
// We need to update the trash's status (full or empty), and if empty,
@ -892,8 +1011,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
var rows = [];
for (let id of ids) {
let row = this._rowMap[id];
this._highlightedRows.add(id);
rows.push(row);
if (row) {
this._highlightedRows.add(id);
rows.push(row);
}
}
rows.sort();
// Select first collection
@ -918,7 +1039,9 @@ var CollectionTree = class CollectionTree extends LibraryTree {
Zotero.debug("Cannot expand to nonexistent collection " + collectionID, 2);
return false;
}
if (!this._includedInTree(col)) {
return false;
}
// Open library if closed
var libraryRow = this._rowMap['L' + col.libraryID];
if (!this.isContainerOpen(libraryRow)) {
@ -1124,6 +1247,13 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
getRowString(index) {
// During filtering, context rows return an empty string to not be selectable
// with key-based navigation
if (!this._isFilterEmpty()) {
if (!this._matchesFilter(this.getRow(index).ref).matchesFilter) {
return "";
}
}
return this.getRow(index).getName();
}
@ -2199,6 +2329,275 @@ var CollectionTree = class CollectionTree extends LibraryTree {
Zotero.Prefs.set("sourceList.persist", JSON.stringify(state));
})
// When collections are renamed or deleted during search, more than
// a single row can be filtered out (if a child matching the filter is deleted,
// its non-matching parent is deleted too). To select the right row
// after such actions, the index should be offset by the total number of rows removed
_calculateOffsetForRowSelection(id) {
let rowIndex = this.getRowIndexByID(id);
let offset = 0;
let parentRowIndex = this.getParentIndex(rowIndex);
while (parentRowIndex > 0) {
let parentRow = this.getRow(parentRowIndex);
if (parentRow.depth < 1 || this._matchesFilter(parentRow.ref).matchesFilter) {
break;
}
parentRowIndex = this.getParentIndex(parentRowIndex);
offset += 1;
}
return offset;
}
/**
* Set collection filter and refresh collectionTree to only include
* rows that match the filter. Rows that do not match the filter but have children that do
* are displayed as context rows. All relevant rows are toggled open. Selection is kept
* on the currently selected row if any.
* @param {String} filterText - Text that rows have to contain to match the filter
* @param {Bool} scrollToSelected - Allow scrolling to the currently selected row.
*/
async setFilter(filterText, scrollToSelected) {
this._filter = filterText.toLowerCase();
let currentRow = this.getRow(this.selection.focused) || this._hiddenFocusedRow;
let currentRowDisplayed = currentRow && this._includedInTree(currentRow.ref);
// If current row does not match any filters, it'll be hidden, so clear selection
if (!currentRowDisplayed) {
this.selection.clearSelection();
}
await this.reload();
if (currentRow) {
// Special treatment for when there are no filter matches
// Otherwise, selection.focused does not get updated by selectByID, which breaks ZoteroPane.
if (this._rows.length == 1) {
this.selection.select(0);
}
// Re-select previously selected row
else {
await this.selectByID(currentRow.id, scrollToSelected);
}
}
let promise = this.waitForSelect();
this.selection.selectEventsSuppressed = false;
await promise;
// Expand all container rows to see all search results
if (!this._isFilterEmpty()) {
for (let i = 0; i < this._rows.length; i++) {
let row = this._rows[i];
if (this.isContainer(i) && this._matchesFilter(row.ref).hasChildMatchingFilter && !row.isOpen) {
await this.toggleOpenState(i);
}
}
}
}
/**
* Creates an extra hidden row to keep focus on it when a currently focused row does not match the filter.
* Required to avoid changes in itemTree during collection search.
* @param {CollectionTreeRow[]} rows - Rows of collectionTree.
* @return {CollectionTreeRow|null}
*/
_createFocusedFilteredRow(rows) {
if (this._isFilterEmpty()) {
return null;
}
let focused = this.getRow(this.selection.focused);
// If row already exists - nothing to add
let focusedRowAlreadyExists = rows.some(row => row.id == focused?.id);
if (!focused || focusedRowAlreadyExists) {
return null;
}
return new Zotero.CollectionTreeRow(this, focused.type, focused.ref, 0, false);
}
focusedRowMatchesFilter() {
let row = this.getRow(this.selection.focused);
return this._matchesFilter(row.ref).matchesFilter;
}
filterEquals(filterValue) {
return filterValue === this._filter;
}
_isFilterEmpty() {
return this._filter === "";
}
clearFilter() {
// Clear the search field
if (collectionsSearchField.value.length) {
collectionsSearchField.value = '';
ZoteroPane.handleCollectionSearchInput();
return null;
}
// If the search field is empty, focus the collection tree
return document.getElementById('collection-tree');
}
/**
* Select and focus the first row matching the collection filter. If it is a child of a collapsed
* container(s), the container(s) on the way will be toggled open.
* @param {Bool} scrollToLibrary - Scroll to the very top after selection
*/
async focusFirstMatchingRow(scrollToLibrary) {
let index = 0;
let row = this.getRow(index);
while (index < this._rows.length && !this._matchesFilter(row.ref).matchesFilter) {
row = this.getRow(index);
if (!row.isOpen) {
await this.toggleOpenState(index);
}
index += 1;
}
this.tree.focus();
await this.selectByID(row.id);
if (scrollToLibrary) {
this.ensureRowIsVisible(0);
}
}
/**
* Select the last row matching collection filter. Opens any container rows on the way.
*/
async focusLastMatchingRow() {
let loopCounter = 0;
let offset = 1;
if (this._hiddenFocusedRow) {
offset = 2;
}
let lastRow = this.getRow(this._rows.length - offset);
while (lastRow && !lastRow.isOpen && this._matchesFilter(lastRow.ref).hasChildMatchingFilter) {
await this.toggleOpenState(this._rows.length - 1);
lastRow = this.getRow(this._rows.length - offset);
// Sanity check to make sure we are not stuck in an infinite loop if something goes wrong
loopCounter++;
if (loopCounter > 100) {
Zotero.debug("Reasonable collections depth exceeded");
return false;
}
}
return this.selectByID(lastRow.id);
}
/**
* Select and focus the next row after startIndex that matches the filter
*
* @param {Int} startIndex - Index of the row from which the search of the next matching row begins
* @param {Bool} up - Move focus up the collection tree. Unless true, default direction is down.
* @return {Bool} true if focus was shifted, false if selected row was not changed
*/
async focusNextMatchingRow(startIndex, up) {
if (this._isFilterEmpty()) {
return false;
}
// Increment or decrement the row index depending on direction
let moveInDirection = (rowIndex) => {
return up ? rowIndex - 1 : rowIndex + 1;
};
let rowIndex = startIndex;
while (rowIndex < this._rows.length && rowIndex >= 0) {
let nextIndex = moveInDirection(rowIndex);
let nextRow = this.getRow(nextIndex);
// If there is not next row or the next row is hidden (which should never happen), stop
if (!nextRow || nextRow.id == this._hiddenFocusedRow?.id) {
// If we stopped going up, make sure the library or group row is visible
if (up && !this.tree.rowIsVisible(0)) {
this.ensureRowIsVisible(0);
}
return false;
}
// Select the row if it's matching the filter unless it's a header or separator
if (this._matchesFilter(nextRow.ref).matchesFilter
&& !["separator", "header"].includes(nextRow.type)) {
this.tree.focus();
return this.selectByID(nextRow.id);
}
rowIndex = nextIndex;
}
return false;
}
/**
* Check if a given object matches filter or has children that match the filter.
*
* @param {Collection|Search|Library|Group} object - Object to check
* @param {Bool} resetCache - Ignore and reset existing cache value for that object
* @return {Object} { matchesFilter: Bool, hasChildMatchingFilter: Bool }
* matchesFilter = object itself matches the filter
* hasChildMatchingFilter = object has children that match the filter
*/
_matchesFilter(object, resetCache = false) {
// When the filter is empty, everything matches
if (this._isFilterEmpty()) {
return { matchesFilter: true, hasChildMatchingFilter: true };
}
// Handle separator or group headers
if ((object.libraryID === undefined || object.libraryID === -1) && !object.updateFeed) {
return { matchesFilter: true, hasChildMatchingFilter: false };
}
// Define objectID to be used in cache
let objectID = "L" + object.libraryID;
if (['Collection', 'Search', 'Feed'].includes(object._ObjectType)) {
objectID = object._ObjectType[0] + object.id;
}
else if (object.updateFeed && !object.libraryID) {
// Special ID for 'Feeds' parent row of all feeds
objectID = 'feeds';
}
// If we found the filter status during previous recursions, return that
if (this._filterResultsCache[objectID] && !resetCache) {
return this._filterResultsCache[objectID];
}
// Filtering is case insensitive
let objectName = (object.name || "").toLowerCase();
// Special treatment to fetch the name for My Library or Feeds
if (objectID[0] == 'L' && object._ObjectType !== "Group") {
objectName = Zotero.getString('pane.collections.library').toLowerCase();
}
else if (objectID == 'feeds') {
objectName = Zotero.getString('pane.collections.feedLibraries').toLowerCase();
}
let filterValue = this._filter;
let childrenToSearch = [];
if (object._ObjectType == 'Collection') {
let collection = Zotero.Collections.get(object.id);
childrenToSearch = collection.getChildCollections();
}
else if (object.libraryID && !["Search", "Feeds"].includes(object._ObjectType)) {
childrenToSearch = Zotero.Collections.getByLibrary(object.libraryID);
childrenToSearch = childrenToSearch.concat(Zotero.Searches.getByLibrary(object.libraryID));
}
else if (objectID == 'feeds') {
childrenToSearch = Zotero.Feeds.getAll();
}
let matchesFilter = objectName.includes(filterValue);
// For libraries, groups and collections, recursively check if they have any children that match the filter
let hasChildMatchingFilter = childrenToSearch.some((child) => {
let { matchesFilter, hasChildMatchingFilter } = this._matchesFilter(child);
return matchesFilter || hasChildMatchingFilter;
});
// Save filter status to cache
this._filterResultsCache[objectID] = {
matchesFilter: matchesFilter,
hasChildMatchingFilter: hasChildMatchingFilter
};
return this._filterResultsCache[objectID];
}
// A shortcut to call this._matchesFilter to check if a given object should be present
// in collectionTree or not
_includedInTree(object, resetCache) {
let { matchesFilter, hasChildMatchingFilter } = this._matchesFilter(object, resetCache);
return matchesFilter || hasChildMatchingFilter;
}
async _expandRow(rows, row, forceOpen) {
var treeRow = rows[row];
var level = rows[row].level;
@ -2260,7 +2659,8 @@ var CollectionTree = class CollectionTree extends LibraryTree {
for (var i = 0, len = collections.length; i < len; i++) {
// Skip collections in trash
if (collections[i].deleted) continue;
// Skip collections that do not match the filter and have no matching children
if (!this._includedInTree(collections[i])) continue;
let beforeRow = row + 1 + newRows;
rows.splice(beforeRow, 0,
new Zotero.CollectionTreeRow(this, isFeeds ? 'feed' : 'collection', collections[i], level + 1));
@ -2277,13 +2677,14 @@ var CollectionTree = class CollectionTree extends LibraryTree {
for (var i = 0, len = savedSearches.length; i < len; i++) {
// Skip searches in trash
if (savedSearches[i].deleted) continue;
// Skip searches not matching the filter
if (!this._includedInTree(savedSearches[i])) continue;
rows.splice(row + 1 + newRows, 0,
new Zotero.CollectionTreeRow(this, 'search', savedSearches[i], level + 1));
newRows++;
}
if (showPublications) {
if (showPublications && this._isFilterEmpty()) {
// Add "My Publications"
rows.splice(row + 1 + newRows, 0,
new Zotero.CollectionTreeRow(this,
@ -2299,7 +2700,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
// Duplicate items
if (showDuplicates) {
if (showDuplicates && this._isFilterEmpty()) {
let d = new Zotero.Duplicates(libraryID);
rows.splice(row + 1 + newRows, 0,
new Zotero.CollectionTreeRow(this, 'duplicates', d, level + 1));
@ -2307,7 +2708,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
// Unfiled items
if (showUnfiled) {
if (showUnfiled && this._isFilterEmpty()) {
let s = new Zotero.Search;
s.libraryID = libraryID;
s.name = Zotero.getString('pane.collections.unfiled');
@ -2319,7 +2720,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
// Retracted items
if (showRetracted) {
if (showRetracted && this._isFilterEmpty()) {
let s = new Zotero.Search;
s.libraryID = libraryID;
s.name = Zotero.getString('pane.collections.retracted');
@ -2330,7 +2731,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
newRows++;
}
if (showTrash) {
if (showTrash && this._isFilterEmpty()) {
let deletedItems = await Zotero.Items.getDeleted(libraryID, true);
if (deletedItems.length || Zotero.Prefs.get("showTrashWhenEmpty")) {
var ref = {
@ -2428,10 +2829,12 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
}
}
this._addRow(
new Zotero.CollectionTreeRow(this, 'collection', collection, level),
beforeRow
);
if (this._includedInTree(collection)) {
this._addRow(
new Zotero.CollectionTreeRow(this, 'collection', collection, level),
beforeRow
);
}
}
else if (objectType == 'search') {
let search = Zotero.Searches.get(id);
@ -2467,10 +2870,12 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
}
}
this._addRow(
new Zotero.CollectionTreeRow(this, 'search', search, level),
beforeRow
);
if (this._includedInTree(search)) {
this._addRow(
new Zotero.CollectionTreeRow(this, 'search', search, level),
beforeRow
);
}
}
return beforeRow;
@ -2494,7 +2899,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
if (row >= this._rows.length) {
row = this._rows.length - 1;
};
// Make sure the selection doesn't land on a separator (e.g. deleting last feed)
while (row >= 0 && !this.isSelectable(row)) {
// move up, since we got shifted down

View file

@ -259,17 +259,46 @@ var ZoteroPane = new function()
moveFocus(actionsMap, event, true);
});
let collectionsSearchField = document.getElementById("zotero-collections-search");
let clearCollectionSearch = (removeFocus) => {
// Clear the search field
if (collectionsSearchField.value.length) {
collectionsSearchField.value = '';
ZoteroPane.collectionsView.setFilter("", true);
if (!removeFocus) {
return null;
}
}
ZoteroPane.hideCollectionSearch();
// If the search field is empty, focus the collection tree
return document.getElementById('collection-tree');
};
collectionTreeToolbar.addEventListener("keydown", (event) => {
let actionsMap = {
'zotero-tb-collection-add': {
ArrowRight: () => null,
ArrowLeft: () => null,
Tab: () => document.getElementById('zotero-tb-collection-search').click(),
Tab: () => document.getElementById('zotero-tb-collections-search').click(),
ShiftTab: () => document.getElementById('zotero-tb-sync')
},
'zotero-collections-search': {
Tab: () => document.getElementById('zotero-tb-add'),
ShiftTab: () => document.getElementById('zotero-tb-collection-add')
ShiftTab: () => document.getElementById('zotero-tb-collection-add'),
Enter: () => {
// Prevent Enter pressed before the filtering ran from doing anything
if (!ZoteroPane.collectionsView.filterEquals(collectionsSearchField.value)) {
return null;
}
// If the current row passes the filter, make sure it is visible and focus collectionTree
if (ZoteroPane.collectionsView.focusedRowMatchesFilter()) {
ZoteroPane.collectionsView.ensureRowIsVisible(ZoteroPane.collectionsView.selection.focused);
return document.getElementById('collection-tree');
}
// Otherwise, focus the first row passing the filter
ZoteroPane.collectionsView.focusFirstMatchingRow(false);
return null;
},
Escape: clearCollectionSearch
},
};
moveFocus(actionsMap, event, true);
@ -291,7 +320,7 @@ var ZoteroPane = new function()
if (collectionsPane.getAttribute("collapsed")) {
return document.getElementById('zotero-tb-sync');
}
document.getElementById('zotero-tb-collection-search').click();
document.getElementById('zotero-tb-collections-search').click();
return null;
},
' ': () => {
@ -305,7 +334,7 @@ var ZoteroPane = new function()
ArrowRight: () => document.getElementById("zotero-tb-attachment-add"),
ArrowLeft: () => document.getElementById("zotero-tb-add"),
Tab: () => document.getElementById("zotero-tb-search")._searchModePopup.flattenedTreeParentNode.focus(),
ShiftTab: () => document.getElementById('zotero-tb-collection-search').click(),
ShiftTab: () => document.getElementById('zotero-tb-collections-search').click(),
Enter: () => Zotero_Lookup.showPanel(event.target),
' ': () => Zotero_Lookup.showPanel(event.target)
},
@ -313,13 +342,13 @@ var ZoteroPane = new function()
ArrowRight: () => document.getElementById("zotero-tb-note-add"),
ArrowLeft: () => document.getElementById("zotero-tb-lookup"),
Tab: () => document.getElementById("zotero-tb-search")._searchModePopup.flattenedTreeParentNode.focus(),
ShiftTab: () => document.getElementById('zotero-tb-collection-search').click()
ShiftTab: () => document.getElementById('zotero-tb-collections-search').click()
},
'zotero-tb-note-add': {
ArrowRight: () => null,
ArrowLeft: () => document.getElementById("zotero-tb-attachment-add"),
Tab: () => document.getElementById("zotero-tb-search")._searchModePopup.flattenedTreeParentNode.focus(),
ShiftTab: () => document.getElementById('zotero-tb-collection-search').click()
ShiftTab: () => document.getElementById('zotero-tb-collections-search').click()
},
'zotero-tb-search-textbox': {
ShiftTab: () => {
@ -348,6 +377,9 @@ var ZoteroPane = new function()
// If tag selector is collapsed, go to itemTree, otherwise
// default to focusing on tag selector
return false;
},
Escape: () => {
clearCollectionSearch(true);
}
}
};
@ -424,6 +456,7 @@ var ZoteroPane = new function()
ZoteroContextPane.init();
await ZoteroPane.initCollectionsTree();
await ZoteroPane.initItemsTree();
ZoteroPane.initCollectionTreeSearch();
// Add a default progress window
ZoteroPane.progressWindow = new Zotero.ProgressWindow({ window });
@ -1026,32 +1059,36 @@ var ZoteroPane = new function()
ZoteroPane_Local.collectionsView.setHighlightedRows();
}
this.revealCollectionSearch = function () {
this.hideCollectionSearch = function () {
let collectionSearchField = document.getElementById("zotero-collections-search");
let collectionSearchButton = document.getElementById("zotero-tb-collection-search");
var hideIfEmpty = function () {
if (!collectionSearchField.value.length) {
collectionSearchField.classList.remove("visible");
collectionSearchButton.style.display = '';
let collectionSearchButton = document.getElementById("zotero-tb-collections-search");
if (!collectionSearchField.value.length && collectionSearchField.classList.contains("visible")) {
collectionSearchField.classList.remove("visible");
collectionSearchButton.style.display = '';
collectionSearchField.setAttribute("disabled", true);
}
}
this.initCollectionTreeSearch = function () {
let collectionSearchField = document.getElementById("zotero-collections-search");
let collectionSearchButton = document.getElementById("zotero-tb-collections-search");
collectionSearchField.addEventListener("blur", ZoteroPane.hideCollectionSearch);
collectionSearchButton.addEventListener("click", (_) => {
if (!collectionSearchField.classList.contains("visible")) {
collectionSearchButton.style.display = 'none';
collectionSearchField.classList.add("visible");
// Enable and focus the field only after it was revealed to prevent the cursor
// from changing between 'text' and 'pointer' back and forth as the input field expands
setTimeout(() => {
collectionSearchField.removeAttribute("disabled");
collectionSearchField.focus();
}, 250);
return;
}
};
if (!collectionSearchField.classList.contains("visible")) {
collectionSearchButton.style.display = 'none';
collectionSearchField.classList.add("visible");
collectionSearchField.addEventListener('blur', hideIfEmpty);
}
collectionSearchField.focus();
collectionSearchField.focus();
});
};
this.handleCollectionSearchKeypress = function (textbox, event) {
if (event.keyCode == event.DOM_VK_ESCAPE) {
textbox.value = '';
textbox.blur();
}
};
this.handleCollectionSearchInput = function (textbox, _) { };
function handleKeyUp(event) {
if ((Zotero.isWin && event.keyCode == 17) ||
@ -2721,6 +2758,13 @@ var ZoteroPane = new function()
this.search(true);
}
}
this.handleCollectionSearchInput = function () {
let collectionsSearchField = document.getElementById("zotero-collections-search");
// Set the filter without scrolling the selected row into the view
this.collectionsView.setFilter(collectionsSearchField.value, false);
}
this.handleSearchInput = function (textbox, event) {

View file

@ -953,11 +953,10 @@
<toolbarbutton id="zotero-tb-collection-add" tabindex="-1" class="zotero-tb-button" tooltiptext="&zotero.toolbar.newCollection.label;" command="cmd_zotero_newCollection"/>
<spacer flex="1"></spacer>
<html:div style="display: flex;flex-direction: row;">
<toolbarbutton id="zotero-tb-collection-search" tabindex="-1" class="zotero-tb-button" tooltiptext="&zotero.toolbar.newCollection.label;" oncommand="ZoteroPane.revealCollectionSearch()"/>
<toolbarbutton id="zotero-tb-collections-search" tabindex="-1" class="zotero-tb-button" tooltiptext="&zotero.toolbar.newCollection.label;"/>
<search-textbox
id="zotero-collections-search" class="hidden"
onkeydown="ZoteroPane.handleCollectionSearchKeypress(this, event)"
oncommand="ZoteroPane.handleCollectionSearchInput(this, event)"/>
id="zotero-collections-search" class="hidden" disabled="true"
oncommand="ZoteroPane.handleCollectionSearchInput()"/>
</html:div>
</hbox>
</toolbar>

View file

@ -207,7 +207,6 @@
}
#zotero-collections-toolbar {
margin-inline-end: 10px; /* Set to width of splitter for visual aesthetics */
padding-inline-start: 2px;
}
@ -282,11 +281,8 @@
}
#zotero-collections-search {
height: 25px;
max-width: 0;
display: inline-block;
overflow: hidden;
transition: max-width 0.7s ease;
transition: max-width 0.5s ease;
padding: 0;
margin: 0;
appearance: none;
@ -294,9 +290,14 @@
}
#zotero-collections-search.visible {
max-width: 200px;
max-width: 180px;
appearance: auto;
visibility: visible;
/* Avoids flickering between pointer and text cursor during transition */
cursor: text;
/* Bring back default margins and padding */
padding: revert-layer;
margin: revert-layer;
}
/* Hide collection search on pane collapse, otherwise it still shows when focused */

View file

@ -1374,4 +1374,200 @@ describe("Zotero.CollectionTree", function() {
assert.lengthOf(win.document.querySelectorAll('#zotero-collections-tree .row.unread'), 2);
});
});
describe("#setFilter()", function () {
var collection1, collection2, collection3, collection4, collection5, collection6, collection7, collection8;
var search1, search2, feed1, feed2;
var allRows = [];
let keyboardClick = (key) => {
return new KeyboardEvent('keydown', {
key: key,
code: key,
bubbles: true,
});
};
before(async function () {
// Delete all previously added collections, feeds, searches
for (let col of Zotero.Collections.getByLibrary(userLibraryID)) {
await col.eraseTx();
}
await clearFeeds();
for (let s of Zotero.Searches.getByLibrary(userLibraryID)) {
await s.eraseTx();
}
// Display the collection search bar
win.document.getElementById("zotero-tb-collections-search").click();
// Do not hide the search panel on blur
win.document.getElementById("zotero-collections-search").removeEventListener('blur', zp.hideCollectionSearch);
feed1 = await createFeed({ name: "feed_1 " });
feed2 = await createFeed({ name: "feed_2" });
collection1 = await createDataObject('collection', { name: "collection_level_one", libraryID: userLibraryID });
collection2 = await createDataObject('collection', { name: "collection_level_two_1", parentID: collection1.id, libraryID: userLibraryID });
collection3 = await createDataObject('collection', { name: "collection_level_two_2", parentID: collection1.id, libraryID: userLibraryID });
collection4 = await createDataObject('collection', { name: "collection_level_three_1", parentID: collection2.id, libraryID: userLibraryID });
collection5 = await createDataObject('collection', { name: "collection_level_three_11", parentID: collection2.id, libraryID: userLibraryID });
collection6 = await createDataObject('collection', { name: "collection_level_one_1", libraryID: userLibraryID });
collection7 = await createDataObject('collection', { name: "collection_level_two_21", parentID: collection6.id, libraryID: userLibraryID });
collection8 = await createDataObject('collection', { name: "collection_level_two_22", parentID: collection6.id, libraryID: userLibraryID });
search1 = await createDataObject('search', { name: "search_1", libraryID: userLibraryID });
search2 = await createDataObject('search', { name: "search_2", libraryID: userLibraryID });
allRows = [feed1, feed2, collection1, collection2, collection3, collection4, collection5, collection6, collection7, collection8, search1, search2];
});
beforeEach(async function () {
// Empty filter and let it settle
await cv.setFilter("");
});
after(async function () {
await cv.setFilter("");
});
for (let type of ['collection', 'search', 'feed']) {
// eslint-disable-next-line no-loop-func
it(`should show only ${type} matching the filter`, async function () {
await cv.setFilter(type);
let displayedRowNames = cv._rows.filter(row => row.type == type).map(row => row.getName());
let expectedRowNames = allRows.filter(row => row.name.includes(type)).map(row => row.name);
assert.sameMembers(displayedRowNames, expectedRowNames);
});
}
it('should show non-passing entries whose children pass the filter', async function () {
await cv.setFilter("three");
let displayedRowNames = cv._rows.filter(row => row.type == "collection").map(row => row.ref.name);
let expectedNames = [
"collection_level_one",
"collection_level_two_1",
"collection_level_three_1",
"collection_level_three_11"
];
assert.sameMembers(displayedRowNames, expectedNames);
});
it('should not move focus from selected collection during filtering', async function () {
await cv.selectByID("C" + collection5.id);
await cv.setFilter("three");
let focusedRow = cv.getRow(cv.selection.focused);
assert.equal(focusedRow.id, "C" + collection5.id);
await cv.setFilter("two");
focusedRow = cv.getRow(cv.selection.focused);
assert.equal(focusedRow.id, "C" + collection5.id);
});
it('should only open relevant collections', async function () {
// Collapse top level collections 1 and 6
for (let c of [collection1, collection6]) {
let index = cv.getRowIndexByID("C" + c.id);
let row = cv.getRow(index);
if (row.isOpen) {
await cv.toggleOpenState(index);
}
}
// Set filter and remove it
await cv.setFilter(collection5.name);
await cv.setFilter("");
// Collection 1 and 2 had a matching child, so they are opened
let colOneRow = cv.getRow(cv.getRowIndexByID("C" + collection1.id));
assert.isTrue(colOneRow.isOpen);
let colTwoRow = cv.getRow(cv.getRowIndexByID("C" + collection2.id));
assert.isTrue(colTwoRow.isOpen);
// Collection 6 had no matches, it remains closed
let colSixRow = cv.getRow(cv.getRowIndexByID("C" + collection6.id));
assert.isFalse(colSixRow.isOpen);
});
for (let type of ['collection', 'search']) {
// eslint-disable-next-line no-loop-func
it(`should only hide ${type} if it's renamed to not match the filter`, async function () {
await cv.setFilter(type);
let objectToSelect = type == 'collection' ? collection5 : search2;
objectToSelect.name += "_updated";
await objectToSelect.saveTx();
let displayedRowNames = cv._rows.map(row => row.getName());
assert.include(displayedRowNames, objectToSelect.name);
objectToSelect.name = "not_matching_filter";
await objectToSelect.saveTx();
displayedRowNames = cv._rows.map(row => row.getName());
assert.notInclude(displayedRowNames, objectToSelect.name);
});
}
for (let type of ['collection', 'search']) {
// eslint-disable-next-line no-loop-func
it(`should only add ${type} if its name matches the filter`, async function () {
await cv.setFilter(type);
let newCollection = await createDataObject(type, { name: `new_${type}`, libraryID: userLibraryID });
let displayedRowNames = cv._rows.map(row => row.ref.name);
assert.include(displayedRowNames, newCollection.name);
newCollection = await createDataObject(type, { name: `not_passing_${type.substring(1)}`, libraryID: userLibraryID });
displayedRowNames = cv._rows.map(row => row.ref.name);
assert.notInclude(displayedRowNames, newCollection.name);
});
}
it(`should focus selected collection on Enter if it matches filter`, async function () {
await cv.selectByID(`C${collection3.id}`);
win.document.getElementById("zotero-collections-search").value = "_2";
await cv.setFilter("_2");
win.document.getElementById("zotero-collections-search").dispatchEvent(keyboardClick("Enter"));
assert.equal(cv.getSelectedCollection(true), collection3.id);
assert.equal(win.document.activeElement.id, 'collection-tree');
});
it(`should focus first matching collection on Enter if selected collection does not match filter`, async function () {
await cv.selectByID(`C${collection2.id}`);
win.document.getElementById("zotero-collections-search").focus();
win.document.getElementById("zotero-collections-search").value = "_2";
await cv.setFilter("_2");
win.document.getElementById("zotero-collections-search").dispatchEvent(keyboardClick("Enter"));
// Wait for the selection to go through
await Zotero.Promise.delay(100);
assert.equal(cv.getSelectedCollection(true), collection3.id);
assert.equal(win.document.activeElement.id, 'collection-tree');
});
it(`should not move focus from collection filter on Enter if no rows pass the filter`, async function () {
await cv.selectByID(`C${collection3.id}`);
win.document.getElementById("zotero-collections-search").focus();
win.document.getElementById("zotero-collections-search").value = "Not matching anything";
await cv.setFilter("Not matching anything");
win.document.getElementById("zotero-collections-search").dispatchEvent(keyboardClick("Enter"));
assert.equal(win.document.activeElement.id, 'zotero-collections-search');
});
it(`should skip context rows on arrow up/down`, async function () {
await cv.selectByID(`C${collection2.id}`);
await cv.setFilter("_2");
await cv.focusFirstMatchingRow();
// Skip collection6 that does not match on the way up and down
for (let col of [collection3, collection7, collection8]) {
assert.equal(cv.getSelectedCollection(true), col.id);
await cv.focusNextMatchingRow(cv.selection.focused);
}
await cv.selectByID(`C${collection8.id}`);
for (let col of [collection8, collection7, collection3]) {
assert.equal(cv.getSelectedCollection(true), col.id);
await cv.focusNextMatchingRow(cv.selection.focused, true);
}
});
it(`should clear filter on Escape from collectionTree`, async function () {
await cv.selectByID(`C${collection2.id}`);
let colTree = win.document.getElementById('collection-tree');
await cv.setFilter("_2");
cv.focusFirstMatchingRow();
colTree.dispatchEvent(keyboardClick("Escape"));
assert.equal(cv._filter, "");
assert.equal(cv.getSelectedCollection(true), collection2.id);
});
});
})

View file

@ -1531,6 +1531,10 @@ describe("ZoteroPane", function() {
for (let id of sequence) {
doc.activeElement.dispatchEvent(shiftTab);
// Wait for collection search to be revealed
if (id === "zotero-collections-search") {
await Zotero.Promise.delay(300);
}
assert.equal(doc.activeElement.id, id);
}
@ -1555,6 +1559,10 @@ describe("ZoteroPane", function() {
];
for (let id of sequence) {
doc.activeElement.dispatchEvent(tab);
// Wait for collection search to be revealed
if (id === "zotero-collections-search") {
await Zotero.Promise.delay(300);
}
assert.equal(doc.activeElement.id, id);
}
});