diff --git a/chrome/content/zotero/elements/abstractBox.js b/chrome/content/zotero/elements/abstractBox.js
index 19e0119e34..8b32bd4aaa 100644
--- a/chrome/content/zotero/elements/abstractBox.js
+++ b/chrome/content/zotero/elements/abstractBox.js
@@ -72,12 +72,13 @@
this.initCollapsibleSection();
this._abstractField = this.querySelector('editable-text');
- this._abstractField.addEventListener('blur', () => this.save());
+ this._abstractField.addEventListener('blur', this._handleFieldBlur);
this._abstractField.ariaLabel = Zotero.getString('itemFields.abstractNote');
this.render();
}
destroy() {
+ this._abstractField?.removeEventListener('blur', this._handleFieldBlur);
Zotero.Notifier.unregisterObserver(this._notifierID);
}
@@ -192,6 +193,10 @@
this._feedAbstractBrowser = null;
}
}
+
+ _handleFieldBlur = () => {
+ this.save();
+ };
}
customElements.define("abstract-box", AbstractBox);
}
diff --git a/chrome/content/zotero/elements/attachmentBox.js b/chrome/content/zotero/elements/attachmentBox.js
index 853e221ac6..17995be7bb 100644
--- a/chrome/content/zotero/elements/attachmentBox.js
+++ b/chrome/content/zotero/elements/attachmentBox.js
@@ -243,34 +243,20 @@
this._body = this.querySelector('.body');
- this._id('url').addEventListener('contextmenu', (event) => {
- this._id('url-menu').openPopupAtScreen(event.screenX, event.screenY, true);
- });
+ this._id('url').addEventListener('contextmenu', this._handleURLContextMenu);
- this._id("title").addEventListener('blur', () => {
- this.item.setField('title', this._id('title').value);
- this.item.saveTx();
- });
+ this._id("title").addEventListener('blur', this._handleTitleBlur);
let fileName = this._id("fileName");
- fileName.addEventListener('focus', () => {
- this._isEditingFilename = true;
- });
- fileName.addEventListener('blur', () => {
- this.editFileName(fileName.value);
- this._isEditingFilename = false;
- });
+ fileName.addEventListener('focus', this._handleFileNameFocus);
+ fileName.addEventListener('blur', this._handleFileNameBlur);
let noteButton = this._id('note-button');
- noteButton.addEventListener("command", () => {
- this.convertAttachmentNote();
- });
+ noteButton.addEventListener("command", this._handleNoteButtonCommand);
let copyMenuitem = this._id('url-menuitem-copy');
copyMenuitem.label = Zotero.getString('general.copy');
- copyMenuitem.addEventListener('command', () => {
- Zotero.Utilities.Internal.copyTextToClipboard(this.item.getField('url'));
- });
+ copyMenuitem.addEventListener('command', this._handleCopyURL);
this._notifierID = Zotero.Notifier.registerObserver(this, ['item'], 'attachmentbox');
@@ -280,37 +266,44 @@
// reindex button
let reindexButton = this._id("indexStatusRow").querySelector(".meta-data toolbarbutton");
if (reindexButton) {
- reindexButton.addEventListener("focusin", function (e) {
- if (e.target.tagName == "image") {
- reindexButton.focus();
- reindexButton.querySelector("image").removeAttribute("tabindex");
- }
- });
- reindexButton.addEventListener("blur", function (_) {
- setTimeout(() => {
- if (document.activeElement !== reindexButton) {
- reindexButton.querySelector("image").setAttribute("tabindex", "0");
- }
- });
- });
+ reindexButton.addEventListener("focusin", this._handleReindexButtonFocus);
+ reindexButton.addEventListener("blur", this._handleReindexButtonBlur);
+ // Prevents the button from getting stuck in active state
+ reindexButton.addEventListener("keydown", this._handleReindexButtonKeydown);
}
- // Prevents the button from getting stuck in active state
- reindexButton.addEventListener("keydown", (e) => {
- if (e.key == " ") {
- e.preventDefault();
- reindexButton.click();
- }
- });
for (let label of this.querySelectorAll(".meta-label")) {
// Prevent default focus/blur behavior - we implement our own below
- label.addEventListener("mousedown", event => event.preventDefault());
+ label.addEventListener("mousedown", this._handleMetaLabelMousedown);
label.addEventListener("click", this._handleMetaLabelClick);
}
}
destroy() {
+ this.discard();
+ this._preview?.remove();
+ delete this._preview;
+
Zotero.Notifier.unregisterObserver(this._notifierID);
+
+ this._id('url')?.removeEventListener('contextmenu', this._handleURLContextMenu);
+ this._id("title")?.removeEventListener('blur', this._handleTitleBlur);
+ this._id("fileName")?.removeEventListener('focus', this._handleFileNameFocus);
+ this._id("fileName")?.removeEventListener('blur', this._handleFileNameBlur);
+ this._id('note-button')?.removeEventListener("command", this._handleNoteButtonCommand);
+ this._id('url-menuitem-copy')?.removeEventListener('command', this._handleCopyURL);
+
+ let reindexButton = this._id("indexStatusRow")?.querySelector(".meta-data toolbarbutton");
+ if (reindexButton) {
+ reindexButton.removeEventListener("focusin", this._handleReindexButtonFocus);
+ reindexButton.removeEventListener("blur", this._handleReindexButtonBlur);
+ reindexButton.removeEventListener("keydown", this._handleReindexButtonKeydown);
+ }
+
+ for (let label of this.querySelectorAll(".meta-label")) {
+ label.removeEventListener("mousedown", this._handleMetaLabelMousedown);
+ label.removeEventListener("click", this._handleMetaLabelClick);
+ }
}
notify(event, _type, ids, _extraData) {
@@ -739,6 +732,58 @@
this._body.prepend(this._preview);
this._preview.disableResize = !!this.hidden;
}
+
+ _handleURLContextMenu = (event) => {
+ this._id('url-menu').openPopupAtScreen(event.screenX, event.screenY, true);
+ };
+
+ _handleTitleBlur = () => {
+ this.item.setField('title', this._id('title').value);
+ this.item.saveTx();
+ };
+
+ _handleFileNameFocus = () => {
+ this._isEditingFilename = true;
+ };
+
+ _handleFileNameBlur = () => {
+ this.editFileName(this._id("fileName").value);
+ this._isEditingFilename = false;
+ };
+
+ _handleNoteButtonCommand = () => {
+ this.convertAttachmentNote();
+ };
+
+ _handleCopyURL = () => {
+ Zotero.Utilities.Internal.copyTextToClipboard(this.item.getField('url'));
+ };
+
+ _handleReindexButtonFocus = (event) => {
+ if (event.target.tagName == "image") {
+ reindexButton.focus();
+ reindexButton.querySelector("image").removeAttribute("tabindex");
+ }
+ };
+
+ _handleReindexButtonBlur = () => {
+ setTimeout(() => {
+ if (document.activeElement !== reindexButton) {
+ reindexButton.querySelector("image").setAttribute("tabindex", "0");
+ }
+ });
+ };
+
+ _handleReindexButtonKeydown = (event) => {
+ if (event.key == " ") {
+ event.preventDefault();
+ reindexButton.click();
+ }
+ };
+
+ _handleMetaLabelMousedown = (event) => {
+ event.preventDefault();
+ };
}
customElements.define("attachment-box", AttachmentBox);
diff --git a/chrome/content/zotero/elements/attachmentPreview.js b/chrome/content/zotero/elements/attachmentPreview.js
index 6bfa02c0f7..e8e1bb3205 100644
--- a/chrome/content/zotero/elements/attachmentPreview.js
+++ b/chrome/content/zotero/elements/attachmentPreview.js
@@ -220,7 +220,12 @@
}
destroy() {
- this._reader?.uninit();
+ try {
+ this._reader?.uninit();
+ }
+ catch (e) {
+ this._debug("Error uninitializing reader", e);
+ }
this._resizeOb.disconnect();
this.removeEventListener("DOMContentLoaded", this._handleReaderLoad);
this.removeEventListener("mouseenter", this.updateGoto);
diff --git a/chrome/content/zotero/elements/attachmentsBox.js b/chrome/content/zotero/elements/attachmentsBox.js
index 8fdc6e64a7..e748e06ea2 100644
--- a/chrome/content/zotero/elements/attachmentsBox.js
+++ b/chrome/content/zotero/elements/attachmentsBox.js
@@ -116,20 +116,10 @@
this._addPopup = this.querySelector('.add-popup');
let [addFile, addLink, addWebLink] = this._addPopup.children;
- this._addPopup.addEventListener('popupshowing', () => {
- let canAddAny = this.item?.isRegularItem() && this.item.library.editable;
- addFile.disabled = addLink.disabled = !(canAddAny && this.item.library.filesEditable);
- addWebLink.disabled = !canAddAny;
- });
- addFile.addEventListener('command', () => {
- ZoteroPane.addAttachmentFromDialog(false, this.item.id);
- });
- addLink.addEventListener('command', () => {
- ZoteroPane.addAttachmentFromDialog(true, this.item.id);
- });
- addWebLink.addEventListener('command', () => {
- ZoteroPane.addAttachmentFromURI(true, this.item.id);
- });
+ this._addPopup.addEventListener('popupshowing', this._handleAddPopupShowing);
+ addFile.addEventListener('command', this._handleAddFile);
+ addLink.addEventListener('command', this._handleAddLink);
+ addWebLink.addEventListener('command', this._handleAddWebLink);
this.usePreview = Zotero.Prefs.get('showAttachmentPreview');
@@ -144,8 +134,22 @@
}
destroy() {
- this._section?.removeEventListener('add', this._handleAdd);
+ this.discard();
+ this._preview?.remove();
+ delete this._preview;
+
Zotero.Notifier.unregisterObserver(this._notifierID);
+
+ this._section?.removeEventListener('add', this._handleAdd);
+ if (this._addPopup) {
+ this._addPopup.removeEventListener('popupshowing', this._handleAddPopupShowing);
+ let [addFile, addLink, addWebLink] = this._addPopup.children;
+ addFile?.removeEventListener('command', this._handleAddFile);
+ addLink?.removeEventListener('command', this._handleAddLink);
+ addWebLink?.removeEventListener('command', this._handleAddWebLink);
+ }
+
+ this._section?._contextMenu?.removeEventListener('popupshowing', this._handleContextMenu);
}
notify(action, type, ids) {
@@ -293,6 +297,24 @@
this._addPopup.openPopup(event.detail.button, 'after_end');
};
+ _handleAddPopupShowing = () => {
+ let canAddAny = this.item?.isRegularItem() && this.item.library.editable;
+ addFile.disabled = addLink.disabled = !(canAddAny && this.item.library.filesEditable);
+ addWebLink.disabled = !canAddAny;
+ };
+
+ _handleAddFile = () => {
+ ZoteroPane.addAttachmentFromDialog(false, this.item.id);
+ };
+
+ _handleAddLink = () => {
+ ZoteroPane.addAttachmentFromDialog(true, this.item.id);
+ };
+
+ _handleAddWebLink = () => {
+ ZoteroPane.addAttachmentFromURI(true, this.item.id);
+ };
+
_handleTogglePreview = () => {
let toOpen = !Zotero.Prefs.get('showAttachmentPreview');
Zotero.Prefs.set('showAttachmentPreview', toOpen);
diff --git a/chrome/content/zotero/elements/collapsibleSection.js b/chrome/content/zotero/elements/collapsibleSection.js
index d2ffcae73b..20ebfe0940 100644
--- a/chrome/content/zotero/elements/collapsibleSection.js
+++ b/chrome/content/zotero/elements/collapsibleSection.js
@@ -199,6 +199,29 @@
let containerRoot = this.closest('.zotero-view-item-container, context-notes-list');
let contextMenu = document.createXULElement('menupopup');
+
+ let pinSection, unpinSection;
+ pinSection = document.createXULElement('menuitem');
+ pinSection.classList.add('menuitem-iconic', 'zotero-menuitem-pin');
+ pinSection.setAttribute('data-l10n-id', 'pin-section');
+ pinSection.addEventListener('command', () => {
+ let sidenav = this._getSidenav();
+ sidenav.container.scrollToPane(this.dataset.pane, 'smooth');
+ sidenav.pinnedPane = this.dataset.pane;
+ });
+ contextMenu.append(pinSection);
+
+ unpinSection = document.createXULElement('menuitem');
+ unpinSection.classList.add('menuitem-iconic', 'zotero-menuitem-unpin');
+ unpinSection.setAttribute('data-l10n-id', 'unpin-section');
+ unpinSection.addEventListener('command', () => {
+ this._getSidenav().pinnedPane = null;
+ });
+ contextMenu.append(unpinSection);
+
+ let pinSeparator = document.createXULElement('menuseparator');
+ contextMenu.append(pinSeparator);
+
let collapseOtherSections = document.createXULElement('menuitem');
collapseOtherSections.classList.add('menuitem-iconic', 'zotero-menuitem-collapse-others');
collapseOtherSections.setAttribute('data-l10n-id', 'collapse-other-sections');
@@ -223,27 +246,35 @@
});
contextMenu.append(expandAllSections);
- let pinSection, unpinSection;
- let pinUnpinSeparator = document.createXULElement('menuseparator');
- contextMenu.append(pinUnpinSeparator);
+ let reorderSeparator = document.createXULElement('menuseparator');
+ contextMenu.append(reorderSeparator);
- pinSection = document.createXULElement('menuitem');
- pinSection.classList.add('menuitem-iconic', 'zotero-menuitem-pin');
- pinSection.setAttribute('data-l10n-id', 'pin-section');
- pinSection.addEventListener('command', () => {
+ let moveSectionUp = document.createXULElement('menuitem');
+ moveSectionUp.classList.add('menuitem-iconic', 'zotero-menuitem-reorder-up');
+ moveSectionUp.setAttribute('data-l10n-id', 'sidenav-reorder-up');
+ moveSectionUp.addEventListener('command', () => {
let sidenav = this._getSidenav();
- sidenav.container.scrollToPane(this.dataset.pane, 'smooth');
- sidenav.pinnedPane = this.dataset.pane;
+ sidenav.handlePaneMove(this.dataset.pane, 'up');
});
- contextMenu.append(pinSection);
+ contextMenu.append(moveSectionUp);
- unpinSection = document.createXULElement('menuitem');
- unpinSection.classList.add('menuitem-iconic', 'zotero-menuitem-unpin');
- unpinSection.setAttribute('data-l10n-id', 'unpin-section');
- unpinSection.addEventListener('command', () => {
- this._getSidenav().pinnedPane = null;
+ let moveSectionDown = document.createXULElement('menuitem');
+ moveSectionDown.classList.add('menuitem-iconic', 'zotero-menuitem-reorder-down');
+ moveSectionDown.setAttribute('data-l10n-id', 'sidenav-reorder-down');
+ moveSectionDown.addEventListener('command', () => {
+ let sidenav = this._getSidenav();
+ sidenav.handlePaneMove(this.dataset.pane, 'down');
});
- contextMenu.append(unpinSection);
+ contextMenu.append(moveSectionDown);
+
+ let resetSectionOrder = document.createXULElement('menuitem');
+ resetSectionOrder.classList.add('menuitem-iconic', 'zotero-menuitem-reorder-reset');
+ resetSectionOrder.setAttribute('data-l10n-id', 'sidenav-reorder-reset');
+ resetSectionOrder.addEventListener('command', () => {
+ let sidenav = this._getSidenav();
+ sidenav.resetPaneOrder();
+ });
+ contextMenu.append(resetSectionOrder);
contextMenu.addEventListener('popupshowing', () => {
let sections = Array.from(containerRoot.querySelectorAll('collapsible-section'));
@@ -252,15 +283,25 @@
let sidenav = this._getSidenav();
if (sidenav?.isPanePinnable(this.dataset.pane)) {
- pinUnpinSeparator.hidden = false;
pinSection.hidden = sidenav.pinnedPane == this.dataset.pane;
unpinSection.hidden = sidenav.pinnedPane != this.dataset.pane;
+ pinSeparator.hidden = false;
}
else {
- pinUnpinSeparator.hidden = true;
pinSection.hidden = true;
unpinSection.hidden = true;
+ pinSeparator.hidden = true;
}
+
+ let canMoveUp = sidenav?.isPaneMovable(this.dataset.pane, 'up');
+ let canMoveDown = sidenav?.isPaneMovable(this.dataset.pane, 'down');
+ let canReset = sidenav?.isOrderChanged();
+
+ moveSectionUp.hidden = !canMoveUp;
+ moveSectionDown.hidden = !canMoveDown;
+ resetSectionOrder.hidden = !canReset;
+
+ reorderSeparator.hidden = !canMoveUp && !canMoveDown && !canReset;
});
return contextMenu;
@@ -297,6 +338,8 @@
this._head.removeEventListener('mousedown', this._handleMouseDown);
this._head.removeEventListener('keydown', this._handleKeyDown);
this._head.removeEventListener('contextmenu', this._handleContextMenu);
+
+ this._contextMenu?.remove();
Zotero.Prefs.unregisterObserver(this._prefsObserverID);
}
diff --git a/chrome/content/zotero/elements/itemBox.js b/chrome/content/zotero/elements/itemBox.js
index 47daa5e2f8..2fd8565fdb 100644
--- a/chrome/content/zotero/elements/itemBox.js
+++ b/chrome/content/zotero/elements/itemBox.js
@@ -125,142 +125,34 @@
init() {
this.initCollapsibleSection();
- this._creatorTypeMenu.addEventListener('command', async (event) => {
- var typeBox = this._popupNode;
- var index = parseInt(typeBox.getAttribute('fieldname').split('-')[1]);
-
- var typeID = event.explicitOriginalTarget.getAttribute('typeid');
- var row = typeBox.parentNode;
- var fields = this.getCreatorFields(row);
- fields.creatorTypeID = typeID;
- typeBox.querySelector("#creator-type-label-inner").textContent = Zotero.getString(
- 'creatorTypes.' + Zotero.CreatorTypes.getName(typeID)
- );
- typeBox.setAttribute('typeid', typeID);
-
- this.modifyCreator(index, fields);
- if (this.saveOnEdit) {
- await this.item.saveTx();
- }
- });
+ this._creatorTypeMenu.addEventListener('command', this._handleCreatorTypeChange);
- this._id('zotero-creator-transform-menu').addEventListener('popupshowing', (_event) => {
- var row = this._popupNode.closest('.meta-row');
- var typeBox = row.querySelector('.creator-type-label').parentNode;
- var index = parseInt(typeBox.getAttribute('fieldname').split('-')[1]);
- var item = this.item;
- var exists = item.hasCreatorAt(index);
- var fieldMode = row.querySelector("[fieldMode]").getAttribute("fieldMode");
-
- var moreCreators = item.numCreators() > index + 1;
-
- var hideMoveToTop = !exists || index < 2;
- var hideMoveUp = !exists || index == 0;
- var hideMoveDown = !exists || !moreCreators;
- var hideMoveSep = hideMoveUp && hideMoveDown;
- var hideNameSwap = fieldMode == '1' || !exists;
-
- this._id('creator-transform-swap-names').hidden = hideNameSwap;
- this._id('creator-transform-capitalize').disabled = !this.canCapitalizeCreatorName(row);
- this._id('zotero-creator-move-sep').setAttribute('hidden', hideMoveSep);
- this._id('zotero-creator-move-to-top').setAttribute('hidden', hideMoveToTop);
- this._id('zotero-creator-move-up').setAttribute('hidden', hideMoveUp);
- this._id('zotero-creator-move-down').setAttribute('hidden', hideMoveDown);
- });
+ this._id('zotero-creator-transform-menu').addEventListener('popupshowing', this._handleCreatorTransformMenuShowing);
- // Ensure no button is forced to stay visible once the menu is closed
- this.addEventListener('popuphidden', (event) => {
- for (let node of this.querySelectorAll('.show-without-hover')) {
- node.classList.remove('show-without-hover');
- node.classList.add("show-on-hover");
- }
- // Some toolbarbuttons get stuck with open=true if popup is
- // opened via keyboard (e.g. select version btn in merge mode)
- let popupParent = event.target.parentElement;
- if (popupParent?.getAttribute("open") == "true") {
- popupParent.removeAttribute("open");
- }
- });
+ this._id('zotero-creator-transform-menu').addEventListener('command', this._handleCreatorTransformMenuCommand);
- this._id('zotero-creator-transform-menu').addEventListener('command', async (event) => {
- var row = this._popupNode.closest('.meta-row');
- var typeBox = row.querySelector('.creator-type-label').parentNode;
- var index = parseInt(typeBox.getAttribute('fieldname').split('-')[1]);
-
- if (event.explicitOriginalTarget.className == 'zotero-creator-move') {
- let dir;
- switch (event.explicitOriginalTarget.id) {
- case 'zotero-creator-move-to-top':
- dir = 'top';
- break;
-
- case 'zotero-creator-move-up':
- dir = 'up';
- break;
-
- case 'zotero-creator-move-down':
- dir = 'down';
- break;
- }
- this.moveCreator(index, dir);
- }
- });
+ this._id('creator-transform-swap-names').addEventListener('command', this._handleCreatorTransformSwapNames);
- this._id('creator-transform-swap-names').addEventListener('command',
- event => this.swapNames(event));
-
- this._id('creator-transform-capitalize').addEventListener('command',
- event => this.capitalizeCreatorName(event));
+ this._id('creator-transform-capitalize').addEventListener('command', this._handleCreatorTransformCapitalize);
- this._linkMenu.addEventListener('popupshowing', () => {
- let menu = this._linkMenu;
- let link = menu.dataset.link;
- let val = menu.dataset.val;
-
- let viewOnline = this._id('zotero-link-menu-view-online');
- let copy = this._id('zotero-link-menu-copy');
-
- viewOnline.disabled = !link;
- copy.disabled = !link;
- copy.hidden = link === val;
-
- let existingCopyMenuitem = menu.querySelector('menuitem[data-action="copy"]');
- if (existingCopyMenuitem) {
- existingCopyMenuitem.after(copy);
- }
- else {
- menu.append(copy);
- }
- });
+ this._linkMenu.addEventListener('popupshowing', this._handleLinkMenuShowing);
this._id('zotero-link-menu-view-online').addEventListener(
'command',
- event => ZoteroPane.loadURI(this._linkMenu.dataset.link, event)
+ this._handleLinkMenuViewOnline
);
this._id('zotero-link-menu-copy').addEventListener(
'command',
- () => Zotero.Utilities.Internal.copyTextToClipboard(this._linkMenu.dataset.link)
+ this._handleLinkMenuCopy
);
- this._infoTable.addEventListener("focusout", async (_) => {
- await Zotero.Promise.delay();
- // If the focus leaves the itemBox, clear the last focused element
- let focused = document.activeElement;
- if (!this._infoTable.contains(focused)) {
- this._clearSavedFieldFocus();
- }
- // If user moves focus outside of empty unsaved creator row, remove it.
- let unsavedCreatorRow = this.querySelector(".creator-type-value[unsaved=true]")?.closest(".meta-row");
- // But not if these parent components receive focus which happens when menus are opened
- if (["zotero-view-item", "main-window"].includes(focused.id) || !unsavedCreatorRow) return;
- let focusLeftUnsavedCreatorRow = !unsavedCreatorRow.contains(focused);
- if (focusLeftUnsavedCreatorRow) {
- this.removeUnsavedCreatorRow(true);
- }
- });
+ this._infoTable.addEventListener("focusout", this._handleFocusout);
+
+ // Ensure no button is forced to stay visible once the menu is closed
+ this.addEventListener('popuphidden', this._handlePopupHidden);
this._notifierID = Zotero.Notifier.registerObserver(this, ['item', 'infobox'], 'itemBox');
- Zotero.Prefs.registerObserver('fontSize', () => {
+ this._prefsObserverID = Zotero.Prefs.registerObserver('fontSize', () => {
this._forceRenderAll();
});
@@ -272,6 +164,18 @@
destroy() {
Zotero.Notifier.unregisterObserver(this._notifierID);
+
+ Zotero.Prefs.unregisterObserver(this._prefsObserverID);
+
+ this._id('zotero-creator-transform-menu')?.removeEventListener('popupshowing', this._handleCreatorTransformMenuShowing);
+ this._id('zotero-creator-transform-menu')?.removeEventListener('command', this._handleCreatorTransformMenuCommand);
+ this._id('creator-transform-swap-names')?.removeEventListener('command', this._handleCreatorTransformSwapNames);
+ this._id('creator-transform-capitalize')?.removeEventListener('command', this._handleCreatorTransformCapitalize);
+ this._linkMenu?.removeEventListener('popupshowing', this._handleLinkMenuShowing);
+ this._id('zotero-link-menu-view-online')?.removeEventListener('command', this._handleLinkMenuViewOnline);
+ this._id('zotero-link-menu-copy')?.removeEventListener('command', this._handleLinkMenuCopy);
+ this._infoTable?.removeEventListener("focusout", this._handleFocusout);
+ this.removeEventListener('popuphidden', this._handlePopupHidden);
}
//
@@ -887,7 +791,7 @@
// If rowIDs are provided, always update them
if (rowIDs?.length > 0) {
for (let rowID of rowIDs) {
- let rowElem = this._infoTable.querySelector(`[data-custom-row-id="${rowID}"]`);
+ let rowElem = this._infoTable.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
if (!rowElem) continue;
this.updateCustomRowData(rowElem);
}
@@ -905,7 +809,7 @@
// Add rows that are in the target rows but not in the current rows
for (let row of targetRows) {
- let rowElem = this._infoTable.querySelector(`[data-custom-row-id="${row.rowID}"]`);
+ let rowElem = this._infoTable.querySelector(`[data-custom-row-id="${CSS.escape(row.rowID)}"]`);
if (rowElem) {
// If the row is already in the table, and not already updated, update it
if (!rowIDs?.includes(row.rowID)) {
@@ -2854,6 +2758,138 @@
_id(id) {
return this.querySelector(`#${id}`);
}
+
+ _handleCreatorTypeChange = async (event) => {
+ var typeBox = this._popupNode;
+ var index = parseInt(typeBox.getAttribute('fieldname').split('-')[1]);
+
+ var typeID = event.explicitOriginalTarget.getAttribute('typeid');
+ var row = typeBox.parentNode;
+ var fields = this.getCreatorFields(row);
+ fields.creatorTypeID = typeID;
+ typeBox.querySelector("#creator-type-label-inner").textContent = Zotero.getString(
+ 'creatorTypes.' + Zotero.CreatorTypes.getName(typeID)
+ );
+ typeBox.setAttribute('typeid', typeID);
+
+ this.modifyCreator(index, fields);
+ if (this.saveOnEdit) {
+ await this.item.saveTx();
+ }
+ };
+
+ _handleCreatorTransformMenuShowing = (_event) => {
+ var row = this._popupNode.closest('.meta-row');
+ var typeBox = row.querySelector('.creator-type-label').parentNode;
+ var index = parseInt(typeBox.getAttribute('fieldname').split('-')[1]);
+ var item = this.item;
+ var exists = item.hasCreatorAt(index);
+ var fieldMode = row.querySelector("[fieldMode]").getAttribute("fieldMode");
+
+ var moreCreators = item.numCreators() > index + 1;
+
+ var hideMoveToTop = !exists || index < 2;
+ var hideMoveUp = !exists || index == 0;
+ var hideMoveDown = !exists || !moreCreators;
+ var hideMoveSep = hideMoveUp && hideMoveDown;
+ var hideNameSwap = fieldMode == '1' || !exists;
+
+ this._id('creator-transform-swap-names').hidden = hideNameSwap;
+ this._id('creator-transform-capitalize').disabled = !this.canCapitalizeCreatorName(row);
+ this._id('zotero-creator-move-sep').setAttribute('hidden', hideMoveSep);
+ this._id('zotero-creator-move-to-top').setAttribute('hidden', hideMoveToTop);
+ this._id('zotero-creator-move-up').setAttribute('hidden', hideMoveUp);
+ this._id('zotero-creator-move-down').setAttribute('hidden', hideMoveDown);
+ };
+
+ _handleCreatorTransformMenuCommand = async (event) => {
+ var row = this._popupNode.closest('.meta-row');
+ var typeBox = row.querySelector('.creator-type-label').parentNode;
+ var index = parseInt(typeBox.getAttribute('fieldname').split('-')[1]);
+
+ if (event.explicitOriginalTarget.className == 'zotero-creator-move') {
+ let dir;
+ switch (event.explicitOriginalTarget.id) {
+ case 'zotero-creator-move-to-top':
+ dir = 'top';
+ break;
+
+ case 'zotero-creator-move-up':
+ dir = 'up';
+ break;
+
+ case 'zotero-creator-move-down':
+ dir = 'down';
+ break;
+ }
+ this.moveCreator(index, dir);
+ }
+ };
+
+ _handleCreatorTransformSwapNames = (event) => {
+ this.swapNames(event);
+ };
+
+ _handleCreatorTransformCapitalize = (event) => {
+ this.capitalizeCreatorName(event);
+ }
+
+ _handleLinkMenuShowing = () => {
+ let menu = this._linkMenu;
+ let link = menu.dataset.link;
+ let val = menu.dataset.val;
+
+ let viewOnline = this._id('zotero-link-menu-view-online');
+ let copy = this._id('zotero-link-menu-copy');
+
+ viewOnline.disabled = !link;
+ copy.disabled = !link;
+ copy.hidden = link === val;
+
+ let existingCopyMenuitem = menu.querySelector('menuitem[data-action="copy"]');
+ if (existingCopyMenuitem) {
+ existingCopyMenuitem.after(copy);
+ }
+ else {
+ menu.append(copy);
+ }
+ };
+
+ _handleLinkMenuViewOnline = event => ZoteroPane.loadURI(this._linkMenu.dataset.link, event);
+
+ _handleLinkMenuCopy = () => {
+ Zotero.Utilities.Internal.copyTextToClipboard(this._linkMenu.dataset.link);
+ };
+
+ _handlePopupHidden = (event) => {
+ for (let node of this.querySelectorAll('.show-without-hover')) {
+ node.classList.remove('show-without-hover');
+ node.classList.add("show-on-hover");
+ }
+ // Some toolbarbuttons get stuck with open=true if popup is
+ // opened via keyboard (e.g. select version btn in merge mode)
+ let popupParent = event.target.parentElement;
+ if (popupParent?.getAttribute("open") == "true") {
+ popupParent.removeAttribute("open");
+ }
+ };
+
+ _handleFocusout = async (_) => {
+ await Zotero.Promise.delay();
+ // If the focus leaves the itemBox, clear the last focused element
+ let focused = document.activeElement;
+ if (!this._infoTable.contains(focused)) {
+ this._clearSavedFieldFocus();
+ }
+ // If user moves focus outside of empty unsaved creator row, remove it.
+ let unsavedCreatorRow = this.querySelector(".creator-type-value[unsaved=true]")?.closest(".meta-row");
+ // But not if these parent components receive focus which happens when menus are opened
+ if (["zotero-view-item", "main-window"].includes(focused.id) || !unsavedCreatorRow) return;
+ let focusLeftUnsavedCreatorRow = !unsavedCreatorRow.contains(focused);
+ if (focusLeftUnsavedCreatorRow) {
+ this.removeUnsavedCreatorRow(true);
+ }
+ };
}
customElements.define("info-box", InfoBox);
}
diff --git a/chrome/content/zotero/elements/itemDetails.js b/chrome/content/zotero/elements/itemDetails.js
index e791f970cc..d8321b6a3d 100644
--- a/chrome/content/zotero/elements/itemDetails.js
+++ b/chrome/content/zotero/elements/itemDetails.js
@@ -234,6 +234,8 @@
this._lastUpdateCustomSection = "";
+ this._lastScrollTop = 0;
+
// If true, will render on tab select
this._pendingRender = false;
// If true, will skip render
@@ -306,7 +308,9 @@
this.pinnedPane = paneID;
}
else {
- this._paneParent.scrollTo(0, 0);
+ // Keep the scroll position after reordering
+ this._paneParent.scrollTo(0, this._lastScrollTop);
+ this._lastScrollTop = 0;
}
// Only execute async render for visible panes
@@ -382,11 +386,16 @@
elem.setL10nArgs(header.l10nArgs);
this._intersectionOb.observe(elem);
this.sidenav.addPane(paneID);
+ this.sidenav.updatePaneStatus(paneID);
}
// Update pending pinned pane
if (this._pendingPinnedPane && this.getEnabledPane(this._pendingPinnedPane)) {
this.pinnedPane = this._pendingPinnedPane;
}
+
+ if (this.sidenav) {
+ this.initPaneOrder(this.sidenav.getPersistedOrder());
+ }
}
renderCustomHead(callback) {
@@ -456,6 +465,69 @@
this.getPanes().forEach(elem => this._sidenav.updatePaneStatus(elem.dataset.pane));
}
+ initPaneOrder(order) {
+ let panes = this.getPanes();
+ let paneIDs = panes.map(elem => elem.dataset.pane);
+
+ // Compare the order of paneIDs with the given order
+ let isOrderDifferent = false;
+ let lastOrderIdx = -1;
+ for (let paneID of paneIDs) {
+ let idx = order.indexOf(paneID);
+ if (idx == -1) {
+ continue;
+ }
+ if (idx < lastOrderIdx) {
+ isOrderDifferent = true;
+ break;
+ }
+ lastOrderIdx = idx;
+ }
+
+ if (!isOrderDifferent) {
+ return;
+ }
+
+ // Rearrange panes according to the given order. Unordered panes will stay at the end
+ for (let i = order.length - 1; i >= 0; i--) {
+ let paneID = order[i];
+ let idx = paneIDs.indexOf(paneID);
+ if (idx == -1) {
+ continue;
+ }
+ this._paneParent.prepend(panes[idx]);
+ }
+ }
+
+ /**
+ * Change the order of panes
+ * @param {string} paneID
+ * @param {number} newIdx
+ * @param {Object} options
+ * @param {boolean} options.render - Whether to rerender panes after reordering
+ */
+ async changePaneOrder(paneID, newIdx, options = {}) {
+
+ let panes = this.getPanes();
+ let paneIDs = panes.map(elem => elem.dataset.pane);
+ let currentIndex = paneIDs.indexOf(paneID);
+ if (currentIndex == -1) return;
+ if (currentIndex == newIdx || currentIndex == newIdx - 1) return;
+ let currentPane = panes[currentIndex];
+ if (newIdx < panes.length) {
+ this._paneParent.insertBefore(currentPane, panes[newIdx]);
+ }
+ else {
+ this._paneParent.appendChild(currentPane);
+ }
+
+ // Rerender panes after reordering
+ if (options.render !== false) {
+ this._lastScrollTop = this._paneParent.scrollTop;
+ await this.render();
+ }
+ }
+
async scrollToPane(paneID, behavior = 'smooth') {
let panes = this.getEnabledPanes();
let paneIndex = panes.findIndex(elem => elem.dataset.pane == paneID);
diff --git a/chrome/content/zotero/elements/itemPaneSection.js b/chrome/content/zotero/elements/itemPaneSection.js
index 1cf01e442a..dcca5e8572 100644
--- a/chrome/content/zotero/elements/itemPaneSection.js
+++ b/chrome/content/zotero/elements/itemPaneSection.js
@@ -106,6 +106,7 @@ class ItemPaneSectionElementBase extends XULElementBase {
this._section.removeEventListener("toggle", this._handleSectionToggle);
this._section = null;
}
+ this._resetRenderedFlags()
}
initCollapsibleSection() {
@@ -185,6 +186,11 @@ class ItemPaneSectionElementBase extends XULElementBase {
_refreshDisabled = true;
+ // Cache section l10n ID and args for reconnecting
+ _sectionL10nId = null;
+
+ _sectionL10nArgs = null;
+
get content() {
let extraButtons = Object.keys(this._sectionButtons).join(",");
let content = `
@@ -235,6 +241,9 @@ class ItemPaneSectionElementBase extends XULElementBase {
if (this._label) this._section.label = this._label;
this.updateSectionIcon();
+ if (this._sectionL10nId !== null) this.setL10nID(this._sectionL10nId);
+ if (this._sectionL10nArgs !== null) this.setL10nArgs(this._sectionL10nArgs);
+
this._sectionListeners = [];
let styles = [];
@@ -270,17 +279,19 @@ class ItemPaneSectionElementBase extends XULElementBase {
destroy() {
this._sectionListeners.forEach(data => this._section?.removeEventListener(data.type, data.listener));
+ this._sectionListeners = [];
this._handleDestroy();
- this._hooks = null;
}
setL10nID(l10nId) {
this._section.dataset.l10nId = l10nId;
+ this._sectionL10nId = l10nId;
}
setL10nArgs(l10nArgs) {
this._section.dataset.l10nArgs = l10nArgs;
+ this._sectionL10nArgs = l10nArgs;
}
registerSectionIcon(options) {
diff --git a/chrome/content/zotero/elements/itemPaneSidenav.js b/chrome/content/zotero/elements/itemPaneSidenav.js
index 345fb15497..f960a47297 100644
--- a/chrome/content/zotero/elements/itemPaneSidenav.js
+++ b/chrome/content/zotero/elements/itemPaneSidenav.js
@@ -28,85 +28,25 @@
{
class ItemPaneSidenav extends XULElementBase {
content = MozXULElement.parseXULToFragment(`
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
+ role="tab">
+
-
+
+
+
+
`, ['chrome://zotero/locale/zotero.dtd']);
+
+ _initialized = false;
_container = null;
@@ -129,6 +75,20 @@
_contextMenuTarget = null;
+ _draggedWrapper = null;
+
+ _dropIndicator = null;
+
+ _prefObserverID = null;
+
+ get _defaultPanes() {
+ return ["info", "abstract", "attachments", "notes", "libraries-collections", "tags", "related"];
+ }
+
+ get _builtInPanes() {
+ return ["info", "abstract", "attachments", "notes", "attachment-info", "attachment-annotations", "libraries-collections", "tags", "related"];
+ }
+
get container() {
return this._container;
}
@@ -188,50 +148,92 @@
}
this.render();
}
-
- isPanePinnable(id) {
- return id !== 'info' && id !== 'context-notes' && id !== 'context-all-notes' && id !== 'context-item-notes';
+
+ get _wrappers() {
+ return Array.from(this._buttonContainer.querySelectorAll('.pin-wrapper'));
}
-
- init() {
- this._buttonContainer = this.querySelector('.inherit-flex');
- for (let toolbarbutton of this.querySelectorAll('toolbarbutton[data-pane]')) {
- let pane = toolbarbutton.dataset.pane;
-
- let pinnable = this.isPanePinnable(pane);
- toolbarbutton.parentElement.classList.toggle('pinnable', pinnable);
-
- if (pinnable) {
- toolbarbutton.addEventListener('contextmenu', (event) => {
- this._contextMenuTarget = pane;
- this.querySelector('.zotero-menuitem-pin').hidden = this.pinnedPane == pane;
- this.querySelector('.zotero-menuitem-unpin').hidden = this.pinnedPane != pane;
- this.querySelector('.context-menu')
- .openPopupAtScreen(event.screenX, event.screenY, true);
- });
+
+ get _enabledWrappers() {
+ return Array.from(this._buttonContainer.querySelectorAll('.pin-wrapper:not([hidden])'));
+ }
+
+ isPanePinnable(id) {
+ if (['context-notes', 'context-all-notes', 'context-item-notes'].includes(id)) {
+ return false;
+ }
+ // The first button in the group is not pinnable
+ if (this._buttonContainer.querySelector(".pin-wrapper:not([hidden])").querySelector(".btn").dataset.pane == id) {
+ return false;
+ }
+ return true;
+ }
+
+ isPaneOrderable(paneID) {
+ let orderable =
+ // Built-in or orderable custom sections
+ this._builtInPanes.includes(paneID) || Zotero.ItemPaneManager.isSectionOrderable(paneID);
+ return orderable;
+ }
+
+ isPaneMovable(paneID, direction) {
+ let wrappers = this._enabledWrappers;
+ let currentWrapper = this.querySelector(`.btn[data-pane=${CSS.escape(paneID)}]`).parentElement;
+ let currentIndex = wrappers.indexOf(currentWrapper);
+
+ let isOrderable = this.isPaneOrderable(paneID);
+ let isLast = currentIndex === wrappers.length - 1;
+ let isNextOrderable = !isLast && this.isPaneOrderable(
+ wrappers[currentIndex + 1]?.querySelector(".btn")?.dataset.pane);
+
+ if (direction === 'up') {
+ return isOrderable && currentIndex !== 0;
+ }
+ else if (direction === 'down') {
+ return isOrderable && isNextOrderable && !isLast;
+ }
+ }
+
+ isOrderChanged() {
+ let order = this.getPersistedOrder();
+ for (let i = 0; i < this._builtInPanes.length; i++) {
+ if (this._builtInPanes[i] != order[i]) {
+ return true;
}
}
+ return false;
+ }
+
+ init() {
+ this._buttonContainer = this.querySelector('.inherit-flex');
+ this.loadBuiltInButtons();
+ this.toggleDefaultStatus(true);
this.addEventListener('click', this.handleButtonClick);
this.addEventListener('keydown', this.handleKeyDown);
this.addEventListener('focusin', this.handleFocusIn);
- // Set up action toolbarbuttons
- for (let toolbarbutton of this.querySelectorAll('toolbarbutton[data-action]')) {
- let action = toolbarbutton.dataset.action;
+ // Set up action buttons
+ for (let button of this.querySelectorAll('.btn[data-action]')) {
+ let action = button.dataset.action;
if (action === 'locate') {
- toolbarbutton.addEventListener('mousedown', async (event) => {
- if (event.button !== 0 || toolbarbutton.open) {
+ button.addEventListener('mousedown', async (event) => {
+ if (event.button !== 0 || button.open) {
return;
}
event.preventDefault();
- let menu = toolbarbutton.querySelector('menupopup');
+ let menu = button.querySelector('menupopup');
await Zotero_LocateMenu.buildLocateMenu(menu);
await document.l10n.translateFragment(menu);
- toolbarbutton.open = true;
+ button.open = true;
});
}
}
+
+ this._buttonContainer.addEventListener('dragstart', this.handleButtonDragStart, true);
+ this._buttonContainer.addEventListener('dragover', this.handleButtonDragOver);
+ this._buttonContainer.addEventListener('drop', this.handleButtonDrop);
+ this._buttonContainer.addEventListener('dragend', this.handleButtonDragEnd);
+ this._buttonContainer.addEventListener('dragleave', this.handleButtonDragLeave);
this.querySelector('.zotero-menuitem-pin').addEventListener('command', () => {
this.container.scrollToPane(this._contextMenuTarget, 'smooth');
@@ -240,99 +242,237 @@
this.querySelector('.zotero-menuitem-unpin').addEventListener('command', () => {
this.pinnedPane = null;
});
+ this.querySelector('.zotero-menuitem-reorder-up').addEventListener('command', () => {
+ this.handlePaneMove(this._contextMenuTarget, 'up');
+ });
+ this.querySelector('.zotero-menuitem-reorder-down').addEventListener('command', () => {
+ this.handlePaneMove(this._contextMenuTarget, 'down');
+ });
+ this.querySelector('.zotero-menuitem-reorder-reset').addEventListener('command', () => {
+ this.resetPaneOrder();
+ });
this.setAttribute("role", "tablist");
+
+ this._prefObserverID = Zotero.Prefs.registerObserver("sidenav.order", this.handlePaneOrderChange);
+
+ this._initialized = true;
}
destroy() {
this.removeEventListener('click', this.handleButtonClick);
this.removeEventListener('keydown', this.handleKeyDown);
this.removeEventListener('focusin', this.handleFocusIn);
+
+ this._buttonContainer.removeEventListener('dragstart', this.handleButtonDragStart, true);
+ this._buttonContainer.removeEventListener('dragover', this.handleButtonDragOver);
+ this._buttonContainer.removeEventListener('drop', this.handleButtonDrop);
+ this._buttonContainer.removeEventListener('dragend', this.handleButtonDragEnd);
+ this._buttonContainer.removeEventListener('dragleave', this.handleButtonDragLeave);
+
+ Zotero.Prefs.unregisterObserver(this._prefObserverID);
+
+ this._initialized = false;
}
render() {
if (!this.container) return;
+
+ for (let paneElem of this.container.getPanes()) {
+ let paneID = paneElem.dataset.pane;
+ this.addPane(paneID);
+ this.updatePaneStatus(paneID);
+ }
+
let contextNotesPaneVisible = this._contextNotesPaneVisible;
let pinnedPane = this.pinnedPane;
- for (let toolbarbutton of this.querySelectorAll('toolbarbutton[data-pane]')) {
- let pane = toolbarbutton.dataset.pane;
+ for (let button of this.querySelectorAll('.btn[data-pane]')) {
+ let pane = button.dataset.pane;
// TEMP: never disable context notes button
if (this._contextNotesPane) {
- toolbarbutton.disabled = false;
+ button.removeAttribute('disabled');
}
if (pane == 'context-notes') {
let hidden = !this._contextNotesPane;
let selected = contextNotesPaneVisible;
- toolbarbutton.parentElement.hidden = hidden;
- toolbarbutton.parentElement.previousElementSibling.hidden = hidden; // Divider
+ button.parentElement.hidden = hidden;
+ button.parentElement.previousElementSibling.hidden = hidden; // Divider
- toolbarbutton.setAttribute('aria-selected', selected);
+ button.setAttribute('aria-selected', selected);
continue;
}
- toolbarbutton.closest("[role='tab']").setAttribute('aria-selected', !contextNotesPaneVisible);
+ button.closest("[role='tab']").setAttribute('aria-selected', !contextNotesPaneVisible);
// No need to set `hidden` here, since it's updated by ItemDetails#_handlePaneStatus
// Set .pinned on the container, for pin styling
- toolbarbutton.parentElement.classList.toggle('pinned', pane == pinnedPane);
+ button.parentElement.classList.toggle('pinned', pane == pinnedPane);
}
- for (let toolbarbutton of this.querySelectorAll('toolbarbutton[data-action]')) {
- let action = toolbarbutton.dataset.action;
+ for (let button of this.querySelectorAll('.btn[data-action]')) {
+ let action = button.dataset.action;
if (action == 'locate') {
- toolbarbutton.parentElement.hidden = false;
+ button.parentElement.hidden = false;
}
}
this.querySelector('.highlight-notes-active').classList.toggle('highlight', contextNotesPaneVisible);
this.querySelector('.highlight-notes-inactive').classList.toggle('highlight',
this._contextNotesPane && !contextNotesPaneVisible);
+
+ // Update the pane order
+ this.container.initPaneOrder(this.getPersistedOrder());
}
- addPane(paneID) {
- let toolbarbutton = this.querySelector(`toolbarbutton[data-pane=${paneID}]`);
- if (toolbarbutton) {
- toolbarbutton.parentElement.hidden = false;
+ async persistOrder(currentOrder = undefined) {
+ let panes = Array.from(this._buttonContainer.querySelectorAll('.btn[data-pane]'));
+ if (currentOrder === undefined) {
+ currentOrder = [];
+ for (let pane of panes) {
+ let paneID = pane.dataset.pane;
+ if (this.isPaneOrderable(paneID)) {
+ currentOrder.push(paneID);
+ }
+ }
+ }
+
+ currentOrder = [...currentOrder];
+ // Restore the order from installed plugins but not registered in the current order
+ let prevOrder = this.getPersistedOrder();
+ let installedPluginIDs = undefined;
+ for (let paneID of prevOrder) {
+ if (currentOrder.includes(paneID)) {
+ continue;
+ }
+ // If the pane ID is not in the current order, check if it's a plugin ID
+ if (!installedPluginIDs) {
+ installedPluginIDs = (await Zotero.Plugins.getAllPluginIDs()).map(
+ // Escape the plugin ID to match the pane ID generation
+ id => CSS.escape(id)
+ );
+ }
+
+ if (!installedPluginIDs.find(id => paneID.startsWith(id))) {
+ continue;
+ }
+
+ // If the pane ID is not in the current order, add it to the end
+ currentOrder.push(paneID);
+ }
+
+ Zotero.Prefs.set("sidenav.order", currentOrder.join(","));
+ }
+
+
+ getPersistedOrder(value = null) {
+ if (value === null) {
+ value = Zotero.Prefs.get("sidenav.order");
+ }
+ if (!value) return this._builtInPanes;
+ try {
+ return value.split(",");
+ }
+ catch(e) {
+ return this._builtInPanes;
+ }
+ }
+
+ addPane(paneID, order = null) {
+ let button = this.querySelector(`.btn[data-pane=${CSS.escape(paneID)}]`);
+ if (button) {
+ button.parentElement.hidden = false;
return;
}
- let pane = this.container.getPane(paneID);
- if (!pane) return;
- let sidenavOptions = {};
- try {
- sidenavOptions = JSON.parse(pane.dataset.sidenavOptions);
- }
- catch (e) {}
- let { icon, darkIcon, l10nID, l10nArgs } = sidenavOptions;
- if (!darkIcon) darkIcon = icon;
- toolbarbutton = document.createXULElement("toolbarbutton");
- toolbarbutton.setAttribute("custom", "true");
- toolbarbutton.dataset.pane = paneID;
- toolbarbutton.dataset.l10nId = l10nID;
- toolbarbutton.dataset.l10nArgs = l10nArgs;
- toolbarbutton.style = `--custom-sidenav-icon-light: url('${icon}'); --custom-sidenav-icon-dark: url('${darkIcon}');`;
- toolbarbutton.addEventListener('contextmenu', (event) => {
- this._contextMenuTarget = paneID;
- this.querySelector('.zotero-menuitem-pin').hidden = this.pinnedPane == paneID;
- this.querySelector('.zotero-menuitem-unpin').hidden = this.pinnedPane != paneID;
- this.querySelector('.context-menu')
- .openPopupAtScreen(event.screenX, event.screenY, true);
- });
+
+ button = document.createXULElement("div");
+ button.classList.add("btn");
+ button.dataset.pane = paneID;
+ button.addEventListener('contextmenu', this.handleButtonContextMenu);
let container = document.createElement("div");
container.classList.add("pin-wrapper");
container.classList.add("pinnable");
- container.append(toolbarbutton);
- if (this._defaultStatus) toolbarbutton.disabled = true;
- toolbarbutton.parentElement.hidden = this._defaultStatus || !this.container.getEnabledPane(paneID);
- this._buttonContainer.append(container);
+ container.append(button);
+ if (this._defaultStatus) button.setAttribute("disabled", "true");
+
+ let isBuiltin = this._builtInPanes.includes(paneID);
+ if (isBuiltin) {
+ button.dataset.l10nId = `sidenav-${paneID}`;
+ }
+ else {
+ let pane = this.container?.getPane(paneID);
+ if (!pane) return;
+ let sidenavOptions = {};
+ try {
+ sidenavOptions = JSON.parse(pane.dataset.sidenavOptions);
+ }
+ catch (e) {}
+ let { icon, darkIcon, l10nID, l10nArgs } = sidenavOptions;
+ if (!darkIcon) darkIcon = icon;
+ button.setAttribute("custom", "true");
+ button.dataset.l10nId = l10nID;
+ button.dataset.l10nArgs = l10nArgs;
+ button.style = `--custom-sidenav-icon-light: url('${icon}'); --custom-sidenav-icon-dark: url('${darkIcon}');`;
+
+ container.hidden = this._defaultStatus || !this.container.getEnabledPane(paneID);
+ }
+
+ if (this.isPaneOrderable(paneID)) {
+ container.draggable = true;
+ } else {
+ // If the pane is not orderable, always insert it at the end
+ this._buttonContainer.appendChild(container);
+ return;
+ }
+
+ // Insert the new button according to the persisted order.
+ if (order === null) {
+ order = this.getPersistedOrder();
+ }
+ let index = order.indexOf(paneID);
+ if (index >= 0) {
+ let children = Array.from(this._buttonContainer.children);
+ let inserted = false;
+ for (let child of children) {
+ let comparedPaneID = child.querySelector('.btn')?.dataset.pane;
+ // If the compared pane is not orderable, insert before it
+ if (!this.isPaneOrderable(comparedPaneID)) {
+ this._buttonContainer.insertBefore(container, child);
+ inserted = true;
+ break;
+ }
+ let comparedIndex = order.indexOf(comparedPaneID);
+ // If the compared pane should go after the new pane, insert before it
+ if (comparedIndex > index) {
+ this._buttonContainer.insertBefore(container, child);
+ inserted = true;
+ break;
+ }
+ }
+ if (!inserted) {
+ this._buttonContainer.appendChild(container);
+ }
+ } else {
+ this._buttonContainer.appendChild(container);
+ }
}
removePane(paneID) {
- let toolbarbutton = this.querySelector(`toolbarbutton[data-pane=${paneID}]`);
- if (!toolbarbutton) return;
- toolbarbutton.parentElement.remove();
+ let button = this.querySelector(`.btn[data-pane=${CSS.escape(paneID)}]`);
+ if (!button) return;
+ button.parentElement.remove();
+ }
+
+ loadBuiltInButtons() {
+ // Clear existing buttons in the first group.
+ this._buttonContainer.innerHTML = "";
+
+ let order = this.getPersistedOrder();
+ for (let paneID of this._builtInPanes) {
+ this.addPane(paneID, order);
+ }
}
updatePaneStatus(paneID) {
@@ -341,18 +481,77 @@
return;
}
- let toolbarbutton = this.querySelector(`toolbarbutton[data-pane=${paneID}]`);
- if (!toolbarbutton) return;
- toolbarbutton.parentElement.hidden = !this.container.getEnabledPane(paneID);
+ let button = this.querySelector(`.btn[data-pane=${CSS.escape(paneID)}]`);
+ if (!button) return;
+ button.parentElement.hidden = !this.container.getEnabledPane(paneID);
if (this.pinnedPane) {
- if (paneID == this.pinnedPane && !toolbarbutton.parentElement.classList.contains("pinned")) {
+ if (paneID == this.pinnedPane && !button.parentElement.classList.contains("pinned")) {
this.querySelector(".pin-wrapper.pinned")?.classList.remove("pinned");
- toolbarbutton.parentElement.classList.add('pinned');
+ button.parentElement.classList.add('pinned');
}
}
else {
this.querySelector(".pin-wrapper.pinned")?.classList.remove("pinned");
}
+
+ if (this._defaultStatus && this._defaultPanes.includes(paneID)) {
+ button.setAttribute("disabled", "true");
+ }
+ else {
+ button.removeAttribute("disabled");
+ }
+ }
+
+ /**
+ * Change the order of the panes in the sidenav.
+ * @param {string} paneID
+ * @param {number} newIndex
+ * @param {Object} options
+ * @param {boolean} options.render Whether to re-render the panes
+ * @param {boolean} options.scroll Whether to scroll to the new position
+ * @param {boolean} options.persist Whether to persist the new order
+ * @returns {Promise} Whether the order was changed
+ */
+ async changePaneOrder(paneID, newIndex, options = {}) {
+ let button = this.querySelector(`.btn[data-pane=${CSS.escape(paneID)}]`);
+ if (!button) return false;
+ let wrappers = this._wrappers;
+ let currentWrapper = button.parentElement;
+ let currentIndex = wrappers.indexOf(currentWrapper);
+ if (currentIndex == -1) return false;
+ // Inserting to the same position or the position before it does nothing
+ if (currentIndex == newIndex || currentIndex == newIndex - 1) return false;
+ if (newIndex < wrappers.length) {
+ this._buttonContainer.insertBefore(currentWrapper, wrappers[newIndex]);
+ }
+ else {
+ this._buttonContainer.appendChild(currentWrapper);
+ }
+
+ if (this.container) {
+ // Notify the container to update the pane order
+ await this.container.changePaneOrder(paneID, newIndex, {
+ render: options.render
+ });
+ }
+
+ // Update the pinned pane if it's no longer pinnable
+ if (!this.isPanePinnable(this.pinnedPane)) {
+ this.pinnedPane = null;
+ }
+
+ // If no pinned pane, scroll to the new position
+ // if (options.scroll !== false) {
+ // this.container.scrollToPane(paneID);
+ // }
+ if (options.persist !== false) {
+ await this.persistOrder();
+ }
+ return true;
+ }
+
+ async resetPaneOrder() {
+ await this.persistOrder([...this._builtInPanes]);
}
toggleDefaultStatus(isDefault) {
@@ -362,25 +561,49 @@
renderDefaultStatus() {
if (this._defaultStatus) {
- this.querySelectorAll('toolbarbutton[data-pane]').forEach((elem) => {
- elem.disabled = true;
- elem.parentElement.hidden = !(
- ["info", "abstract", "attachments", "notes", "libraries-collections", "tags", "related"]
- .includes(elem.dataset.pane));
+ this.querySelectorAll('.btn[data-pane]').forEach((elem) => {
+ elem.setAttribute("disabled", "true");
+ elem.parentElement.hidden = !this._defaultPanes.includes(elem.dataset.pane);
});
- this.querySelectorAll('toolbarbutton[data-action]').forEach((elem) => {
- elem.disabled = false;
+ this.querySelectorAll('.btn[data-action]').forEach((elem) => {
+ elem.removeAttribute("disabled");
});
}
else {
- this.querySelectorAll('toolbarbutton').forEach((elem) => {
- elem.disabled = false;
+ this.querySelectorAll('.btn').forEach((elem) => {
+ elem.removeAttribute("disabled");
});
this.render();
}
}
+ /**
+ * Compute the drop position based on the offset from the button container.
+ * @param {number} offset Offset from the left or top of the button container
+ * @returns {Object} Object containing the index and position of the drop position
+ * @property {number} index Index of the drop position after the drop in the enabled wrappers
+ * @property {number} position Position of the drop indicator
+ */
+ computeDropPosition(offset) {
+ // Keep in sync with _itemPaneSidenav.scss
+ let btnSize = 28;
+ let btnGap = 6;
+ // Before the center of the button, insert before it; otherwise, insert after it.
+ let index = 0;
+ if (offset < btnSize / 2) {
+ index = 0;
+ }
+ else {
+ offset -= btnSize / 2;
+ index = Math.floor(offset / (btnSize + btnGap)) + 1;
+ }
+ return {
+ index,
+ position: index === 0 ? 0 : index * (btnSize + btnGap) + btnGap / 2
+ }
+ };
+
handleKeyDown = (event) => {
if (event.key == "Tab" && !event.shiftKey) {
// Wrap focus around to the tab bar
@@ -415,7 +638,7 @@
if (!(event.target == this._buttonContainer || event.target.closest(".highlight-notes-active"))) return;
// Click the first itemPane button in a group to switch from notes to item details pane
if (event.target === this._buttonContainer && this._contextNotesPaneVisible) {
- let firstBtn = event.target.querySelector("toolbarbutton");
+ let firstBtn = event.target.querySelector(".btn");
let clickEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
@@ -467,8 +690,8 @@
};
handleButtonClick = (event) => {
- let toolbarbutton = event.target;
- let pane = toolbarbutton.dataset.pane;
+ let button = event.target;
+ let pane = button.dataset.pane;
if (!pane) return;
switch (pane) {
case "context-notes":
@@ -508,6 +731,174 @@
}
this.render();
};
+
+ handleButtonContextMenu = (event) => {
+ event.preventDefault();
+ let button = event.target;
+ let paneID = button.dataset.pane;
+ if (!paneID) return;
+ this._contextMenuTarget = paneID;
+
+ let isPinnable = this.isPanePinnable(paneID);
+ this.querySelector('.zotero-menuitem-pin').hidden = !isPinnable || this.pinnedPane == paneID;
+ this.querySelector('.zotero-menuitem-unpin').hidden = !isPinnable || this.pinnedPane != paneID;
+ this.querySelector('.zotero-menuitem-pin-separator').hidden = !isPinnable;
+
+ this.querySelector('.zotero-menuitem-reorder-up').hidden = !this.isPaneMovable(paneID, 'up');
+ this.querySelector('.zotero-menuitem-reorder-down').hidden = !this.isPaneMovable(paneID, 'down');
+ this.querySelector('.zotero-menuitem-reorder-reset').hidden = !this.isOrderChanged();
+
+ this.querySelector('.context-menu')
+ .openPopupAtScreen(event.screenX, event.screenY, true);
+ };
+
+ handlePaneMove = (paneID, direction) => {
+ let enabledWrappers = this._enabledWrappers;
+ let currentWrapper = this.querySelector(`.btn[data-pane=${CSS.escape(paneID)}]`).parentElement;
+ let currentIndex = enabledWrappers.indexOf(currentWrapper);
+ let targetIndex;
+ if (direction === 'up') {
+ targetIndex = currentIndex - 1;
+ }
+ else if (direction === 'down') {
+ targetIndex = currentIndex + 2;
+ }
+ else {
+ return;
+ }
+ let targetWrapper = enabledWrappers[targetIndex];
+ if (targetWrapper) {
+ // Insert at the index of the previous wrapper
+ this.changePaneOrder(paneID, this._wrappers.indexOf(targetWrapper));
+ }
+ }
+
+ handleButtonDragStart = (event) => {
+ let wrapper = event.target.closest('.pin-wrapper');
+ if (!wrapper) return;
+ let button = wrapper.querySelector(".btn");
+ if (button.hasAttribute("disabled")) return;
+ let paneID = button.dataset.pane;
+ if (!this.isPaneOrderable(paneID)) return;
+ this._draggedWrapper = wrapper;
+ event.dataTransfer.dropEffect = "move";
+ // Create a clone for a custom drag image.
+ let clone = button.cloneNode(true);
+ clone.style.position = "absolute";
+ clone.style.top = "-1000px";
+ clone.style.left = "-1000px";
+ this.appendChild(clone);
+ event.dataTransfer.setDragImage(clone, 0, 0);
+ // Remove the clone after the drag has started.
+ setTimeout(() => {
+ clone.remove();
+ }, 0);
+ // Set the data to the pane ID.
+ event.dataTransfer.setData("zotero/sidenav", paneID);
+ };
+
+ handleButtonDragOver = (event) => {
+ let paneID = event.dataTransfer.getData("zotero/sidenav");
+ if (!paneID) return;
+ event.preventDefault();
+
+ let rect = this._buttonContainer.getBoundingClientRect();
+ let isStacked = this.classList.contains("stacked");
+ let offset = isStacked ? event.clientX - rect.left : event.clientY - rect.top;
+ let { index, position } = this.computeDropPosition(offset);
+
+ let currentIndex = this._enabledWrappers.indexOf(this._draggedWrapper);
+ if (
+ // Dragging the button to the same position
+ currentIndex === index || currentIndex === index - 1
+ // Dragging the button after the non-orderable button
+ || (
+ index > 0
+ && !this.isPaneOrderable(this._enabledWrappers[index - 1].querySelector(".btn").dataset.pane)
+ )
+ ) {
+ this._dropIndicator?.setAttribute("hidden", "true");
+ // Make pointer forbidden
+ event.dataTransfer.dropEffect = "none";
+ return;
+ }
+
+ event.dataTransfer.dropEffect = "move";
+
+ // Create a drop indicator if one doesn't exist.
+ if (!this._dropIndicator) {
+ this._dropIndicator = document.createElement('div');
+ this._dropIndicator.classList.add('drop-indicator');
+ this._buttonContainer.appendChild(this._dropIndicator);
+ }
+
+ this.style.setProperty("--drop-indicator-offset", `${position}px`);
+
+ this._dropIndicator.removeAttribute("hidden");
+ };
+
+ handleButtonDrop = async (event) => {
+ let paneID = event.dataTransfer.getData("zotero/sidenav");
+ if (!paneID) return;
+ event.preventDefault();
+
+ let rect = this._buttonContainer.getBoundingClientRect();
+ let isStacked = this.classList.contains("stacked");
+ let offset = isStacked ? event.clientX - rect.left : event.clientY - rect.top;
+ let { index } = this.computeDropPosition(offset);
+
+ // Drop the button after the non-orderable button is not allowed
+ if (
+ index > 0
+ && !this.isPaneOrderable(this._enabledWrappers[index - 1].querySelector(".btn").dataset.pane)) {
+ return;
+ }
+ let actualIndex = this._wrappers.indexOf(this._enabledWrappers[index]);
+
+ await this.changePaneOrder(paneID, actualIndex);
+ };
+
+ handleButtonDragEnd = (event) => {
+ if (!event.dataTransfer.types.includes("zotero/sidenav")) {
+ return;
+ }
+ this._draggedWrapper = null;
+ // Clean up the drop indicator if it still exists.
+ if (this._dropIndicator) {
+ this._dropIndicator.remove();
+ this._dropIndicator = null;
+ }
+ };
+
+ handleButtonDragLeave = (event) => {
+ if (this._dropIndicator) {
+ this._dropIndicator.setAttribute("hidden", "true");
+ }
+ };
+
+ handlePaneOrderChange = async (value) => {
+ // If no container, wait until it's set
+ if (!this.container) return;
+ let order = this.getPersistedOrder(value);
+ let hasChange = false;
+ for (let i = 0; i < order.length; i++) {
+ let paneID = order[i];
+ let changed = await this.changePaneOrder(paneID, i, {
+ scroll: false,
+ persist: false,
+ render: false
+ });
+ if (changed && !hasChange) {
+ hasChange = true;
+ }
+ }
+ if (hasChange) {
+ if (this.pinnedPane) {
+ this.container?.scrollToPane(this.pinnedPane, 'instant');
+ }
+ this.container?.render();
+ }
+ }
}
customElements.define("item-pane-sidenav", ItemPaneSidenav);
}
diff --git a/chrome/content/zotero/elements/librariesCollectionsBox.js b/chrome/content/zotero/elements/librariesCollectionsBox.js
index 5e21f9df1c..f2097996b1 100644
--- a/chrome/content/zotero/elements/librariesCollectionsBox.js
+++ b/chrome/content/zotero/elements/librariesCollectionsBox.js
@@ -66,15 +66,14 @@ import { getCSSIcon } from 'components/icons';
this._body = this.querySelector('.body');
this.initCollapsibleSection();
this._addPopup = this.querySelector('.add-popup');
- this._addPopup.addEventListener('popupshowing', (event) => {
- ZoteroPane.buildAddItemToCollectionMenu(event, [this._item]);
- });
+ this._addPopup.addEventListener('popupshowing', this._handleAddPopupShowing);
this._section.addEventListener('add', this._handleAdd);
}
destroy() {
Zotero.Notifier.unregisterObserver(this._notifierID);
this._section?.removeEventListener('add', this._handleAdd);
+ this._addPopup?.removeEventListener('popupshowing', this._handleAddPopupShowing);
}
notify(action, type, ids) {
@@ -279,6 +278,10 @@ import { getCSSIcon } from 'components/icons';
);
this._section.open = true;
};
+
+ _handleAddPopupShowing = (event) => {
+ ZoteroPane.buildAddItemToCollectionMenu(event, [this._item]);
+ };
}
customElements.define("libraries-collections-box", LibrariesCollectionsBox);
}
diff --git a/chrome/content/zotero/elements/noteEditor.js b/chrome/content/zotero/elements/noteEditor.js
index 9b62b7a74e..38d50cf4da 100644
--- a/chrome/content/zotero/elements/noteEditor.js
+++ b/chrome/content/zotero/elements/noteEditor.js
@@ -192,13 +192,16 @@
}
if (ids.includes(id) || this._parentItem && ids.includes(this._parentItem.id)) {
- this._id('links-box').refresh();
+ this._id('links-box')?.refresh();
}
};
set notitle(val) {
this._notitle = !!val;
- this._id('links-box').notitle = val;
+ let linksBox = this._id('links-box');
+ if (linksBox) {
+ linksBox.notitle = val;
+ }
}
set navigateHandler(val) {
@@ -407,8 +410,8 @@
}
refresh() {
- this._id('related').render();
- this._id('tags').render();
+ this._id('related')?.render();
+ this._id('tags')?.render();
}
_id(id) {
diff --git a/chrome/content/zotero/elements/notesBox.js b/chrome/content/zotero/elements/notesBox.js
index eb6b8c45db..9dc1139977 100644
--- a/chrome/content/zotero/elements/notesBox.js
+++ b/chrome/content/zotero/elements/notesBox.js
@@ -36,7 +36,6 @@ import { getCSSItemTypeIcon } from 'components/icons';
`);
init() {
- this._item = null;
this._noteIDs = [];
this.initCollapsibleSection();
this._section.addEventListener('add', this._handleAdd);
diff --git a/chrome/content/zotero/elements/relatedBox.js b/chrome/content/zotero/elements/relatedBox.js
index da644d5595..ab97717fc8 100644
--- a/chrome/content/zotero/elements/relatedBox.js
+++ b/chrome/content/zotero/elements/relatedBox.js
@@ -36,7 +36,6 @@ import { getCSSItemTypeIcon } from 'components/icons';
`);
init() {
- this._item = null;
this._notifierID = Zotero.Notifier.registerObserver(this, ['item'], 'relatedbox');
this.initCollapsibleSection();
this._section.addEventListener('add', this.add);
diff --git a/chrome/content/zotero/elements/tagsBox.js b/chrome/content/zotero/elements/tagsBox.js
index 19ff43b2a0..2b909e3f89 100644
--- a/chrome/content/zotero/elements/tagsBox.js
+++ b/chrome/content/zotero/elements/tagsBox.js
@@ -47,7 +47,6 @@
this._tabDirection = null;
this._tagColors = [];
this._notifierID = null;
- this._item = null;
this.initCollapsibleSection();
this._section.addEventListener('add', this._handleAddButtonClick);
diff --git a/chrome/content/zotero/xpcom/pluginAPI/itemPaneManager.js b/chrome/content/zotero/xpcom/pluginAPI/itemPaneManager.js
index 708dbaee42..7c29f1978e 100644
--- a/chrome/content/zotero/xpcom/pluginAPI/itemPaneManager.js
+++ b/chrome/content/zotero/xpcom/pluginAPI/itemPaneManager.js
@@ -198,6 +198,10 @@
type: "string",
optional: true,
},
+ orderable: {
+ type: "boolean",
+ optional: true,
+ },
}
},
sectionButtons: {
@@ -328,6 +332,14 @@
return this._sectionManager.data;
}
+ isSectionOrderable(paneID) {
+ let option = this._sectionManager._optionsCache[paneID];
+ if (!option) {
+ return false;
+ }
+ return option.sidenav.orderable ?? true;
+ }
+
registerInfoRow(options) {
return this._infoRowManager.register(options);
}
diff --git a/chrome/content/zotero/xpcom/pluginAPI/pluginAPIBase.mjs b/chrome/content/zotero/xpcom/pluginAPI/pluginAPIBase.mjs
index a4198e0642..d25f62b4ac 100644
--- a/chrome/content/zotero/xpcom/pluginAPI/pluginAPIBase.mjs
+++ b/chrome/content/zotero/xpcom/pluginAPI/pluginAPIBase.mjs
@@ -314,7 +314,7 @@ class PluginAPIBase {
let pluginID = this._getOptionPluginID(option);
if (pluginID && mainKey) {
// Make sure the return value is valid as class name or element id
- return CSS.escape(`${pluginID}-${mainKey}`.replace(/[^a-zA-Z0-9-_]/g, "-"));
+ return CSS.escape(`${pluginID}-${mainKey}`);
}
return mainKey;
}
diff --git a/chrome/content/zotero/xpcom/plugins.js b/chrome/content/zotero/xpcom/plugins.js
index cb483cb817..4175f7eee2 100644
--- a/chrome/content/zotero/xpcom/plugins.js
+++ b/chrome/content/zotero/xpcom/plugins.js
@@ -309,6 +309,12 @@ Zotero.Plugins = new function () {
};
+ this.getAllPluginIDs = async function () {
+ let addons = await AddonManager.getAddonsByTypes(["extension"]);
+ return addons.map(addon => addon.id);
+ }
+
+
/**
* @param {String} id
* @param {Number} idealSize In logical pixels (scaled automatically on hiDPI displays)
diff --git a/chrome/locale/en-US/zotero/zotero.ftl b/chrome/locale/en-US/zotero/zotero.ftl
index 379922383f..bd3b6e6183 100644
--- a/chrome/locale/en-US/zotero/zotero.ftl
+++ b/chrome/locale/en-US/zotero/zotero.ftl
@@ -523,6 +523,12 @@ sidenav-related =
.tooltiptext = { pane-related }
sidenav-main-btn-grouping =
.aria-label = { pane-item-details }
+sidenav-reorder-up =
+ .label = Move Section Up
+sidenav-reorder-down =
+ .label = Move Section Down
+sidenav-reorder-reset =
+ .label = Reset Section Order
pin-section =
.label = Pin Section
diff --git a/chrome/skin/default/zotero/16/universal/down.svg b/chrome/skin/default/zotero/16/universal/down.svg
new file mode 100644
index 0000000000..f5184788a2
--- /dev/null
+++ b/chrome/skin/default/zotero/16/universal/down.svg
@@ -0,0 +1,3 @@
+
diff --git a/chrome/skin/default/zotero/16/universal/reset.svg b/chrome/skin/default/zotero/16/universal/reset.svg
new file mode 100644
index 0000000000..8e34751bc4
--- /dev/null
+++ b/chrome/skin/default/zotero/16/universal/reset.svg
@@ -0,0 +1,3 @@
+
diff --git a/chrome/skin/default/zotero/16/universal/up.svg b/chrome/skin/default/zotero/16/universal/up.svg
new file mode 100644
index 0000000000..c62cccff2b
--- /dev/null
+++ b/chrome/skin/default/zotero/16/universal/up.svg
@@ -0,0 +1,3 @@
+
diff --git a/scss/components/_menu.scss b/scss/components/_menu.scss
index a4e18de9d4..c86a79dd55 100644
--- a/scss/components/_menu.scss
+++ b/scss/components/_menu.scss
@@ -51,6 +51,9 @@ $menu-icons: (
retrieve-metadata: "retrieve-metadata",
unrecognize: "restore",
reindex: "reindex",
+ reorder-up: "up",
+ reorder-down: "down",
+ reorder-reset: "reset",
create-parent: "page",
rename-from-parent: "rename",
create-note-from-annotations: "light-dark:note-annotation",
diff --git a/scss/elements/_itemPaneSidenav.scss b/scss/elements/_itemPaneSidenav.scss
index afe89fff36..c1a23a5c5e 100644
--- a/scss/elements/_itemPaneSidenav.scss
+++ b/scss/elements/_itemPaneSidenav.scss
@@ -65,7 +65,7 @@ item-pane-sidenav {
}
}
- toolbarbutton {
+ .btn {
// TODO: Extract button styles?
width: 28px;
@@ -73,16 +73,29 @@ item-pane-sidenav {
margin: 0;
padding: 4px;
pointer-events: all;
+ background-repeat: no-repeat;
+ background-position: center;
+ -moz-context-properties: fill, fill-opacity, stroke, stroke-opacity;
+ border-radius: 5px;
+ box-sizing: border-box;
&:disabled,
&[disabled="true"] {
opacity: 60%;
pointer-events: none;
}
+
+ &:hover {
+ background-color: var(--fill-quinary);
+ }
+
+ &:active {
+ background-color: var(--fill-quarternary);
+ }
@each $pane, $color in $item-pane-sections {
&[data-pane="#{$pane}"] {
- list-style-image: url("chrome://zotero/skin/itempane/20/#{$pane}.svg");
+ background-image: url("chrome://zotero/skin/itempane/20/#{$pane}.svg");
fill: $color;
stroke: $color;
}
@@ -90,13 +103,14 @@ item-pane-sidenav {
// Notes context pane button
&[data-pane="context-notes"] {
- list-style-image: url("chrome://zotero/skin/itempane/20/notes.svg");
+ background-image: url("chrome://zotero/skin/itempane/20/notes.svg");
fill: map.get($item-pane-sections, "notes");
stroke: map.get($item-pane-sections, "notes");
}
// Locate button
&[data-action="locate"] {
+ color: var(--fill-secondary);
@include svgicon-menu("go-to", "universal", "20");
&:-moz-locale-dir(rtl) {
@@ -106,10 +120,10 @@ item-pane-sidenav {
&[custom] {
@media (prefers-color-scheme: light) {
- list-style-image: var(--custom-sidenav-icon-light);
+ background-image: var(--custom-sidenav-icon-light);
}
@media (prefers-color-scheme: dark) {
- list-style-image: var(--custom-sidenav-icon-dark);
+ background-image: var(--custom-sidenav-icon-dark);
}
fill: var(--fill-secondary);
stroke: var(--fill-secondary);
@@ -132,4 +146,32 @@ item-pane-sidenav {
.context-menu {
@include macOS-hide-menu-icons;
}
+
+ .drop-indicator {
+ position: absolute;
+ display: block;
+ background-color: var(--color-accent);
+ z-index: 1000;
+ --drop-indicator-size: 2px;
+
+ &[hidden] {
+ display: none;
+ }
+ }
+
+ &.stacked .drop-indicator {
+ left: var(--drop-indicator-offset);
+ top: 0;
+ width: var(--drop-indicator-size);
+ height: 28px;
+ margin-block: 4px;
+ }
+
+ &:not(.stacked) .drop-indicator {
+ top: var(--drop-indicator-offset);
+ left: 0;
+ width: 28px;
+ height: var(--drop-indicator-size);
+ margin-inline: 4px;
+ }
}
diff --git a/test/content/support.js b/test/content/support.js
index fcde87eb37..d0c1b0e599 100644
--- a/test/content/support.js
+++ b/test/content/support.js
@@ -353,6 +353,17 @@ function waitForNotifierEvent(event, type) {
return deferred.promise;
}
+async function waitForPrefsChange(key, global) {
+ var deferred = Zotero.Promise.defer();
+ let observerID;
+ var observer = function() {
+ Zotero.Prefs.unregisterObserver(observerID);
+ deferred.resolve();
+ };
+ observerID = Zotero.Prefs.registerObserver(key, observer, global);
+ return deferred.promise;
+}
+
/**
* Hang tests for manual inspection
*/
diff --git a/test/tests/itemPaneTest.js b/test/tests/itemPaneTest.js
index ce2595fdf7..422728aae8 100644
--- a/test/tests/itemPaneTest.js
+++ b/test/tests/itemPaneTest.js
@@ -1838,4 +1838,243 @@ describe("Item pane", function () {
itemDetails.pinnedPane = "";
});
});
+
+ describe("Sidenav", function () {
+ async function waitForSidenav() {
+ await waitForCallback(() => {
+ return !!ZoteroPane.itemPane._itemDetails.sidenav?._initialized;
+ });
+ }
+
+ async function waitForSidenavActive() {
+ await waitForCallback(() => {
+ return !ZoteroPane.itemPane._itemDetails.sidenav._defaultStatus;
+ });
+ }
+
+ function compareOrder(order, targetOrder) {
+ let lastIndex = -1;
+ for (let paneID of order) {
+ let index = targetOrder.indexOf(paneID);
+ if (index === -1) {
+ continue;
+ }
+ if (index < lastIndex) {
+ return false;
+ }
+ lastIndex = index;
+ }
+ return true;
+ }
+
+ function compareButtonOrder(order) {
+ let wrappers = ZoteroPane.itemPane._itemDetails.sidenav._enabledWrappers;
+ let buttonOrder = wrappers.map(wrapper => wrapper.querySelector('.btn').dataset.pane);
+ return compareOrder(buttonOrder, order)
+ }
+
+ function compareSectionOrder(order) {
+ let sections = ZoteroPane.itemPane._itemDetails.getEnabledPanes();
+ let sectionOrder = sections.map(section => section.dataset.pane);
+ return compareOrder(sectionOrder, order)
+ }
+
+ function getSidenavOrder() {
+ return Zotero.Prefs.get('sidenav.order') || ZoteroPane.itemPane._itemDetails.sidenav._builtInPanes.join(',');
+ }
+
+ async function clickSidenavMenu(paneIdx, menuSelector) {
+ let sidenav = ZoteroPane.itemPane._itemDetails.sidenav;
+ if (paneIdx < 0) {
+ paneIdx = sidenav._enabledWrappers.length + paneIdx;
+ }
+ let btn = sidenav._enabledWrappers[paneIdx].querySelector('.btn');
+ let btnRect = btn.getBoundingClientRect();
+ let popup = sidenav.querySelector('.context-menu');
+ let promise = waitForDOMEvent(popup, 'popupshown');
+ sidenav.handleButtonContextMenu({
+ target: btn,
+ preventDefault: function () {},
+ screenX: btnRect.left,
+ screenY: btnRect.top,
+ });
+ await promise;
+ let menu = popup.querySelector(menuSelector);
+ if (!menu || menu.hidden) {
+ popup.hidePopup();
+ return false;
+ }
+ menu.click();
+ popup.hidePopup();
+ return true;
+ }
+
+ it("should reorder section when prefs change", async function () {
+ await waitForSidenav();
+
+ let orderRaw = getSidenavOrder();
+ let order = orderRaw.split(',');
+ let newOrder = order.reverse()
+ let newOrderRaw = newOrder.join(',');
+ Zotero.Prefs.set('sidenav.order', newOrderRaw);
+
+ // If the order is not updated, a timeout exception will be thrown to fail the test
+ await waitForCallback(() => {
+ return compareSectionOrder(newOrder) && compareButtonOrder(newOrder);
+ }
+ , 100, 3);
+ });
+
+ it("should move section up", async function () {
+ await waitForSidenav();
+
+ // Create an item so that the sidenav is active
+ let item = await createDataObject('item');
+ await ZoteroPane.selectItem(item.id);
+
+ await waitForSidenavActive();
+
+ let orderRaw = getSidenavOrder();
+ let order = orderRaw.split(',');
+
+ let promise = waitForPrefsChange('sidenav.order');
+ let menuEnabled = await clickSidenavMenu(1, '.zotero-menuitem-reorder-up');
+ assert.isTrue(menuEnabled);
+ await promise;
+
+ let newOrderRaw = getSidenavOrder();
+
+ let newOrder = newOrderRaw.split(',');
+ let expectedOrder = [...order];
+ // Exchange 0 and 1
+ let temp = expectedOrder[0];
+ expectedOrder[0] = expectedOrder[1];
+ expectedOrder[1] = temp;
+ assert.deepEqual(newOrder, expectedOrder);
+
+ // Remove the temp item
+ await Zotero.Items.erase(item.id);
+ });
+
+ it("should move section down", async function () {
+ await waitForSidenav();
+
+ // Create an item so that the sidenav is active
+ let item = await createDataObject('item');
+ await ZoteroPane.selectItem(item.id);
+
+ await waitForSidenavActive();
+
+ let orderRaw = getSidenavOrder();
+ let order = orderRaw.split(',');
+ let promise = waitForPrefsChange('sidenav.order');
+ let menuEnabled = await clickSidenavMenu(0, '.zotero-menuitem-reorder-down');
+ assert.isTrue(menuEnabled);
+ await promise;
+
+ let newOrderRaw = getSidenavOrder();
+
+ let newOrder = newOrderRaw.split(',');
+ let expectedOrder = [...order];
+ // Exchange 0 and 1
+ let temp = expectedOrder[0];
+ expectedOrder[0] = expectedOrder[1];
+ expectedOrder[1] = temp;
+ assert.deepEqual(newOrder, expectedOrder);
+
+ // Remove the temp item
+ await Zotero.Items.erase(item.id);
+ });
+
+ it("should not show move up menu for first section", async function () {
+ await waitForSidenav();
+
+ // Create an item so that the sidenav is active
+ let item = await createDataObject('item');
+ await ZoteroPane.selectItem(item.id);
+
+ await waitForSidenavActive();
+
+ let menuEnabled = await clickSidenavMenu(0, '.zotero-menuitem-reorder-up');
+ assert.isFalse(menuEnabled);
+
+ await Zotero.Items.erase(item.id);
+ });
+
+ it("should not show move down menu for last section", async function () {
+ await waitForSidenav();
+
+ // Create an item so that the sidenav is active
+ let item = await createDataObject('item');
+ await ZoteroPane.selectItem(item.id);
+
+ await waitForSidenavActive();
+
+ let menuEnabled = await clickSidenavMenu(-1, '.zotero-menuitem-reorder-down');
+ assert.isFalse(menuEnabled);
+
+ await Zotero.Items.erase(item.id);
+ });
+
+ it("should unpin section if it moves to the top", async function () {
+ await waitForSidenav();
+
+ // Create an item so that the sidenav is active
+ let item = await createDataObject('item');
+ await ZoteroPane.selectItem(item.id);
+
+ await waitForSidenavActive();
+
+ let orderRaw = getSidenavOrder();
+ let order = orderRaw.split(',');
+
+ // Pin the second section
+ ZoteroPane.itemPane._itemDetails.pinnedPane = order[1];
+
+ let promise = waitForPrefsChange('sidenav.order');
+ let menuEnabled = await clickSidenavMenu(1, '.zotero-menuitem-reorder-up');
+ assert.isTrue(menuEnabled);
+ await promise;
+
+ assert.isEmpty(ZoteroPane.itemPane._itemDetails.pinnedPane);
+
+ await Zotero.Items.erase(item.id);
+ });
+
+ it("should not show reorder menu for custom section with orderable disabled", async function () {
+ await waitForSidenav();
+
+ const registeredID = Zotero.ItemPaneManager.registerSection({
+ paneID: "custom-section-example",
+ pluginID: "example@example.com",
+ header: {
+ l10nID: "example-item-pane-header",
+ icon: "chrome://zotero/skin/16/universal/note.svg",
+ },
+ sidenav: {
+ l10nID: "example-item-pane-header",
+ icon: "chrome://zotero/skin/20/universal/note.svg",
+ // Disable orderable
+ orderable: false,
+ },
+ onRender: ({ body }) => {
+ body.textContent = "Custom section";
+ },
+ });
+
+ // Create an item so that the sidenav is active
+ let item = await createDataObject('item');
+ await ZoteroPane.selectItem(item.id);
+
+ await waitForSidenavActive();
+
+ let menuUpEnabled = await clickSidenavMenu(-1, '.zotero-menuitem-reorder-up');
+ assert.isFalse(menuUpEnabled);
+ let menuDownEnabled = await clickSidenavMenu(-1, '.zotero-menuitem-reorder-down');
+ assert.isFalse(menuDownEnabled);
+
+ await Zotero.Items.erase(item.id);
+ Zotero.ItemPaneManager.unregisterSection(registeredID);
+ });
+ });
});
diff --git a/test/tests/pluginAPITest.js b/test/tests/pluginAPITest.js
index 7630c43493..33a58b51e3 100644
--- a/test/tests/pluginAPITest.js
+++ b/test/tests/pluginAPITest.js
@@ -89,7 +89,7 @@ describe("Plugin API", function () {
let result = await getDataPromise;
// Should render custom row
- let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`);
+ let rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
assert.exists(rowElem);
// Should call onGetData and render
@@ -113,7 +113,7 @@ describe("Plugin API", function () {
let rowID = await waitForRegister(option);
- let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`);
+ let rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
let valueElem = rowElem.querySelector(".value");
// Should call onSetData on value change
@@ -152,7 +152,7 @@ describe("Plugin API", function () {
let rowID = await waitForRegister(option);
let result = await itemChangePromise;
- let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`);
+ let rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
let valueElem = rowElem.querySelector(".value");
// Should be enabled and editable
@@ -192,7 +192,7 @@ describe("Plugin API", function () {
result = await itemChangePromise;
let itemDetails = ZoteroContextPane.context._getItemContext(tabID);
- rowElem = itemDetails.getPane("info").querySelector(`[data-custom-row-id="${rowID}"]`);
+ rowElem = itemDetails.getPane("info").querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
valueElem = rowElem.querySelector(".value");
// Should not be enabled in non-library tab
@@ -219,14 +219,14 @@ describe("Plugin API", function () {
// Row at start
let rowID = await waitForRegister(startOption);
- let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`);
+ let rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
assert.notExists(rowElem.previousElementSibling);
await waitForUnregister(rowID);
// Row after creator rows
rowID = await waitForRegister(afterCreatorsOption);
- rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`);
+ rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
assert.exists(rowElem.previousElementSibling.querySelector(".creator-type-value"));
assert.notExists(rowElem.nextElementSibling.querySelector(".creator-type-value"));
@@ -234,7 +234,7 @@ describe("Plugin API", function () {
// Row at end
rowID = rowID = await waitForRegister(endOption);
- rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`);
+ rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
assert.exists(rowElem.nextElementSibling.querySelector("*[fieldname=dateAdded]"));
await waitForUnregister(rowID);
@@ -254,7 +254,7 @@ describe("Plugin API", function () {
let rowID = await waitForRegister(defaultOption);
- let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`);
+ let rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
let valueElem = rowElem.querySelector(".value");
assert.isFalse(valueElem.readOnly);
@@ -263,7 +263,7 @@ describe("Plugin API", function () {
rowID = await waitForRegister(editableOption);
- rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`);
+ rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
valueElem = rowElem.querySelector(".value");
assert.isFalse(valueElem.readOnly);
@@ -272,7 +272,7 @@ describe("Plugin API", function () {
rowID = await waitForRegister(notEditableOption);
- rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`);
+ rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
valueElem = rowElem.querySelector(".value");
assert.isTrue(valueElem.readOnly);
@@ -294,7 +294,7 @@ describe("Plugin API", function () {
let rowID = await waitForRegister(defaultOption);
- let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`);
+ let rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
let valueElem = rowElem.querySelector(".value");
assert.isFalse(valueElem.multiline);
@@ -303,7 +303,7 @@ describe("Plugin API", function () {
rowID = await waitForRegister(multilineOption);
- rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`);
+ rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
valueElem = rowElem.querySelector(".value");
assert.isTrue(valueElem.multiline);
@@ -312,7 +312,7 @@ describe("Plugin API", function () {
rowID = await waitForRegister(notMultilineOption);
- rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`);
+ rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
valueElem = rowElem.querySelector(".value");
assert.isFalse(valueElem.multiline);
@@ -334,7 +334,7 @@ describe("Plugin API", function () {
let rowID = await waitForRegister(defaultOption);
- let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`);
+ let rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
let valueElem = rowElem.querySelector(".value");
assert.isFalse(valueElem.noWrap);
@@ -343,7 +343,7 @@ describe("Plugin API", function () {
rowID = await waitForRegister(noWrapOption);
- rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`);
+ rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
valueElem = rowElem.querySelector(".value");
assert.isTrue(valueElem.noWrap);
@@ -352,7 +352,7 @@ describe("Plugin API", function () {
rowID = await waitForRegister(wrapOption);
- rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`);
+ rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
valueElem = rowElem.querySelector(".value");
assert.isFalse(valueElem.noWrap);
@@ -367,7 +367,7 @@ describe("Plugin API", function () {
let rowID = await waitForRegister(defaultOption);
- let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`);
+ let rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
let valueElem = rowElem.querySelector(".value");
let oldValue = valueElem.value;
@@ -397,7 +397,7 @@ describe("Plugin API", function () {
let rowID = await waitForRegister(defaultOption);
- let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`);
+ let rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
let valueElem = rowElem.querySelector(".value");
let value = valueElem.value;
@@ -458,7 +458,7 @@ describe("Plugin API", function () {
// Wait for column header to render
await waitForCallback(
- () => !!doc.querySelector(`#zotero-items-tree .virtualized-table-header .cell.${dataKey}`),
+ () => !!doc.querySelector(`#zotero-items-tree .virtualized-table-header .cell.${CSS.escape(dataKey)}`),
100, 3);
};
@@ -470,7 +470,7 @@ describe("Plugin API", function () {
};
let getSelectedRowCell = (dataKey) => {
- let cell = doc.querySelector(`#zotero-items-tree .row.selected .${dataKey}`);
+ let cell = doc.querySelector(`#zotero-items-tree .row.selected .${CSS.escape(dataKey)}`);
return cell;
};