select collection from long list via dialog

If the user has too many collections to comfortably
navigate via context menu, do not render the collection hierarchy
via menus, and instead have the user select the collection
via dialog that has a full collectionTree.

Two hidden prefs determine when the dialog will appear:
- collectionsMenu.maxEntriesTotal (default 5000) - max
collections in specified libraries
- collectionsMenu.maxEntriesAtOneLevel (default 100) - max
collections on any single level (e.g max top level collections)

If there are more collections than maxEntriesTotal or one
collection has more than maxEntriesAtOneLevel children,
the dialog will be used.

It also takes care of some performance issues caused by
trying to render too many collections.

Fixes: #5693
This commit is contained in:
Bogdan Abaev 2026-03-19 15:04:55 -07:00
parent 9a765dd0b9
commit db71a75b13
16 changed files with 673 additions and 60 deletions

View file

@ -1851,6 +1851,8 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
}
else if (dataType == 'zotero/collection') {
// Can drop collections into trash to delete it
if (treeRow.isTrash()) return true;
let draggedCollectionID = data[0];
let draggedCollection = Zotero.Collections.get(draggedCollectionID);
let canMove = await draggedCollection.canMoveToTargetAsync(treeRow.ref, { debug: true });
@ -2687,6 +2689,36 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
}
clearCollectionSearch(searchFieldSelector) {
let collectionsSearchField = document.querySelector(searchFieldSelector);
// If empty filter - just focus the collectionTree
if (collectionsSearchField.value.length == 0) {
return document.getElementById("collection-tree");
}
// Clear the search field and focus collection tree
if (collectionsSearchField.value.length) {
collectionsSearchField.value = '';
this.setFilter("", true);
}
return null;
}
focusCollectionTree(searchFieldSelector) {
let collectionsSearchField = document.querySelector(searchFieldSelector);
// Prevent Enter/Tab pressed before the filtering ran from doing anything
if (!this.filterEquals(collectionsSearchField.value)) {
return null;
}
// If the current row passes the filter, make sure it is visible and focus collectionTree
if (this.focusedRowMatchesFilter()) {
this.ensureRowIsVisible(this.selection.focused);
return document.getElementById('collection-tree');
}
// Otherwise, focus the first row passing the filter
this.focusFirstMatchingRow(false);
return null;
}
/**
* Creates an extra hidden row to keep focus on it when a currently focused row does not match the filter.
@ -2724,17 +2756,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
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.
@ -2940,7 +2961,8 @@ var CollectionTree = class CollectionTree extends LibraryTree {
&& this._virtualCollectionLibraries.duplicates[libraryID] !== false;
var showUnfiled = this.props.hideSources.indexOf('unfiled') == -1
&& this._virtualCollectionLibraries.unfiled?.[libraryID] !== false;
var showRecentlyRead = this._virtualCollectionLibraries.recentlyRead?.[libraryID] !== false;
var showRecentlyRead = this.props.hideSources.indexOf('recentlyRead') == -1
&& this._virtualCollectionLibraries.recentlyRead?.[libraryID] !== false;
var showRetracted = this.props.hideSources.indexOf('retracted') == -1
&& this._virtualCollectionLibraries.retracted?.[libraryID] !== false
&& Zotero.Retractions.libraryHasRetractedItems(libraryID);
@ -2948,6 +2970,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
&& this._virtualCollectionLibraries.publications?.[libraryID] !== false
&& libraryID == Zotero.Libraries.userLibraryID;
var showTrash = this.props.hideSources.indexOf('trash') == -1;
var showSavedSearches = this.props.hideSources.indexOf('searches') == -1;
}
else {
var savedSearches = [];
@ -2957,6 +2980,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
var showRetracted = false;
var showPublications = false;
var showTrash = false;
var showSavedSearches = false;
}
// If not a manual open and either the library is set to be collapsed or this is a collection that isn't explicitly opened,
@ -3013,14 +3037,16 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
// Add searches
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 (showSavedSearches) {
for (let i = 0; i < savedSearches.length; 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 && this._isFilterEmpty()) {

View file

@ -23,6 +23,8 @@
***** END LICENSE BLOCK *****
*/
import CollectionTree from 'zotero/collectionTree';
var Zotero_New_Collection_Dialog = {
_handleLoad() {
let io = window.arguments[0];
@ -34,7 +36,20 @@ var Zotero_New_Collection_Dialog = {
this._libraryID = io.libraryID;
this._parentCollectionID = io.parentCollectionID;
this._updateMenu();
// depending on how many collections there are, display the dropdown
// or the entire collection tree
if (Zotero.Collections.canListCollectionsInMenu(this._libraryID)) {
document.getElementById("create-in").hidden = false;
this._updateMenu();
}
else {
document.getElementById("zotero-collections-tree-container").hidden = false;
setTimeout(() => {
window.moveToAlertPosition();
this._initCollectionTree();
});
}
},
_handleAccept() {
@ -45,6 +60,26 @@ var Zotero_New_Collection_Dialog = {
};
},
async _initCollectionTree() {
this._collectionsView = await CollectionTree.init(document.getElementById('zotero-collections-tree'), {
onSelectionChange: () => {
let selectedCollection = this._collectionsView.getSelectedCollection();
this._parentCollectionID = selectedCollection ? selectedCollection.id : null;
},
filterLibraryIDs: [this._libraryID],
hideSources: ['duplicates', 'trash', 'feeds', 'unfiled', 'retracted', 'publications', 'searches', 'recentlyRead']
});
await this._collectionsView.makeVisible();
if (this._parentCollectionID) {
await this._collectionsView.selectByID('C' + this._parentCollectionID);
}
else {
await this._collectionsView.selectByID('L' + this._libraryID);
}
},
_updateMenu() {
let createInField = document.querySelector('#create-in');
let menupopup = createInField.firstElementChild;

View file

@ -65,9 +65,12 @@
<vbox>
<label data-l10n-id="new-collection-create-in" control="create-in"/>
<menulist id="create-in" native="true">
<menulist id="create-in" native="true" hidden="true">
<menupopup id="zotero-new-collection-menu" />
</menulist>
<vbox id="zotero-collections-tree-container" class="virtualized-table-container" hidden="true">
<html:div id="zotero-collections-tree"/>
</vbox>
</vbox>
</dialog>
</window>

View file

@ -0,0 +1,120 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2009 Center for History and New Media
George Mason University, Fairfax, Virginia, USA
http://zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
import CollectionTree from 'zotero/collectionTree';
var collectionsView;
var loaded;
var io;
var doLoad = async function () {
// Set font size from pref
var sbc = document.getElementById('zotero-select-collection-container');
Zotero.UIProperties.registerRoot(sbc);
io = window.arguments[0];
if (io.wrappedJSObject) io = io.wrappedJSObject;
let filterLibraryIDs = io.filterLibraryIDs || false;
collectionsView = await CollectionTree.init(document.getElementById('zotero-collections-tree'), {
onSelectionChange: async () => {
let selectedRow = collectionsView.getRow(collectionsView.selection.focused);
let canSelect = await io.canSelect(selectedRow.ref);
document.getElementById("select-collection-dialog").getButton("accept").disabled = !canSelect;
},
filterLibraryIDs,
hideSources: ['duplicates', 'trash', 'feeds', 'unfiled', 'retracted', 'publications', 'searches', 'recentlyRead'],
onActivate: () => {
document.querySelector('dialog').acceptDialog();
}
});
await collectionsView.makeVisible();
if (io.currentCollectionID) {
await collectionsView.selectByID('C' + io.currentCollectionID);
}
document.addEventListener('dialogaccept', doAccept);
// Setup keyboard navigation
let collectionTree = document.getElementById('collection-tree');
let searchField = document.getElementById('zotero-collections-search');
searchField.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
_handleCollectionFilterEscape(event);
}
else if (event.key === 'Enter' || (event.key === 'Tab' && !event.shiftKey)) {
_handleCollectionFilterEnter(event);
}
});
collectionTree.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
_handleCollectionFilterEscape(event);
}
});
// Used in tests
loaded = true;
};
function doUnload()
{
collectionsView.unregister();
io.deferred && io.deferred.resolve();
}
function handleCollectionSearchInput() {
let collectionsSearchField = document.getElementById("zotero-collections-search");
collectionsView.setFilter(collectionsSearchField.value);
}
function doAccept() {
let selected = collectionsView.getRow(collectionsView.selection.focused);
io.dataOut = selected.ref;
}
function _handleCollectionFilterEnter(event) {
let result = collectionsView.focusCollectionTree("#zotero-collections-search");
if (result) {
result.focus();
event.preventDefault();
event.stopPropagation();
}
}
function _handleCollectionFilterEscape(event) {
if (!document.getElementById("zotero-collections-search").value) return;
let result = collectionsView.clearCollectionSearch("#zotero-collections-search");
if (result) {
result.focus();
}
event.preventDefault();
event.stopPropagation();
}

View file

@ -0,0 +1,77 @@
<?xml version="1.0"?>
<!--
***** BEGIN LICENSE BLOCK *****
Copyright © 2009 Center for History and New Media
George Mason University, Fairfax, Virginia, USA
http://zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
-->
<?xml-stylesheet href="chrome://global/skin/" type="text/css"?>
<?xml-stylesheet href="chrome://zotero/skin/zotero.css" type="text/css"?>
<?xml-stylesheet href="chrome://zotero/skin/overlay.css" type="text/css"?>
<?xml-stylesheet href="chrome://zotero-platform/content/overlay.css" type="text/css"?>
<?xml-stylesheet href="chrome://zotero-platform/content/zotero.css"?>
<!DOCTYPE window SYSTEM "chrome://zotero/locale/zotero.dtd">
<window
id="zotero-select-collection-dialog"
windowtype="zotero:collection-selector"
xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
xmlns:html="http://www.w3.org/1999/xhtml"
onload="doLoad();"
onunload="doUnload();"
persist="screenX screenY width height"
class="zotero-dialog"
data-l10n-id="select-collection-window"
drawintitlebar-platforms="mac,win"
>
<dialog
id="select-collection-dialog"
orient="vertical"
buttons="cancel,accept"
data-l10n-id="select-collection-dialog"
data-l10n-attrs="buttonlabelaccept"
>
<script>
Services.scriptloader.loadSubScript("chrome://zotero/content/include.js", this);
Services.scriptloader.loadSubScript("chrome://zotero/content/customElements.js", this);
Services.scriptloader.loadSubScript("chrome://zotero/content/selectCollectionDialog.js", this);
Services.scriptloader.loadSubScript("chrome://zotero/content/titlebar.js", this);
</script>
<linkset>
<html:link rel="localization" href="zotero.ftl" />
</linkset>
<vbox id="zotero-select-collection-container" flex="1">
<hbox id="search-toolbar">
<search-textbox
id="zotero-collections-search"
data-l10n-id="zotero-collections-search"
data-l10n-attrs="placeholder"
oncommand="handleCollectionSearchInput()"/>
</hbox>
<vbox id="zotero-collections-tree-container" class="virtualized-table-container">
<html:div id="zotero-collections-tree"/>
</vbox>
</vbox>
</dialog>
</window>

View file

@ -1053,3 +1053,16 @@ Zotero.Collection.prototype.canMoveToTargetAsync = async function (target, optio
}
return true;
};
// Determine which libraries the collection can be copied to
Zotero.Collection.prototype.getLibrariesForCopying = async function () {
let libraries = Zotero.Libraries.getAll().filter(lib => !(lib instanceof Zotero.Feed));
let librariesWhereOneCanCopy = [];
for (let library of libraries) {
let canCopy = await this.canMoveToTargetAsync(library);
if (canCopy) {
librariesWhereOneCanCopy.push(library);
}
}
return librariesWhereOneCanCopy;
};

View file

@ -85,6 +85,41 @@ Zotero.Collections = function () {
return _getByContainer(null, parentCollectionID, recursive, includeTrashed);
}
/**
* Determine if the user has too many collections in a library to list them all in a menu.
* @param {Integer[]} libraryIDs - ids of libraries whose collections will be counted.
* If not provided, will check all libraries.
* @returns {Boolean} true if the number of collections exceeds the limits set in preferences.
*/
this.canListCollectionsInMenu = function (libraryIDs) {
if (!Array.isArray(libraryIDs)) {
libraryIDs = [libraryIDs];
}
let maxEntriesTotal = Zotero.Prefs.get("collectionsMenu.maxEntriesTotal");
let maxEntriesAtOneLevel = Zotero.Prefs.get("collectionsMenu.maxEntriesAtOneLevel");
let totalCollectionsCount = 0;
for (let libraryID of libraryIDs) {
let allCollections = Zotero.Collections.getByLibrary(libraryID, true, false);
totalCollectionsCount += allCollections.length;
// Too many top-level collections in this library
let topLevelCollections = Zotero.Collections.getByLibrary(libraryID);
if (topLevelCollections.length > maxEntriesAtOneLevel) {
return false;
}
// Any single collection has too many children
for (let collection of allCollections) {
if (collection.getChildCollections().length > maxEntriesAtOneLevel) {
return false;
}
}
}
// Too many total collections across all libraries
if (totalCollectionsCount > maxEntriesTotal) {
return false;
}
return true;
};
var _getByContainer = function (libraryID, parentID, recursive, includeTrashed) {
let children = [];

View file

@ -313,33 +313,15 @@ var ZoteroPane = new function () {
moveFocus(actionsMap, event, true);
});
let collectionsSearchField = document.getElementById("zotero-collections-search");
let clearCollectionSearch = () => {
// If empty filter - just focus the collectionTree
if (collectionsSearchField.value.length == 0) {
return document.getElementById("collection-tree");
let result = ZoteroPane.collectionsView.clearCollectionSearch("#zotero-collections-search");
if (result === null) {
ZoteroPane.hideCollectionSearch();
}
// Clear the search field and focus collection tree
if (collectionsSearchField.value.length) {
collectionsSearchField.value = '';
ZoteroPane.collectionsView.setFilter("", true);
}
ZoteroPane.hideCollectionSearch();
return null;
return result;
};
let focusCollectionTree = () => {
// Prevent Enter/Tab 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;
return ZoteroPane.collectionsView.focusCollectionTree("#zotero-collections-search");
};
collectionTreeToolbar.addEventListener("keydown", (event) => {
let actionsMap = {
@ -2664,6 +2646,26 @@ var ZoteroPane = new function () {
await selected.saveTx();
};
this.moveCollectionViaDialog = async function () {
let selected = this.getSelectedCollection();
if (!selected) return;
let io = {
dataIn: null,
dataOut: null,
filterLibraryIDs: [selected.libraryID],
currentCollectionID: selected.id,
canSelect: async (target) => {
return selected.canMoveToTargetAsync(target);
}
};
window.openDialog('chrome://zotero/content/selectCollectionDialog.xhtml', '',
'chrome,modal,centerscreen,resizable=no', io);
if (!io.dataOut) return;
await this.moveCollection(io.dataOut);
};
// Copy selected collection into another collection or library.
// Partially, a replication of drag-drop mechanism from CollectionTree.onDrop.
this.copyCollection = async (target) => {
@ -2694,6 +2696,28 @@ var ZoteroPane = new function () {
});
};
this.copyCollectionViaDialog = async function () {
let selected = this.getSelectedCollection();
if (!selected) return;
let librariesWhereOneCanCopy = await selected.getLibrariesForCopying();
let io = {
dataIn: null,
dataOut: null,
filterLibraryIDs: librariesWhereOneCanCopy.map(lib => lib.libraryID),
currentCollectionID: selected.id,
canSelect: async (collection) => {
return selected.canMoveToTargetAsync(collection);
}
};
window.openDialog('chrome://zotero/content/selectCollectionDialog.xhtml', '',
'chrome,modal,centerscreen,resizable=no', io);
if (!io.dataOut) return;
await this.copyCollection(io.dataOut);
};
this.toggleSelectedItemsRead = async function () {
await Zotero.FeedItems.toggleReadByID(this.getSelectedItems(true));
};
@ -3367,9 +3391,17 @@ var ZoteroPane = new function () {
{
id: "moveCollection",
},
{
id: "moveCollectionDialog",
oncommand: () => this.moveCollectionViaDialog()
},
{
id: "copyCollection"
},
{
id: "copyCollectionDialog",
oncommand: () => this.copyCollectionViaDialog()
},
{
id: "duplicate",
oncommand: () => this.duplicateSelectedCollection()
@ -3488,8 +3520,6 @@ var ZoteroPane = new function () {
'newSubcollection',
'sep2',
'editSelectedCollection',
'moveCollection',
'copyCollection',
'deleteCollection',
'deleteCollectionAndItems',
'sep3',
@ -3497,6 +3527,16 @@ var ZoteroPane = new function () {
'createBibCollection',
'loadReport'
];
// Decide if collections can be listed as menuitems for move/copy menus
// or (if there are too many collections), collections will be selected in the dialog
let librariesWhereOneCanCopy = await collectionTreeRow.ref.getLibrariesForCopying();
if (Zotero.Collections.canListCollectionsInMenu(librariesWhereOneCanCopy.map(lib => lib.libraryID))) {
show.push('moveCollection', 'copyCollection');
}
else {
show.push('moveCollectionDialog', 'copyCollectionDialog');
}
if (!this.itemsView.rowCount) {
disable = ['createBibCollection', 'loadReport'];
@ -3729,6 +3769,7 @@ var ZoteroPane = new function () {
'toggleRead',
'changeParentItem',
'addToCollection',
'addToCollectionDialog',
'removeItems',
'duplicateAndConvert',
'duplicateItem',
@ -4140,8 +4181,15 @@ var ZoteroPane = new function () {
&& collectionTreeRow.editable
&& Zotero.Items.keepTopLevel(items).every(item => item.isTopLevelItem())
) {
menu.childNodes[m.addToCollection].setAttribute('label', Zotero.getString('pane.items.menu.addToCollection'));
show.add(m.addToCollection);
// List collections if possible. If there are too many of them, collection
// will be selected in the dialog.
if (Zotero.Collections.canListCollectionsInMenu([items[0].libraryID])) {
menu.childNodes[m.addToCollection].setAttribute('label', Zotero.getString('pane.items.menu.addToCollection'));
show.add(m.addToCollection);
}
else {
show.add(m.addToCollectionDialog);
}
}
// Remove from collection
@ -4316,6 +4364,20 @@ var ZoteroPane = new function () {
// Build menus for all libraries (or collections)
for (let obj of topLevelEntries) {
// For libraries that cannot be copied to, just add a disabled stub
if (obj instanceof Zotero.Library) {
let canCopy = await selected.canMoveToTargetAsync(obj);
if (!canCopy) {
let menuItem = document.createXULElement("menu");
menuItem.setAttribute("label", obj.name);
menuItem.setAttribute("image", obj.treeViewImage);
menuItem.setAttribute("value", obj.treeViewID);
menuItem.classList.add('menu-iconic');
menuItem.disabled = true;
popup.append(menuItem);
continue;
}
}
let menuItem = Zotero.Utilities.Internal.createMenuForTarget(
obj,
popup,
@ -4330,17 +4392,6 @@ var ZoteroPane = new function () {
);
popup.append(menuItem);
}
// Disable libraries to which collection cannot be copied
if (topLevelEntries[0] instanceof Zotero.Library) {
for (let entry of topLevelEntries) {
let canCopy = await selected.canMoveToTargetAsync(entry);
if (!canCopy) {
let menuitem = popup.querySelector(`[value="L${entry.libraryID}"]`);
menuitem.disabled = true;
}
}
}
});
this.buildAddItemToCollectionMenu = function (event, items = this.getSelectedItems()) {
@ -4387,6 +4438,33 @@ var ZoteroPane = new function () {
separator.hidden = !collections.length;
};
this.addToCollectionViaDialog = async function (event, items = this.getSelectedItems()) {
items = Zotero.Items.keepTopLevel(items);
if (!items.length) return;
let libraryID = items[0].libraryID;
if (items.some(item => item.libraryID !== libraryID)) return;
let selectedCollection = ZoteroPane.collectionsView.getSelectedCollection();
let io = {
dataIn: null,
dataOut: null,
filterLibraryIDs: [libraryID],
currentCollectionID: selectedCollection?.id,
canSelect: (collection) => {
if (!(collection instanceof Zotero.Collection)) return false;
if (selectedCollection && collection.id === selectedCollection.id) return false;
if (items.every(item => collection.hasItem(item))) return false;
return true;
}
};
window.openDialog('chrome://zotero/content/selectCollectionDialog.xhtml', '',
'chrome,modal,centerscreen,resizable=no', io);
if (!io.dataOut) return;
await this.addItemsToCollection(items, io.dataOut);
};
this.addItemsToCollection = async function (items, collection, createNew = false) {
items = Zotero.Items.keepTopLevel(items);

View file

@ -947,10 +947,12 @@
<menupopup id="zotero-move-collection-popup" onpopupshowing="ZoteroPane_Local.buildMoveCollectionMenu(event)">
</menupopup>
</menu>
<menuitem class="zotero-menuitem-move-collection" data-l10n-id="collections-menu-move-collection-dialog"/>
<menu class="menu-iconic zotero-menuitem-copy-collection">
<menupopup id="zotero-copy-collection-popup" onpopupshowing="ZoteroPane_Local.buildCopyCollectionMenu(event)">
</menupopup>
</menu>
<menuitem class="zotero-menuitem-copy-collection" data-l10n-id="collections-menu-copy-collection-dialog"/>
<menuitem class="zotero-menuitem-duplicate-collection"/>
<menuitem class="zotero-menuitem-mark-read-feed"/>
<menuitem class="zotero-menuitem-edit-feed" label="&zotero.toolbar.feeds.edit;"/>
@ -1008,6 +1010,7 @@
<menu class="menu-iconic zotero-menuitem-add-to-collection">
<menupopup id="zotero-add-to-collection-popup" onpopupshowing="ZoteroPane_Local.buildAddItemToCollectionMenu(event)"/>
</menu>
<menuitem class="menuitem-iconic zotero-menuitem-add-to-collection-dialog" data-l10n-id="item-menu-add-to-collection-dialog" oncommand="ZoteroPane_Local.addToCollectionViaDialog();"/>
<menuitem class="menuitem-iconic zotero-menuitem-remove-items" oncommand="ZoteroPane_Local.deleteSelectedItems();"/>
<menuitem class="menuitem-iconic zotero-menuitem-duplicate-and-convert" oncommand="ZoteroPane_Local.duplicateAndConvertSelectedItem();"/>
<menuitem class="menuitem-iconic zotero-menuitem-duplicate-item" label="&zotero.items.menu.duplicateItem;" oncommand="ZoteroPane_Local.duplicateSelectedItem().done();"/>

View file

@ -189,6 +189,10 @@ collections-menu-move-collection =
.label = Move To
collections-menu-copy-collection =
.label = Copy To
collections-menu-move-collection-dialog =
.label = Move To…
collections-menu-copy-collection-dialog =
.label = Copy To…
item-creator-moveDown =
.label = Move Down
@ -230,6 +234,8 @@ item-menu-change-parent-item =
.label = Change Parent Item…
item-menu-relate-items =
.label = Relate Items
item-menu-add-to-collection-dialog =
.label = Add to Collection…
view-online = View Online
item-menu-option-view-online =

View file

@ -32,6 +32,8 @@ pref("extensions.zotero.hideContextAnnotationRows", true);
pref("extensions.zotero.fontSize", "1.00");
pref("extensions.zotero.layout", "standard");
pref("extensions.zotero.recursiveCollections", false);
pref("extensions.zotero.collectionsMenu.maxEntriesTotal", 5000);
pref("extensions.zotero.collectionsMenu.maxEntriesAtOneLevel", 100);
pref("extensions.zotero.autoRecognizeFiles", true);
pref("extensions.zotero.autoRenameFiles", true);
pref("extensions.zotero.autoRenameFiles.linked", false);

View file

@ -60,6 +60,7 @@
@import "components/search";
@import "components/select";
@import "components/selectItemsDialog";
@import "components/selectCollectionDialog";
@import "components/spinner";
@import "components/syncButtonTooltip";
@import "components/tabBar";

View file

@ -58,6 +58,7 @@ $menu-icons: (
rename-from-parent: "rename",
create-note-from-annotations: "light-dark:note-annotation",
add-to-collection: "new-collection",
add-to-collection-dialog: "new-collection",
new-tab: "new-tab",
new-window: "new-window",
pin: "pin",

View file

@ -6,4 +6,24 @@
menulist::part(icon) {
margin-inline-end: 3px;
}
[hidden="true"] {
display: none !important;
}
#zotero-collections-tree-container {
min-width: 300px;
min-height: 300px;
max-height: 400px;
margin-top: 8px;
.virtualized-table-body {
padding: 8px;
}
}
#zotero-collections-tree {
border-radius: 5px;
background: var(--material-sidepane);
}
}

View file

@ -0,0 +1,48 @@
#zotero-select-collection-dialog {
display: flex;
min-width: 400px;
min-height: 450px;
}
#select-collection-dialog {
appearance: none;
background: var(--material-background);
display: flex;
flex-direction: column;
-moz-window-dragging: drag;
height: 100%;
#zotero-select-collection-container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 100%;
-moz-window-dragging: no-drag;
margin: -15px -15px 0 -15px;
#search-toolbar {
margin: 10px;
@media (-moz-platform: windows) {
margin: 20px 15px 10px 15px;
}
#zotero-collections-search {
max-width: 100%;
width: 100%;
}
}
}
#zotero-collections-tree-container {
min-width: 400px;
flex: 1;
background: var(--material-sidepane);
.virtualized-table-body {
padding: 16px 8px;
}
}
#zotero-collections-tree {
background: var(--material-sidepane);
}
}

View file

@ -1110,6 +1110,76 @@ describe("ZoteroPane", function () {
var restoreMenuItem = menu.querySelector('.zotero-menuitem-restore-to-library');
assert.isTrue(restoreMenuItem.disabled);
});
it("should list collections when not too many", async function () {
Zotero.Prefs.set('collectionsMenu.maxEntriesTotal', 1000);
let collection = await createDataObject('collection');
let item = await createDataObject('item');
await zp.selectItems([item.id]);
await zp.buildItemContextMenu();
let menu = win.document.getElementById('zotero-itemmenu');
menu.openPopup();
// The menu (not menuitem) variant should be shown
let addToCollectionMenu = menu.querySelector('.zotero-menuitem-add-to-collection');
assert.isFalse(addToCollectionMenu.hidden);
// The dialog variant should be hidden
let addToCollectionDialog = menu.querySelector('.zotero-menuitem-add-to-collection-dialog');
assert.isTrue(addToCollectionDialog.hidden);
// Open the menupopup so it gets populated
let popup = addToCollectionMenu.querySelector('menupopup');
popup.openPopup();
// Find the menuitem for our collection and click it
let collectionMenuItem = popup.querySelector(`[value="C${collection.id}"]`);
assert.ok(collectionMenuItem);
collectionMenuItem.doCommand();
popup.hidePopup();
await waitForNotifierEvent("modify", "item");
// Verify item was added to the collection
assert.isTrue(collection.hasItem(item.id));
});
it("should not list collections if too many", async function () {
let collection = await createDataObject('collection');
let item = await createDataObject('item');
Zotero.Prefs.set('collectionsMenu.maxEntriesTotal', 0);
await zp.selectItems([item.id]);
await zp.buildItemContextMenu();
let menu = win.document.getElementById('zotero-itemmenu');
// The menu variant should be hidden
let addToCollectionMenu = menu.querySelector('.zotero-menuitem-add-to-collection');
assert.isTrue(addToCollectionMenu.hidden);
// The dialog menuitem should be shown
let addToCollectionDialog = menu.querySelector('.zotero-menuitem-add-to-collection-dialog');
assert.isFalse(addToCollectionDialog.hidden);
// Set up listener for the dialog before triggering it
waitForWindow('chrome://zotero/content/selectCollectionDialog.xhtml', async (selectWin) => {
// Wait for the dialog to finish loading
do {
await Zotero.Promise.delay(50);
}
while (!selectWin.loaded);
await selectWin.collectionsView.makeVisible();
// Select the target collection
await selectWin.collectionsView.selectByID('C' + collection.id);
selectWin.document.querySelector('dialog').acceptDialog();
});
await zp.addToCollectionViaDialog();
// Verify item was added to the collection
assert.isTrue(collection.hasItem(item.id));
Zotero.Prefs.set('collectionsMenu.maxEntriesTotal', 1000);
});
});
describe("#restoreSelectedItems()", function () {
@ -1913,6 +1983,81 @@ describe("ZoteroPane", function () {
assert.equal(anotherNewCollection.libraryID, groupCollection.libraryID);
});
});
describe("#buildCollectionContextMenu()", function () {
it("should list collections for copy when not too many", async function () {
Zotero.Prefs.set('collectionsMenu.maxEntriesTotal', 1000);
let collectionA = await createDataObject('collection');
let collectionB = await createDataObject('collection');
await select(win, collectionA);
await zp.buildCollectionContextMenu();
let menu = doc.getElementById('zotero-collectionmenu');
menu.openPopup();
// The menu (not menuitem) variant for copy should be shown
let copyCollectionMenu = menu.querySelector('#copyCollection');
assert.isFalse(copyCollectionMenu.hidden);
// The dialog variant should be hidden
let copyCollectionDialog = menu.querySelector('#copyCollectionDialog');
assert.isTrue(copyCollectionDialog.hidden);
// Open the menupopup so it gets populated
let popup = doc.getElementById('zotero-copy-collection-popup');
popup.openPopup();
// Find the menuitem for collection B and click it
let collectionMenuItem = popup.querySelector(`[value="C${collectionB.id}"]`);
assert.ok(collectionMenuItem);
collectionMenuItem.doCommand();
popup.hidePopup();
await waitForNotifierEvent("add", "collection");
// Verify collection A was copied into collection B
let childNames = collectionB.getDescendents(false, 'collection').map(col => col.name);
assert.include(childNames, collectionA.name);
});
it("should not list collections for copy if too many", async function () {
let collectionA = await createDataObject('collection');
let collectionB = await createDataObject('collection');
Zotero.Prefs.set('collectionsMenu.maxEntriesTotal', 0);
await select(win, collectionA);
await zp.buildCollectionContextMenu();
let menu = doc.getElementById('zotero-collectionmenu');
// The menu variant for copy should be hidden
let copyCollectionMenu = menu.querySelector('#copyCollection');
assert.isTrue(copyCollectionMenu.hidden);
// The dialog menuitem should be shown
let copyCollectionDialog = menu.querySelector('#copyCollectionDialog');
assert.isFalse(copyCollectionDialog.hidden);
// Set up listener for the dialog before triggering it
waitForWindow('chrome://zotero/content/selectCollectionDialog.xhtml', async (selectWin) => {
do {
await Zotero.Promise.delay(50);
}
while (!selectWin.loaded);
await selectWin.collectionsView.makeVisible();
// Select the target collection
await selectWin.collectionsView.selectByID('C' + collectionB.id);
selectWin.document.querySelector('dialog').acceptDialog();
});
await zp.copyCollectionViaDialog();
await waitForNotifierEvent("add", "collection");
// Verify collection A was copied into collection B
let childNames = collectionB.getDescendents(false, 'collection').map(col => col.name);
assert.include(childNames, collectionA.name);
Zotero.Prefs.set('collectionsMenu.maxEntriesTotal', 1000);
});
});
describe("#moveCollection", function () {
it("should move collection into another collection of the same library", async function () {
let collection = await createDataObject('collection');