collection search
This commit is contained in:
parent
7da00957ef
commit
f8a6b82c63
6 changed files with 742 additions and 89 deletions
|
@ -85,10 +85,13 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
this._editingInput = null;
|
this._editingInput = null;
|
||||||
this._dropRow = null;
|
this._dropRow = null;
|
||||||
this._typingTimeout = null;
|
this._typingTimeout = null;
|
||||||
|
|
||||||
this._customRowHeights = [];
|
this._customRowHeights = [];
|
||||||
this._separatorHeight = 8;
|
this._separatorHeight = 8;
|
||||||
|
|
||||||
|
this._filter = "";
|
||||||
|
this._filterResultsCache = {};
|
||||||
|
this._hiddenFocusedRow = null;
|
||||||
|
|
||||||
this.onLoad = this.createEventBinding('load', true, true);
|
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()) {
|
else if (event.key == "F2" && !Zotero.isMac && treeRow.isCollection()) {
|
||||||
this.handleActivate(event, [this.selection.focused]);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -162,6 +187,23 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
this.commitEditingName(this._editing);
|
this.commitEditingName(this._editing);
|
||||||
this._editing = null;
|
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
|
// Update aria-activedescendant on the tree
|
||||||
this.forceUpdate();
|
this.forceUpdate();
|
||||||
if (shouldDebounce) {
|
if (shouldDebounce) {
|
||||||
|
@ -224,6 +266,12 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
// Div creation and content
|
// Div creation and content
|
||||||
let div = oldDiv || document.createElement('div');
|
let div = oldDiv || document.createElement('div');
|
||||||
div.innerHTML = "";
|
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
|
// Classes
|
||||||
div.className = "row";
|
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('highlighted', this._highlightedRows.has(treeRow.id));
|
||||||
div.classList.toggle('drop', this._dropRow == index);
|
div.classList.toggle('drop', this._dropRow == index);
|
||||||
div.classList.toggle('unread', treeRow.ref && treeRow.ref.unreadCount > 0);
|
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
|
// Depth indent
|
||||||
let depth = treeRow.level;
|
let depth = treeRow.level;
|
||||||
|
@ -241,6 +299,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
&& treeRow.ref && treeRow.ref.libraryID != Zotero.Libraries.userLibraryID) {
|
&& treeRow.ref && treeRow.ref.libraryID != Zotero.Libraries.userLibraryID) {
|
||||||
depth--;
|
depth--;
|
||||||
}
|
}
|
||||||
|
// Ensures the feeds row has no padding
|
||||||
|
if (treeRow.isFeeds()) {
|
||||||
|
depth = 0;
|
||||||
|
}
|
||||||
div.style.paddingInlineStart = (CHILD_INDENT * depth) + 'px';
|
div.style.paddingInlineStart = (CHILD_INDENT * depth) + 'px';
|
||||||
|
|
||||||
// Create a single-cell for the row (for the single-column layout)
|
// Create a single-cell for the row (for the single-column layout)
|
||||||
|
@ -249,7 +311,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
|
|
||||||
// Twisty/spacer
|
// Twisty/spacer
|
||||||
let twisty;
|
let twisty;
|
||||||
if (this.isContainerEmpty(index)) {
|
if (this.isContainerEmpty(index) || !hasChildMatchingFilter) {
|
||||||
twisty = document.createElement('span');
|
twisty = document.createElement('span');
|
||||||
if (Zotero.isMac && treeRow.isHeader()) {
|
if (Zotero.isMac && treeRow.isHeader()) {
|
||||||
twisty.classList.add("spacer-header");
|
twisty.classList.add("spacer-header");
|
||||||
|
@ -397,19 +459,26 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
|
|
||||||
var newRows = [];
|
var newRows = [];
|
||||||
var added = 0;
|
var added = 0;
|
||||||
|
this._filterResultsCache = {};
|
||||||
|
let libraryIncluded, groupsIncluded, feedsIncluded;
|
||||||
//
|
//
|
||||||
// Add "My Library"
|
// Add "My Library"
|
||||||
//
|
//
|
||||||
newRows.splice(added++, 0,
|
libraryIncluded = this._includedInTree({ libraryID: Zotero.Libraries.userLibraryID });
|
||||||
new Zotero.CollectionTreeRow(this, 'library', { libraryID: Zotero.Libraries.userLibraryID }));
|
if (libraryIncluded) {
|
||||||
newRows[0].isOpen = true;
|
newRows.splice(added++, 0,
|
||||||
added += await this._expandRow(newRows, 0);
|
new Zotero.CollectionTreeRow(this, 'library', { libraryID: Zotero.Libraries.userLibraryID }));
|
||||||
|
newRows[0].isOpen = true;
|
||||||
|
added += await this._expandRow(newRows, 0);
|
||||||
|
}
|
||||||
|
|
||||||
// Add groups
|
// Add groups
|
||||||
var groups = Zotero.Groups.getAll();
|
var groups = Zotero.Groups.getAll();
|
||||||
if (groups.length) {
|
groupsIncluded = groups.some(group => this._includedInTree(group));
|
||||||
newRows.splice(added++, 0, new Zotero.CollectionTreeRow(this, 'separator', false));
|
if (groups.length && groupsIncluded) {
|
||||||
|
if (libraryIncluded) {
|
||||||
|
newRows.splice(added++, 0, new Zotero.CollectionTreeRow(this, 'separator', false, 0));
|
||||||
|
}
|
||||||
let groupHeader = new Zotero.CollectionTreeRow(this, 'header', {
|
let groupHeader = new Zotero.CollectionTreeRow(this, 'header', {
|
||||||
id: "group-libraries-header",
|
id: "group-libraries-header",
|
||||||
label: Zotero.getString('pane.collections.groupLibraries'),
|
label: Zotero.getString('pane.collections.groupLibraries'),
|
||||||
|
@ -417,6 +486,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
});
|
});
|
||||||
newRows.splice(added++, 0, groupHeader);
|
newRows.splice(added++, 0, groupHeader);
|
||||||
for (let group of groups) {
|
for (let group of groups) {
|
||||||
|
if (!this._includedInTree(group)) continue;
|
||||||
newRows.splice(added++, 0,
|
newRows.splice(added++, 0,
|
||||||
new Zotero.CollectionTreeRow(this, 'group', group, 1),
|
new Zotero.CollectionTreeRow(this, 'group', group, 1),
|
||||||
);
|
);
|
||||||
|
@ -424,29 +494,39 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add feeds
|
let feeds = {
|
||||||
if (this.hideSources.indexOf('feeds') == -1 && Zotero.Feeds.haveFeeds()) {
|
get unreadCount() {
|
||||||
newRows.splice(added++, 0,
|
return Zotero.Feeds.totalUnreadCount();
|
||||||
new Zotero.CollectionTreeRow(this, 'separator', false),
|
},
|
||||||
);
|
|
||||||
newRows.splice(added++, 0,
|
|
||||||
new Zotero.CollectionTreeRow(this, 'feeds', {
|
|
||||||
get unreadCount() {
|
|
||||||
return Zotero.Feeds.totalUnreadCount();
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateFeed() {
|
async updateFeed() {
|
||||||
for (let feed of Zotero.Feeds.getAll()) {
|
for (let feed of Zotero.Feeds.getAll()) {
|
||||||
await feed.updateFeed();
|
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, 'feeds', feeds)
|
||||||
);
|
);
|
||||||
added += await this._expandRow(newRows, added - 1);
|
added += await this._expandRow(newRows, added - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.selection.selectEventsSuppressed = true;
|
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._rows = newRows;
|
||||||
this._refreshRowMap();
|
this._refreshRowMap();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -465,7 +545,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
this.tree.invalidate();
|
this.tree.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
async selectByID(id) {
|
async selectByID(id, ensureRowVisible = true) {
|
||||||
var type = id[0];
|
var type = id[0];
|
||||||
id = parseInt(('' + id).substr(1));
|
id = parseInt(('' + id).substr(1));
|
||||||
|
|
||||||
|
@ -490,10 +570,12 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
}
|
}
|
||||||
|
|
||||||
var row = this.getRowIndexByID(type + id);
|
var row = this.getRowIndexByID(type + id);
|
||||||
if (!row) {
|
if (row === false) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this.ensureRowIsVisible(row);
|
if (ensureRowVisible) {
|
||||||
|
this.ensureRowIsVisible(row);
|
||||||
|
}
|
||||||
await this.selectWait(row);
|
await this.selectWait(row);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
@ -664,6 +746,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
if (action == 'delete') {
|
if (action == 'delete') {
|
||||||
let selectedIndex = this.selection.focused;
|
let selectedIndex = this.selection.focused;
|
||||||
let feedDeleted = false;
|
let feedDeleted = false;
|
||||||
|
var offset = 0;
|
||||||
|
|
||||||
// Since a delete involves shifting of rows, we have to do it in reverse order
|
// Since a delete involves shifting of rows, we have to do it in reverse order
|
||||||
let rows = [];
|
let rows = [];
|
||||||
|
@ -672,6 +755,9 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'collection':
|
case 'collection':
|
||||||
if (this._rowMap['C' + id] !== undefined) {
|
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]);
|
rows.push(this._rowMap['C' + id]);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
@ -714,8 +800,12 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
this._removeRow(row - 1);
|
this._removeRow(row - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// If there's an active filter, we can have a child matching filter be deleted
|
||||||
this._selectAfterRowRemoval(selectedIndex);
|
// 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') {
|
else if (action == 'modify') {
|
||||||
let row;
|
let row;
|
||||||
|
@ -723,6 +813,20 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
let rowID = "C" + id;
|
let rowID = "C" + id;
|
||||||
let selectedIndex = this.selection.focused;
|
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) {
|
switch (type) {
|
||||||
case 'collection':
|
case 'collection':
|
||||||
let collection = Zotero.Collections.get(id);
|
let collection = Zotero.Collections.get(id);
|
||||||
|
@ -765,6 +869,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
this.tree.invalidateRow(parentRow);
|
this.tree.invalidateRow(parentRow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await handleFocusDuringSearch('collection');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'search':
|
case 'search':
|
||||||
|
@ -802,6 +907,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await handleFocusDuringSearch('search');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'feed':
|
case 'feed':
|
||||||
|
@ -854,6 +960,19 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
break;
|
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') {
|
else if (action == 'refresh' && type == 'trash') {
|
||||||
// We need to update the trash's status (full or empty), and if empty,
|
// 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 = [];
|
var rows = [];
|
||||||
for (let id of ids) {
|
for (let id of ids) {
|
||||||
let row = this._rowMap[id];
|
let row = this._rowMap[id];
|
||||||
this._highlightedRows.add(id);
|
if (row) {
|
||||||
rows.push(row);
|
this._highlightedRows.add(id);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
rows.sort();
|
rows.sort();
|
||||||
// Select first collection
|
// Select first collection
|
||||||
|
@ -918,7 +1039,9 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
Zotero.debug("Cannot expand to nonexistent collection " + collectionID, 2);
|
Zotero.debug("Cannot expand to nonexistent collection " + collectionID, 2);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!this._includedInTree(col)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Open library if closed
|
// Open library if closed
|
||||||
var libraryRow = this._rowMap['L' + col.libraryID];
|
var libraryRow = this._rowMap['L' + col.libraryID];
|
||||||
if (!this.isContainerOpen(libraryRow)) {
|
if (!this.isContainerOpen(libraryRow)) {
|
||||||
|
@ -1124,6 +1247,13 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
}
|
}
|
||||||
|
|
||||||
getRowString(index) {
|
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();
|
return this.getRow(index).getName();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2199,6 +2329,275 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
Zotero.Prefs.set("sourceList.persist", JSON.stringify(state));
|
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) {
|
async _expandRow(rows, row, forceOpen) {
|
||||||
var treeRow = rows[row];
|
var treeRow = rows[row];
|
||||||
var level = rows[row].level;
|
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++) {
|
for (var i = 0, len = collections.length; i < len; i++) {
|
||||||
// Skip collections in trash
|
// Skip collections in trash
|
||||||
if (collections[i].deleted) continue;
|
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;
|
let beforeRow = row + 1 + newRows;
|
||||||
rows.splice(beforeRow, 0,
|
rows.splice(beforeRow, 0,
|
||||||
new Zotero.CollectionTreeRow(this, isFeeds ? 'feed' : 'collection', collections[i], level + 1));
|
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++) {
|
for (var i = 0, len = savedSearches.length; i < len; i++) {
|
||||||
// Skip searches in trash
|
// Skip searches in trash
|
||||||
if (savedSearches[i].deleted) continue;
|
if (savedSearches[i].deleted) continue;
|
||||||
|
// Skip searches not matching the filter
|
||||||
|
if (!this._includedInTree(savedSearches[i])) continue;
|
||||||
rows.splice(row + 1 + newRows, 0,
|
rows.splice(row + 1 + newRows, 0,
|
||||||
new Zotero.CollectionTreeRow(this, 'search', savedSearches[i], level + 1));
|
new Zotero.CollectionTreeRow(this, 'search', savedSearches[i], level + 1));
|
||||||
newRows++;
|
newRows++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showPublications) {
|
if (showPublications && this._isFilterEmpty()) {
|
||||||
// Add "My Publications"
|
// Add "My Publications"
|
||||||
rows.splice(row + 1 + newRows, 0,
|
rows.splice(row + 1 + newRows, 0,
|
||||||
new Zotero.CollectionTreeRow(this,
|
new Zotero.CollectionTreeRow(this,
|
||||||
|
@ -2299,7 +2700,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Duplicate items
|
// Duplicate items
|
||||||
if (showDuplicates) {
|
if (showDuplicates && this._isFilterEmpty()) {
|
||||||
let d = new Zotero.Duplicates(libraryID);
|
let d = new Zotero.Duplicates(libraryID);
|
||||||
rows.splice(row + 1 + newRows, 0,
|
rows.splice(row + 1 + newRows, 0,
|
||||||
new Zotero.CollectionTreeRow(this, 'duplicates', d, level + 1));
|
new Zotero.CollectionTreeRow(this, 'duplicates', d, level + 1));
|
||||||
|
@ -2307,7 +2708,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unfiled items
|
// Unfiled items
|
||||||
if (showUnfiled) {
|
if (showUnfiled && this._isFilterEmpty()) {
|
||||||
let s = new Zotero.Search;
|
let s = new Zotero.Search;
|
||||||
s.libraryID = libraryID;
|
s.libraryID = libraryID;
|
||||||
s.name = Zotero.getString('pane.collections.unfiled');
|
s.name = Zotero.getString('pane.collections.unfiled');
|
||||||
|
@ -2319,7 +2720,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retracted items
|
// Retracted items
|
||||||
if (showRetracted) {
|
if (showRetracted && this._isFilterEmpty()) {
|
||||||
let s = new Zotero.Search;
|
let s = new Zotero.Search;
|
||||||
s.libraryID = libraryID;
|
s.libraryID = libraryID;
|
||||||
s.name = Zotero.getString('pane.collections.retracted');
|
s.name = Zotero.getString('pane.collections.retracted');
|
||||||
|
@ -2330,7 +2731,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
newRows++;
|
newRows++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showTrash) {
|
if (showTrash && this._isFilterEmpty()) {
|
||||||
let deletedItems = await Zotero.Items.getDeleted(libraryID, true);
|
let deletedItems = await Zotero.Items.getDeleted(libraryID, true);
|
||||||
if (deletedItems.length || Zotero.Prefs.get("showTrashWhenEmpty")) {
|
if (deletedItems.length || Zotero.Prefs.get("showTrashWhenEmpty")) {
|
||||||
var ref = {
|
var ref = {
|
||||||
|
@ -2428,10 +2829,12 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._addRow(
|
if (this._includedInTree(collection)) {
|
||||||
new Zotero.CollectionTreeRow(this, 'collection', collection, level),
|
this._addRow(
|
||||||
beforeRow
|
new Zotero.CollectionTreeRow(this, 'collection', collection, level),
|
||||||
);
|
beforeRow
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (objectType == 'search') {
|
else if (objectType == 'search') {
|
||||||
let search = Zotero.Searches.get(id);
|
let search = Zotero.Searches.get(id);
|
||||||
|
@ -2467,10 +2870,12 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._addRow(
|
if (this._includedInTree(search)) {
|
||||||
new Zotero.CollectionTreeRow(this, 'search', search, level),
|
this._addRow(
|
||||||
beforeRow
|
new Zotero.CollectionTreeRow(this, 'search', search, level),
|
||||||
);
|
beforeRow
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return beforeRow;
|
return beforeRow;
|
||||||
|
|
|
@ -259,17 +259,46 @@ var ZoteroPane = new function()
|
||||||
moveFocus(actionsMap, event, true);
|
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) => {
|
collectionTreeToolbar.addEventListener("keydown", (event) => {
|
||||||
let actionsMap = {
|
let actionsMap = {
|
||||||
'zotero-tb-collection-add': {
|
'zotero-tb-collection-add': {
|
||||||
ArrowRight: () => null,
|
ArrowRight: () => null,
|
||||||
ArrowLeft: () => 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')
|
ShiftTab: () => document.getElementById('zotero-tb-sync')
|
||||||
},
|
},
|
||||||
'zotero-collections-search': {
|
'zotero-collections-search': {
|
||||||
Tab: () => document.getElementById('zotero-tb-add'),
|
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);
|
moveFocus(actionsMap, event, true);
|
||||||
|
@ -291,7 +320,7 @@ var ZoteroPane = new function()
|
||||||
if (collectionsPane.getAttribute("collapsed")) {
|
if (collectionsPane.getAttribute("collapsed")) {
|
||||||
return document.getElementById('zotero-tb-sync');
|
return document.getElementById('zotero-tb-sync');
|
||||||
}
|
}
|
||||||
document.getElementById('zotero-tb-collection-search').click();
|
document.getElementById('zotero-tb-collections-search').click();
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
' ': () => {
|
' ': () => {
|
||||||
|
@ -305,7 +334,7 @@ var ZoteroPane = new function()
|
||||||
ArrowRight: () => document.getElementById("zotero-tb-attachment-add"),
|
ArrowRight: () => document.getElementById("zotero-tb-attachment-add"),
|
||||||
ArrowLeft: () => document.getElementById("zotero-tb-add"),
|
ArrowLeft: () => document.getElementById("zotero-tb-add"),
|
||||||
Tab: () => document.getElementById("zotero-tb-search")._searchModePopup.flattenedTreeParentNode.focus(),
|
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),
|
Enter: () => Zotero_Lookup.showPanel(event.target),
|
||||||
' ': () => 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"),
|
ArrowRight: () => document.getElementById("zotero-tb-note-add"),
|
||||||
ArrowLeft: () => document.getElementById("zotero-tb-lookup"),
|
ArrowLeft: () => document.getElementById("zotero-tb-lookup"),
|
||||||
Tab: () => document.getElementById("zotero-tb-search")._searchModePopup.flattenedTreeParentNode.focus(),
|
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': {
|
'zotero-tb-note-add': {
|
||||||
ArrowRight: () => null,
|
ArrowRight: () => null,
|
||||||
ArrowLeft: () => document.getElementById("zotero-tb-attachment-add"),
|
ArrowLeft: () => document.getElementById("zotero-tb-attachment-add"),
|
||||||
Tab: () => document.getElementById("zotero-tb-search")._searchModePopup.flattenedTreeParentNode.focus(),
|
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': {
|
'zotero-tb-search-textbox': {
|
||||||
ShiftTab: () => {
|
ShiftTab: () => {
|
||||||
|
@ -348,6 +377,9 @@ var ZoteroPane = new function()
|
||||||
// If tag selector is collapsed, go to itemTree, otherwise
|
// If tag selector is collapsed, go to itemTree, otherwise
|
||||||
// default to focusing on tag selector
|
// default to focusing on tag selector
|
||||||
return false;
|
return false;
|
||||||
|
},
|
||||||
|
Escape: () => {
|
||||||
|
clearCollectionSearch(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -424,6 +456,7 @@ var ZoteroPane = new function()
|
||||||
ZoteroContextPane.init();
|
ZoteroContextPane.init();
|
||||||
await ZoteroPane.initCollectionsTree();
|
await ZoteroPane.initCollectionsTree();
|
||||||
await ZoteroPane.initItemsTree();
|
await ZoteroPane.initItemsTree();
|
||||||
|
ZoteroPane.initCollectionTreeSearch();
|
||||||
|
|
||||||
// Add a default progress window
|
// Add a default progress window
|
||||||
ZoteroPane.progressWindow = new Zotero.ProgressWindow({ window });
|
ZoteroPane.progressWindow = new Zotero.ProgressWindow({ window });
|
||||||
|
@ -1026,32 +1059,36 @@ var ZoteroPane = new function()
|
||||||
ZoteroPane_Local.collectionsView.setHighlightedRows();
|
ZoteroPane_Local.collectionsView.setHighlightedRows();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.revealCollectionSearch = function () {
|
this.hideCollectionSearch = function () {
|
||||||
let collectionSearchField = document.getElementById("zotero-collections-search");
|
let collectionSearchField = document.getElementById("zotero-collections-search");
|
||||||
let collectionSearchButton = document.getElementById("zotero-tb-collection-search");
|
let collectionSearchButton = document.getElementById("zotero-tb-collections-search");
|
||||||
var hideIfEmpty = function () {
|
if (!collectionSearchField.value.length && collectionSearchField.classList.contains("visible")) {
|
||||||
if (!collectionSearchField.value.length) {
|
collectionSearchField.classList.remove("visible");
|
||||||
collectionSearchField.classList.remove("visible");
|
collectionSearchButton.style.display = '';
|
||||||
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;
|
||||||
}
|
}
|
||||||
};
|
collectionSearchField.focus();
|
||||||
if (!collectionSearchField.classList.contains("visible")) {
|
});
|
||||||
collectionSearchButton.style.display = 'none';
|
|
||||||
collectionSearchField.classList.add("visible");
|
|
||||||
collectionSearchField.addEventListener('blur', hideIfEmpty);
|
|
||||||
}
|
|
||||||
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) {
|
function handleKeyUp(event) {
|
||||||
if ((Zotero.isWin && event.keyCode == 17) ||
|
if ((Zotero.isWin && event.keyCode == 17) ||
|
||||||
|
@ -2723,6 +2760,13 @@ var ZoteroPane = new function()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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) {
|
this.handleSearchInput = function (textbox, event) {
|
||||||
if (textbox.searchTextbox.value.indexOf('"') != -1) {
|
if (textbox.searchTextbox.value.indexOf('"') != -1) {
|
||||||
this.setItemsPaneMessage(Zotero.getString('advancedSearchMode'));
|
this.setItemsPaneMessage(Zotero.getString('advancedSearchMode'));
|
||||||
|
|
|
@ -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"/>
|
<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>
|
<spacer flex="1"></spacer>
|
||||||
<html:div style="display: flex;flex-direction: row;">
|
<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
|
<search-textbox
|
||||||
id="zotero-collections-search" class="hidden"
|
id="zotero-collections-search" class="hidden" disabled="true"
|
||||||
onkeydown="ZoteroPane.handleCollectionSearchKeypress(this, event)"
|
oncommand="ZoteroPane.handleCollectionSearchInput()"/>
|
||||||
oncommand="ZoteroPane.handleCollectionSearchInput(this, event)"/>
|
|
||||||
</html:div>
|
</html:div>
|
||||||
</hbox>
|
</hbox>
|
||||||
</toolbar>
|
</toolbar>
|
||||||
|
|
|
@ -207,7 +207,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
#zotero-collections-toolbar {
|
#zotero-collections-toolbar {
|
||||||
margin-inline-end: 10px; /* Set to width of splitter for visual aesthetics */
|
|
||||||
padding-inline-start: 2px;
|
padding-inline-start: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -282,11 +281,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
#zotero-collections-search {
|
#zotero-collections-search {
|
||||||
height: 25px;
|
|
||||||
max-width: 0;
|
max-width: 0;
|
||||||
display: inline-block;
|
transition: max-width 0.5s ease;
|
||||||
overflow: hidden;
|
|
||||||
transition: max-width 0.7s ease;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
@ -294,9 +290,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
#zotero-collections-search.visible {
|
#zotero-collections-search.visible {
|
||||||
max-width: 200px;
|
max-width: 180px;
|
||||||
appearance: auto;
|
appearance: auto;
|
||||||
visibility: visible;
|
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 */
|
/* Hide collection search on pane collapse, otherwise it still shows when focused */
|
||||||
|
|
|
@ -1374,4 +1374,200 @@ describe("Zotero.CollectionTree", function() {
|
||||||
assert.lengthOf(win.document.querySelectorAll('#zotero-collections-tree .row.unread'), 2);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
})
|
})
|
||||||
|
|
|
@ -1531,6 +1531,10 @@ describe("ZoteroPane", function() {
|
||||||
|
|
||||||
for (let id of sequence) {
|
for (let id of sequence) {
|
||||||
doc.activeElement.dispatchEvent(shiftTab);
|
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);
|
assert.equal(doc.activeElement.id, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1555,6 +1559,10 @@ describe("ZoteroPane", function() {
|
||||||
];
|
];
|
||||||
for (let id of sequence) {
|
for (let id of sequence) {
|
||||||
doc.activeElement.dispatchEvent(tab);
|
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);
|
assert.equal(doc.activeElement.id, id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Reference in a new issue