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; };