Closes #1644, Allow selecting multiple items with zotero://select

This adds selectItems() to ZoteroPane and collectionTreeView and removes
the ancient, unused 'expand' argument to selectItem(), which didn't
really make sense there. It also includes a new
itemTreeView::ensureRowsAreVisible() that tries to scroll to an
appropriate place (or, better yet, not scroll at all) given the
specified rows and page size.
This commit is contained in:
Dan Stillman 2019-02-23 17:28:44 -05:00
parent 4d3625f101
commit 945c413c42
8 changed files with 331 additions and 185 deletions

View file

@ -97,9 +97,7 @@ Zotero.API = {
}
var s = new Zotero.Search;
if (params.libraryID !== undefined) {
s.addCondition('libraryID', 'is', params.libraryID);
}
s.libraryID = params.libraryID;
if (params.objectKey) {
s.addCondition('key', 'is', params.objectKey);
@ -107,11 +105,12 @@ Zotero.API = {
else if (params.objectID) {
s.addCondition('itemID', 'is', params.objectID);
}
if (params.itemKey) {
for (let i=0; i<params.itemKey.length; i++) {
let itemKey = params.itemKey[i];
s.addCondition('key', 'is', itemKey);
// For performance reasons, add requested item keys instead of filtering after.
// This will need to be changed if other conditions are added to the search.
else if (params.itemKey) {
s.addCondition('joinMode', 'any');
for (let key of params.itemKey) {
s.addCondition('key', 'is', key);
}
}
@ -123,20 +122,19 @@ Zotero.API = {
var ids = yield s.search();
}
let itemKeys = new Set(params.itemKey || []);
if (results) {
// Filter results by item key
if (params.itemKey) {
results = results.filter(function (result) {
return params.itemKey.indexOf(result.key) !== -1;
});
results = results.filter(key => itemKeys.has(key));
}
}
else if (ids) {
// Filter results by item key
if (params.itemKey) {
ids = ids.filter(function (id) {
ids = ids.filter((id) => {
var {libraryID, key} = Zotero.Items.getLibraryAndKeyFromID(id);
return params.itemKey.indexOf(key) !== -1;
return itemKeys.has(key);
});
}
results = yield Zotero.Items.getAsync(ids);

View file

@ -1164,57 +1164,75 @@ Zotero.CollectionTreeView.prototype.selectTrash = Zotero.Promise.coroutine(funct
/**
* Find item in current collection, or, if not there, in a library root, and select it
* Find an item in the current collection, or, if not there, in a library root, and select it
*
* @param {Integer} itemID
* @param {Integer} - itemID
* @param {Boolean} [inLibraryRoot=false] - Always show in library root
* @param {Boolean} [expand=false] - Open item if it's a container
* @return {Boolean} - True if item was found, false if not
* @return {Boolean} - TRUE if the item was selected, FALSE if not
*/
Zotero.CollectionTreeView.prototype.selectItem = Zotero.Promise.coroutine(function* (itemID, inLibraryRoot, expand) {
if (!itemID) {
return false;
Zotero.CollectionTreeView.prototype.selectItem = async function (itemID, inLibraryRoot) {
return !!(await this.selectItems([itemID], inLibraryRoot));
};
/**
* Find items in current collection, or, if not there, in a library root, and select them
*
* @param {Integer[]} itemIDs
* @param {Boolean} [inLibraryRoot=false] - Always show in library root
* @return {Integer} - The number of items selected
*/
Zotero.CollectionTreeView.prototype.selectItems = async function (itemIDs, inLibraryRoot) {
if (!itemIDs.length) {
return 0;
}
var item = yield Zotero.Items.getAsync(itemID);
if (!item) {
return false;
var items = await Zotero.Items.getAsync(itemIDs);
if (!items.length) {
return 0;
}
yield this.waitForLoad();
await this.waitForLoad();
// Check if items from multiple libraries were specified
if (items.length > 1 && new Set(items.map(item => item.libraryID)).size > 1) {
Zotero.debug("Can't select items in multiple libraries", 2);
return 0;
}
var currentLibraryID = this.getSelectedLibraryID();
var libraryID = items[0].libraryID;
// If in a different library
if (item.libraryID != currentLibraryID) {
if (libraryID != currentLibraryID) {
Zotero.debug("Library ID differs; switching library");
yield this.selectLibrary(item.libraryID);
await this.selectLibrary(libraryID);
}
// Force switch to library view
else if (!this.selectedTreeRow.isLibrary() && inLibraryRoot) {
Zotero.debug("Told to select in library; switching to library");
yield this.selectLibrary(item.libraryID);
await this.selectLibrary(libraryID);
}
yield this.itemTreeView.waitForLoad();
await this.itemTreeView.waitForLoad();
var selected = yield this.itemTreeView.selectItem(itemID, expand);
if (selected) {
return true;
var numSelected = await this.itemTreeView.selectItems(itemIDs);
if (numSelected == items.length) {
return numSelected;
}
if (item.deleted) {
// If there's a single item and it's in the trash, switch to that
if (items.length == 1 && items[0].deleted) {
Zotero.debug("Item is deleted; switching to trash");
yield this.selectTrash(item.libraryID);
await this.selectTrash(libraryID);
}
else {
Zotero.debug("Item was not selected; switching to library");
yield this.selectLibrary(item.libraryID);
await this.selectLibrary(libraryID);
}
yield this.itemTreeView.waitForLoad();
return this.itemTreeView.selectItem(itemID, expand);
});
await this.itemTreeView.waitForLoad();
return this.itemTreeView.selectItems(itemIDs);
};
/*

View file

@ -259,10 +259,9 @@ Zotero.ItemTreeView.prototype.setTree = async function (treebox) {
this._updateIntroText();
if (this.collectionTreeRow && this.collectionTreeRow.itemToSelect) {
var item = this.collectionTreeRow.itemToSelect;
await this.selectItem(item['id'], item['expand']);
this.collectionTreeRow.itemToSelect = null;
if (this.collectionTreeRow && this.collectionTreeRow.itemsToSelect) {
await this.selectItems(this.collectionTreeRow.itemsToSelect);
this.collectionTreeRow.itemsToSelect = null;
}
Zotero.debug("Set tree for items view " + this.id + " in " + (Date.now() - start) + " ms");
@ -1804,165 +1803,203 @@ Zotero.ItemTreeView.prototype._updateIntroText = function() {
/*
* Select an item
*/
Zotero.ItemTreeView.prototype.selectItem = Zotero.Promise.coroutine(function* (id, expand, noRecurse) {
Zotero.ItemTreeView.prototype.selectItem = async function (id) {
return this.selectItems([id]);
};
Zotero.ItemTreeView.prototype.selectItems = async function (ids, noRecurse) {
if (!ids.length) return 0;
// If no row map, we're probably in the process of switching collections,
// so store the item to select on the item group for later
// so store the items to select on the item group for later
if (!this._rowMap) {
if (this.collectionTreeRow) {
this.collectionTreeRow.itemToSelect = { id: id, expand: expand };
Zotero.debug("_rowMap not yet set; not selecting item");
return false;
this.collectionTreeRow.itemsToSelect = ids;
Zotero.debug("_rowMap not yet set; not selecting items");
return 0;
}
Zotero.debug('Item group not found and no row map in ItemTreeView.selectItem() -- discarding select', 2);
return false;
return 0;
}
var row = this._rowMap[id];
// Get the row of the parent, if there is one
var parentRow = null;
var item = yield Zotero.Items.getAsync(id);
// Can't select a deleted item if we're not in the trash
if (item.deleted && !this.collectionTreeRow.isTrash()) {
return false;
}
var parent = item.parentItemID;
if (parent && this._rowMap[parent] != undefined) {
parentRow = this._rowMap[parent];
}
var selected = this.getSelectedItems(true);
if (selected.length == 1 && selected[0] == id) {
Zotero.debug("Item " + id + " is already selected");
this.betterEnsureRowIsVisible(row, parentRow);
return true;
}
// If row with id not visible, check to see if it's hidden under a parent
if(row == undefined)
{
if (!parent || parentRow === null) {
// No parent -- it's not here
// Clear the quick search and tag selection and try again (once)
if (!noRecurse && this.window.ZoteroPane) {
let cleared1 = yield this.window.ZoteroPane.clearQuicksearch();
let cleared2 = this.window.ZoteroPane.tagSelector.clearTagSelection();
if (cleared1 || cleared2) {
return this.selectItem(id, expand, true);
var idsToSelect = [];
for (let id of ids) {
let row = this._rowMap[id];
let item = Zotero.Items.get(id);
// Can't select a deleted item if we're not in the trash
if (item.deleted && !this.collectionTreeRow.isTrash()) {
continue;
}
// Get the row of the parent, if there is one
let parent = item.parentItemID;
let parentRow = parent && this._rowMap[parent];
// If row with id isn't visible, check to see if it's hidden under a parent
if (row == undefined) {
if (!parent || parentRow === undefined) {
// No parent -- it's not here
// Clear the quick search and tag selection and try again (once)
if (!noRecurse && this.window.ZoteroPane) {
let cleared1 = await this.window.ZoteroPane.clearQuicksearch();
let cleared2 = this.window.ZoteroPane.tagSelector.clearTagSelection();
if (cleared1 || cleared2) {
return this.selectItems(ids, true);
}
}
Zotero.debug(`Couldn't find row for item ${id} -- not selecting`);
continue;
}
Zotero.debug("Could not find row for item; not selecting item");
return false;
// If parent is already open and we haven't found the item, the child
// hasn't yet been added to the view, so close parent to allow refresh
this._closeContainer(parentRow);
// Open the parent
this.toggleOpenState(parentRow);
}
// If parent is already open and we haven't found the item, the child
// hasn't yet been added to the view, so close parent to allow refresh
this._closeContainer(parentRow);
// Open the parent
this.toggleOpenState(parentRow);
row = this._rowMap[id];
// Since we're opening containers, we still need to reference by id
idsToSelect.push(id);
}
// this.selection.select() triggers the <tree>'s 'onselect' attribute, which calls
// ZoteroPane.itemSelected(), which calls ZoteroItemPane.viewItem(), which refreshes the
// itembox. But since the 'onselect' doesn't handle promises, itemSelected() isn't waited for
// here, which means that 'yield selectItem(itemID)' continues before the itembox has been
// refreshed. To get around this, we wait for a select event that's triggered by
// itemSelected() when it's done.
let promise;
try {
if (this.selection.selectEventsSuppressed) {
this.selection.select(row);
// Now that all items have been expanded, get associated rows
var rowsToSelect = [];
for (let id of idsToSelect) {
let row = this._rowMap[id];
rowsToSelect.push(row);
}
// If items are already selected, just scroll to the top-most one
var selectedRows = new Set(this.getSelectedRowIndexes());
if (rowsToSelect.length == selectedRows.size && rowsToSelect.every(row => selectedRows.has(row))) {
this.ensureRowsAreVisible(rowsToSelect);
return rowsToSelect.length;
}
// Single item
if (rowsToSelect.length == 1) {
// this.selection.select() triggers the <tree>'s 'onselect' attribute, which calls
// ZoteroPane.itemSelected(), which calls ZoteroItemPane.viewItem(), which refreshes the
// itembox. But since the 'onselect' doesn't handle promises, itemSelected() isn't waited for
// here, which means that 'yield selectItem(itemID)' continues before the itembox has been
// refreshed. To get around this, we wait for a select event that's triggered by
// itemSelected() when it's done.
let promise;
try {
if (!this.selection.selectEventsSuppressed) {
promise = this.waitForSelect();
}
this.selection.select(rowsToSelect[0]);
}
else {
promise = this.waitForSelect();
this.selection.select(row);
// Ignore NS_ERROR_UNEXPECTED from nsITreeSelection::select(), apparently when the tree
// disappears before it's called (though I can't reproduce it):
//
// https://forums.zotero.org/discussion/comment/297039/#Comment_297039
catch (e) {
Zotero.logError(e);
}
// If |expand|, open row if container
if (expand && this.isContainer(row) && !this.isContainerOpen(row)) {
this.toggleOpenState(row);
if (promise) {
await promise;
}
this.selection.select(row);
}
// Ignore NS_ERROR_UNEXPECTED from nsITreeSelection::select(), apparently when the tree
// disappears before it's called (though I can't reproduce it):
//
// https://forums.zotero.org/discussion/comment/297039/#Comment_297039
catch (e) {
Zotero.logError(e);
// Multiple items
else {
this.selection.clearSelection();
this.selection.selectEventsSuppressed = true;
var lastStart = 0;
for (let i = 0, len = rowsToSelect.length; i < len; i++) {
if (i == len - 1 || rowsToSelect[i + 1] != rowsToSelect[i] + 1) {
this.selection.rangedSelect(rowsToSelect[lastStart], rowsToSelect[i], true);
lastStart = i + 1;
}
}
this.selection.selectEventsSuppressed = false;
}
if (promise) {
yield promise;
}
this.ensureRowsAreVisible(rowsToSelect);
this.betterEnsureRowIsVisible(row, parentRow);
return true;
});
return rowsToSelect.length;
};
/**
* Select multiple top-level items
*
* @param {Integer[]} ids An array of itemIDs
*/
Zotero.ItemTreeView.prototype.selectItems = function(ids) {
if (ids.length == 0) {
Zotero.ItemTreeView.prototype.ensureRowsAreVisible = function (rows) {
const firstVisibleRow = this._treebox.getFirstVisibleRow();
const pageLength = this._treebox.getPageLength();
const lastVisibleRow = firstVisibleRow + pageLength;
const maxBuffer = 5;
var isRowVisible = function (row) {
return row >= firstVisibleRow && row <= lastVisibleRow;
};
rows = rows.concat();
rows.sort((a, b) => a - b);
var rowsWithParents = [];
for (let row of rows) {
let parent = this.getParentIndex(row);
rowsWithParents.push(parent != -1 ? parent : row);
}
// If all rows are visible, don't change anything
if (rows.every(row => isRowVisible(row))) {
//Zotero.debug("All rows are visible");
return;
}
var rows = [];
for (let id of ids) {
if(this._rowMap[id] !== undefined) rows.push(this._rowMap[id]);
}
rows.sort(function (a, b) {
return a - b;
});
this.selection.clearSelection();
this.selection.selectEventsSuppressed = true;
var lastStart = 0;
for (var i = 0, len = rows.length; i < len; i++) {
if (i == len - 1 || rows[i + 1] != rows[i] + 1) {
this.selection.rangedSelect(rows[lastStart], rows[i], true);
lastStart = i + 1;
// If we can fit all parent rows in view, do that
for (let buffer = maxBuffer; buffer >= 0; buffer--) {
if (rowsWithParents[rowsWithParents.length - 1] - rowsWithParents[0] - buffer < pageLength) {
//Zotero.debug(`We can fit all parent rows with buffer ${buffer}`);
this._treebox.scrollToRow(rowsWithParents[0] - buffer);
return;
}
}
this.selection.selectEventsSuppressed = false;
// TODO: This could probably be improved to try to focus more of the selected rows
this.betterEnsureRowIsVisible(rows[0]);
}
Zotero.ItemTreeView.prototype.betterEnsureRowIsVisible = function (row, parentRow = null) {
// We aim for a row 5 below the target row, since ensureRowIsVisible() does
// the bare minimum to get the row in view
for (let v = row + 5; v >= row; v--) {
if (this._rows[v]) {
this._treebox.ensureRowIsVisible(v);
if (this._treebox.getFirstVisibleRow() <= row) {
break;
// If we can fit all rows in view, do that
for (let buffer = maxBuffer; buffer >= 0; buffer--) {
if (rows[rows.length - 1] - rows[0] - buffer < pageLength) {
//Zotero.debug(`We can fit all rows with buffer ${buffer}`);
this._treebox.scrollToRow(rows[0] - buffer);
return;
}
}
// If more than half of the rows are visible, don't change anything
var visible = 0;
for (let row of rows) {
if (isRowVisible(row)) {
visible++;
}
}
if (visible > rows / 2) {
//Zotero.debug("More than half of rows are visible");
return;
}
// If the first parent row isn't in view and we have enough room, make it visible, trying to
// put it five rows from the top
if (rows[0] != rowsWithParents[0]) {
for (let buffer = maxBuffer; buffer >= 0; buffer--) {
if (rows[0] - rowsWithParents[0] - buffer <= pageLength) {
//Zotero.debug(`Scrolling to first parent minus ${buffer}`);
this._treebox.scrollToRow(rowsWithParents[0] - buffer);
return;
}
}
}
// If the parent row isn't in view and we have enough room, make parent visible
if (parentRow !== null && this._treebox.getFirstVisibleRow() > parentRow) {
if ((row - parentRow) < this._treebox.getPageLength()) {
this._treebox.ensureRowIsVisible(parentRow);
}
}
// Otherwise just put the first row at the top
//Zotero.debug("Scrolling to first row " + Math.max(rows[0] - maxBuffer, 0));
this._treebox.scrollToRow(Math.max(rows[0] - maxBuffer, 0));
};

View file

@ -134,6 +134,20 @@ Zotero.LibraryTreeView.prototype = {
},
getSelectedRowIndexes: function () {
var rows = [];
var start = {};
var end = {};
for (let i = 0, len = this.selection.getRangeCount(); i < len; i++) {
this.selection.getRangeAt(i, start, end);
for (let j = start.value; j <= end.value; j++) {
rows.push(j);
}
}
return rows;
},
/**
* Return an object describing the current scroll position to restore after changes
*

View file

@ -2187,13 +2187,21 @@ var ZoteroPane = new function()
});
this.selectItem = Zotero.Promise.coroutine(function* (itemID, inLibraryRoot, expand) {
this.selectItem = async function (itemID, inLibraryRoot) {
if (!itemID) {
return false;
}
return this.selectItems([itemID], inLibraryRoot);
};
this.selectItems = async function (itemIDs, inLibraryRoot) {
if (!itemIDs.length) {
return false;
}
var item = yield Zotero.Items.getAsync(itemID);
if (!item) {
var items = await Zotero.Items.getAsync(itemIDs);
if (!items.length) {
return false;
}
@ -2206,7 +2214,7 @@ var ZoteroPane = new function()
throw new Error("Collections view not loaded");
}
var found = yield this.collectionsView.selectItem(itemID, inLibraryRoot, expand);
var found = await this.collectionsView.selectItems(itemIDs, inLibraryRoot);
// Focus the items pane
if (found) {
@ -2215,7 +2223,7 @@ var ZoteroPane = new function()
// open Zotero pane
this.show();
});
};
this.getSelectedLibraryID = function () {

View file

@ -849,6 +849,12 @@ function ZoteroProtocolHandler() {
router.run(path);
Zotero.API.parseParams(params);
if (!params.objectKey && !params.objectID && !params.itemKey) {
Zotero.debug("No objects specified");
return;
}
var results = yield Zotero.API.getResultsFromParams(params);
if (!results.length) {
@ -868,8 +874,7 @@ function ZoteroProtocolHandler() {
return zp.collectionsView.selectCollection(results[0].id);
}
else {
// TODO: Currently only able to select one item
return zp.selectItem(results[0].id);
return zp.selectItems(results.map(x => x.id));
}
}),

View file

@ -490,7 +490,19 @@ describe("Zotero.CollectionTreeView", function() {
await cv.selectItem(item.id);
await waitForItemsLoad(win);
assert.equal(cv.selection.currentIndex, 0);
assert.sameMembers(zp.itemsView.getSelectedItems(), [item]);
assert.sameMembers(zp.itemsView.getSelectedItems(true), [item.id]);
});
});
describe("#selectItems()", function () {
it("should switch to library root if at least one item isn't in the current collection", async function () {
var collection = await createDataObject('collection');
var item1 = await createDataObject('item', { collections: [collection.id] });
var item2 = await createDataObject('item');
await cv.selectItems([item1.id, item2.id]);
await waitForItemsLoad(win);
assert.equal(cv.selection.currentIndex, 0);
assert.sameMembers(zp.itemsView.getSelectedItems(true), [item1.id, item2.id]);
});
});

View file

@ -1,7 +1,9 @@
"use strict";
describe("Zotero.ItemTreeView", function() {
var win, zp, cv, itemsView, existingItemID;
var win, zp, cv, itemsView;
var existingItemID;
var existingItemID2;
// Load Zotero pane and select library
before(function* () {
@ -9,8 +11,10 @@ describe("Zotero.ItemTreeView", function() {
zp = win.ZoteroPane;
cv = zp.collectionsView;
var item = yield createDataObject('item', { setTitle: true });
existingItemID = item.id;
var item1 = yield createDataObject('item', { setTitle: true });
existingItemID = item1.id;
var item2 = yield createDataObject('item');
existingItemID2 = item2.id;
});
beforeEach(function* () {
yield selectLibrary(win);
@ -30,20 +34,70 @@ describe("Zotero.ItemTreeView", function() {
describe("#selectItem()", function () {
/**
* Make sure that selectItem() doesn't hang if the pane's item-select handler is never
* triggered due to the item already being selected
* Don't hang if the pane's item-select handler is never triggered due to the item already
* being selected
*/
it("should return if item is already selected", function* () {
yield itemsView.selectItem(existingItemID);
it("should return if item is already selected", async function () {
var numSelected = await itemsView.selectItem(existingItemID);
assert.equal(numSelected, 1);
var selected = itemsView.getSelectedItems(true);
assert.lengthOf(selected, 1);
assert.equal(selected[0], existingItemID);
yield itemsView.selectItem(existingItemID);
numSelected = await itemsView.selectItem(existingItemID);
assert.equal(numSelected, 1);
selected = itemsView.getSelectedItems(true);
assert.lengthOf(selected, 1);
assert.equal(selected[0], existingItemID);
});
})
});
describe("#selectItems()", function () {
/**
* Don't hang if the pane's item-select handler is never triggered due to the items already
* being selected
*/
it("should return if all items are already selected", async function () {
var itemIDs = [existingItemID, existingItemID2];
var numSelected = await itemsView.selectItems(itemIDs);
assert.equal(numSelected, 2);
var selected = itemsView.getSelectedItems(true);
assert.lengthOf(selected, 2);
assert.sameMembers(selected, itemIDs);
numSelected = await itemsView.selectItems(itemIDs);
assert.equal(numSelected, 2);
selected = itemsView.getSelectedItems(true);
assert.lengthOf(selected, 2);
assert.sameMembers(selected, itemIDs);
});
it("should expand parent items to select children", async function () {
var item1 = await createDataObject('item');
var item2 = await createDataObject('item');
var item3 = await createDataObject('item');
var note1 = await createDataObject('item', { itemType: 'note', parentID: item1.id });
var note2 = await createDataObject('item', { itemType: 'note', parentID: item2.id });
var note3 = await createDataObject('item', { itemType: 'note', parentID: item3.id });
var toSelect = [note1.id, note2.id, note3.id];
itemsView.collapseAllRows();
var numSelected = await itemsView.selectItems(toSelect);
assert.equal(numSelected, 3);
var selected = itemsView.getSelectedItems(true);
assert.lengthOf(selected, 3);
assert.sameMembers(selected, toSelect);
// Again with the ids given in reverse order
itemsView.collapseAllRows();
toSelect = toSelect.reverse();
var numSelected = await itemsView.selectItems(toSelect);
assert.equal(numSelected, 3);
var selected = itemsView.getSelectedItems(true);
assert.lengthOf(selected, 3);
assert.sameMembers(selected, toSelect);
});
});
describe("#getCellText()", function () {
it("should return new value after edit", function* () {
@ -720,7 +774,7 @@ describe("Zotero.ItemTreeView", function() {
var item3 = yield createDataObject('item', { itemType: 'note', parentID: item1.id });
let view = zp.itemsView;
yield view.selectItem(item3.id, true);
yield view.selectItem(item3.id);
var promise = view.waitForSelect();
@ -759,7 +813,7 @@ describe("Zotero.ItemTreeView", function() {
var item3 = yield createDataObject('item', { itemType: 'note', parentID: item2.id });
let view = zp.itemsView;
yield view.selectItem(item3.id, true);
yield view.selectItem(item3.id);
var promise = view.waitForSelect();