diff --git a/chrome/content/scaffold/scaffold.xhtml b/chrome/content/scaffold/scaffold.xhtml index b8fc0aad7e..0259759f76 100644 --- a/chrome/content/scaffold/scaffold.xhtml +++ b/chrome/content/scaffold/scaffold.xhtml @@ -415,7 +415,7 @@ - + @@ -550,8 +550,8 @@ - - + + diff --git a/chrome/content/zotero/contextPane.js b/chrome/content/zotero/contextPane.js index 3414154bd6..336496c88c 100644 --- a/chrome/content/zotero/contextPane.js +++ b/chrome/content/zotero/contextPane.js @@ -23,8 +23,8 @@ ***** END LICENSE BLOCK ***** */ -let ZoteroContextPane = new function () { - let _tabCover; +var ZoteroContextPane = new function () { + let _loadingMessageContainer; let _contextPane; let _contextPaneInner; let _contextPaneSplitter; @@ -32,43 +32,42 @@ let ZoteroContextPane = new function () { let _librarySidenav; let _readerSidenav; - // Using attribute instead of property to set 'selectedIndex' - // is more reliable - + Object.defineProperty(this, 'activeEditor', { + get: () => _contextPaneInner.activeEditor + }); + + Object.defineProperty(this, 'sidenav', { + get: () => (Zotero_Tabs.selectedType == "library" + ? _librarySidenav + : _readerSidenav) + }); + + Object.defineProperty(this, 'splitter', { + get: () => (_isStacked() + ? _contextPaneSplitterStacked + : _contextPaneSplitter) + }); + this.update = _update; - this.getActiveEditor = () => { - return _contextPaneInner._getActiveEditor(); - }; - this.focus = () => { - return _contextPaneInner._focus(); + return _contextPaneInner.handleFocus(); }; - this.getSidenav = () => { - return Zotero_Tabs.selectedType == "library" - ? _librarySidenav - : _readerSidenav; - }; - - this.getSplitter = () => { - return _isStacked() - ? _contextPaneSplitterStacked - : _contextPaneSplitter; - }; - - this.showTabCover = (isShow) => { - _tabCover.classList.toggle('hidden', !isShow); + this.showLoadingMessage = (isShow) => { + _loadingMessageContainer.classList.toggle('hidden', !isShow); }; this.updateAddToNote = _updateAddToNote; + this.togglePane = _togglePane; + this.init = function () { if (!Zotero) { return; } - _tabCover = document.getElementById('zotero-tab-cover'); + _loadingMessageContainer = document.getElementById('zotero-tab-cover'); _contextPane = document.getElementById('zotero-context-pane'); // CE _contextPaneInner = document.getElementById('zotero-context-pane-inner'); @@ -79,6 +78,8 @@ let ZoteroContextPane = new function () { _contextPaneInner.sidenav = _readerSidenav; + this.context = _contextPaneInner; + window.addEventListener('resize', _update); Zotero.Reader.onChangeSidebarWidth = _updatePaneWidth; Zotero.Reader.onToggleSidebar = _updatePaneWidth; @@ -93,7 +94,7 @@ let ZoteroContextPane = new function () { function _updateAddToNote() { let reader = Zotero.Reader.getByTabID(Zotero_Tabs.selectedID); if (reader) { - let editor = ZoteroContextPane.getActiveEditor(); + let editor = ZoteroContextPane.activeEditor; let libraryReadOnly = editor && editor.item && _isLibraryReadOnly(editor.item.libraryID); let noteReadOnly = editor && editor.item && (editor.item.deleted || editor.item.parentItem && editor.item.parentItem.deleted); @@ -157,17 +158,18 @@ let ZoteroContextPane = new function () { } if (Zotero_Tabs.selectedIndex > 0) { - let height = 0; - if (_isStacked() - && _contextPane.getAttribute('collapsed') != 'true') { - height = _contextPaneInner.getBoundingClientRect().height; + var height = null; + if (_isStacked()) { + height = 0; + if (_contextPane.getAttribute('collapsed') != 'true') { + height = _contextPaneInner.getBoundingClientRect().height; + } } Zotero.Reader.setBottomPlaceholderHeight(height); } _updatePaneWidth(); _updateAddToNote(); - _readerSidenav.container?.render(); } function _isLibraryReadOnly(libraryID) { @@ -175,7 +177,7 @@ let ZoteroContextPane = new function () { } function _togglePane() { - var splitter = ZoteroContextPane.getSplitter(); + var splitter = ZoteroContextPane.splitter; var open = true; if (splitter.getAttribute('state') != 'collapsed') { diff --git a/chrome/content/zotero/elements/abstractBox.js b/chrome/content/zotero/elements/abstractBox.js index 84b877e26a..2670e6b31a 100644 --- a/chrome/content/zotero/elements/abstractBox.js +++ b/chrome/content/zotero/elements/abstractBox.js @@ -35,19 +35,13 @@ `); - showInFeeds = true; - - _item = null; - - _mode = null; - get item() { return this._item; } set item(item) { this.blurOpenField(); - this._item = item; + super.item = item; if (item?.isRegularItem()) { this.hidden = false; } @@ -56,16 +50,16 @@ } } - get mode() { - return this._mode; + get editable() { + return this._editable; } - set mode(mode) { - if (this._mode === mode) { + set editable(editable) { + if (this._editable === editable) { return; } this.blurOpenField(); - this._mode = mode; + super.editable = editable; } init() { @@ -86,7 +80,7 @@ notify(action, type, ids) { if (action == 'modify' && this.item && ids.includes(this.item.id)) { - this.render(true); + this._forceRenderAll(); } } @@ -95,7 +89,7 @@ this.item.setField('abstractNote', this._abstractField.value); await this.item.saveTx(); } - this.render(true); + this._forceRenderAll(); } async blurOpenField() { @@ -105,9 +99,9 @@ } } - render(force = false) { + render() { if (!this.item) return; - if (!force && this._isAlreadyRendered()) return; + if (this._isAlreadyRendered()) return; let abstract = this.item.getField('abstractNote'); this._section.summary = abstract; @@ -119,7 +113,7 @@ else { this._abstractField.value = abstract; } - this._abstractField.readOnly = this._mode == 'view'; + this._abstractField.readOnly = !this.editable; this._abstractField.setAttribute('aria-label', Zotero.ItemFields.getLocalizedString('abstractNote')); } } diff --git a/chrome/content/zotero/elements/annotationRow.js b/chrome/content/zotero/elements/annotationRow.js index 78921318b7..c6d8315bd7 100644 --- a/chrome/content/zotero/elements/annotationRow.js +++ b/chrome/content/zotero/elements/annotationRow.js @@ -38,8 +38,6 @@ _annotation = null; - _mode = null; - _listenerAdded = false; static get observedAttributes() { diff --git a/chrome/content/zotero/elements/attachmentAnnotationsBox.js b/chrome/content/zotero/elements/attachmentAnnotationsBox.js index 6d5f01186e..fa942c5d5b 100644 --- a/chrome/content/zotero/elements/attachmentAnnotationsBox.js +++ b/chrome/content/zotero/elements/attachmentAnnotationsBox.js @@ -37,7 +37,7 @@ } set tabType(tabType) { - this._tabType = tabType; + super.tabType = tabType; this._updateHidden(); } @@ -46,7 +46,7 @@ } set item(item) { - this._item = item; + super.item = item; this._updateHidden(); } @@ -60,13 +60,13 @@ notify(action, type, ids) { if (action == 'modify' && this.item && ids.includes(this.item.id)) { - this.render(true); + this._forceRenderAll(); } } - render(force = false) { + render() { if (!this.initialized || !this.item?.isFileAttachment()) return; - if (!force && this._isAlreadyRendered()) return; + if (this._isAlreadyRendered()) return; let annotations = this.item.getAnnotations(); this._section.setCount(annotations.length); diff --git a/chrome/content/zotero/elements/attachmentBox.js b/chrome/content/zotero/elements/attachmentBox.js index 9063bedd53..8d7a59c24a 100644 --- a/chrome/content/zotero/elements/attachmentBox.js +++ b/chrome/content/zotero/elements/attachmentBox.js @@ -77,7 +77,6 @@ constructor() { super(); - this.editable = false; this.clickableLink = false; this.displayButton = false; this.displayNote = false; @@ -104,7 +103,6 @@ set mode(val) { Zotero.debug("Setting mode to '" + val + "'"); - this.editable = false; this.synchronous = false; this.displayURL = false; this.displayFileName = false; @@ -128,7 +126,6 @@ break; case 'edit': - this.editable = true; this.displayURL = true; this.displayFileName = true; this.clickableLink = true; @@ -150,7 +147,6 @@ case 'mergeedit': this.synchronous = true; - this.editable = true; this.displayURL = true; this.displayFileName = true; this.displayAccessed = true; @@ -171,6 +167,19 @@ } this._mode = val; + + this._editable = ["edit", "mergeedit"].includes(this._mode); + } + + get editable() { + return this._editable; + } + + set editable(editable) { + // TODO: Replace `mode` with `editable`? + this.mode = editable ? "edit" : "view"; + // Use the current `_editable` set by `mode` + super.editable = this._editable; } get usePreview() { @@ -186,7 +195,7 @@ } set tabType(tabType) { - this._tabType = tabType; + super.tabType = tabType; if (tabType == "reader") this.usePreview = false; } @@ -286,16 +295,16 @@ continue; } - this.render(true); + this._forceRenderAll(); break; } } - async render(force = false) { + async asyncRender() { if (!this.item) return; if (this._isRendering) return; if (!this._section.open) return; - if (!force && this._isAlreadyRendered()) return; + if (this._isAlreadyRendered("async")) return; Zotero.debug('Refreshing attachment box'); this._isRendering = true; @@ -359,7 +368,13 @@ } if (this.displayFileName && !isLinkedURL) { - let fileName = this.item.attachmentFilename; + let fileName = ""; + try { + fileName = this.item.attachmentFilename; + } + catch (e) { + Zotero.warn("Error getting attachment filename: " + e); + } if (fileName) { this._id("fileName").value = fileName; @@ -528,7 +543,7 @@ } // Don't allow empty filename if (!newFilename) { - this.render(true); + this._forceRenderAll(); return; } let newExt = getExtension(newFilename); @@ -584,7 +599,7 @@ Zotero.getString('pane.item.attachments.fileNotFound.text1') ); } - this.render(true); + this._forceRenderAll(); } initAttachmentNoteEditor() { diff --git a/chrome/content/zotero/elements/attachmentPreview.js b/chrome/content/zotero/elements/attachmentPreview.js index d0fc7fa285..f081187491 100644 --- a/chrome/content/zotero/elements/attachmentPreview.js +++ b/chrome/content/zotero/elements/attachmentPreview.js @@ -48,7 +48,6 @@ this._isDiscarding = false; this._failedCount = 0; - // this._intersectionOb = new IntersectionObserver(this._handleIntersection.bind(this)); this._resizeOb = new ResizeObserver(this._handleResize.bind(this)); } diff --git a/chrome/content/zotero/elements/attachmentRow.js b/chrome/content/zotero/elements/attachmentRow.js index 4a86c426af..4cc0bd3419 100644 --- a/chrome/content/zotero/elements/attachmentRow.js +++ b/chrome/content/zotero/elements/attachmentRow.js @@ -95,7 +95,7 @@ import { getCSSItemTypeIcon } from 'components/icons'; // TODO: jump to annotations pane let pane; if (ZoteroContextPane) { - pane = ZoteroContextPane.getSidenav()?.container.querySelector(`:scope > [data-pane="attachment-annotations"]`); + pane = ZoteroContextPane.sidenav?.container.querySelector(`:scope > [data-pane="attachment-annotations"]`); } if (pane) { pane._section.open = true; diff --git a/chrome/content/zotero/elements/attachmentsBox.js b/chrome/content/zotero/elements/attachmentsBox.js index b94f10899d..8c2d5694aa 100644 --- a/chrome/content/zotero/elements/attachmentsBox.js +++ b/chrome/content/zotero/elements/attachmentsBox.js @@ -37,14 +37,8 @@ `); - _item = null; - _attachmentIDs = []; - _mode = null; - - _inTrash = false; - _preview = null; get item() { @@ -56,37 +50,18 @@ return; } - this._item = item; + super.item = item; let hidden = !item?.isRegularItem() || item?.isFeedItem; this.hidden = hidden; this._preview.disableResize = !!hidden; } - + get inTrash() { - return this._inTrash; - } - - set inTrash(inTrash) { - if (this._inTrash === inTrash) { - return; + if (this.tabType != "library") { + return false; } - this._inTrash = inTrash; - if (!this._item?.isRegularItem()) { - return; - } - for (let row of Array.from(this._attachments.querySelectorAll("attachment-row"))) { - this._updateRowAttributes(row, row.attachment); - } - this.updateCount(); - } - - get tabType() { - return this._tabType; - } - - set tabType(tabType) { - this._tabType = tabType; - this._updateHidden(); + return ZoteroPane.collectionsView.selectedTreeRow + && ZoteroPane.collectionsView.selectedTreeRow.isTrash(); } get usePreview() { @@ -116,7 +91,7 @@ } destroy() { - this._section.removeEventListener('add', this._handleAdd); + this._section?.removeEventListener('add', this._handleAdd); Zotero.Notifier.unregisterObserver(this._notifierID); } @@ -175,9 +150,15 @@ return row; } - async render(force = false) { + render() { if (!this._item) return; - if (!force && this._isAlreadyRendered()) return; + if (this._isAlreadyRendered()) return; + this.updateCount(); + } + + async asyncRender() { + if (!this._item) return; + if (this._isAlreadyRendered("async")) return; await this._updateAttachmentIDs(); @@ -187,12 +168,11 @@ for (let attachment of itemAttachments) { this.addRow(attachment); } - this.updateCount(); this.usePreview = Zotero.Prefs.get('showAttachmentPreview'); } updateCount() { - let count = this._item.numAttachments(this._inTrash); + let count = this._item.numAttachments(this.inTrash); this._section.setCount(count); } @@ -249,11 +229,9 @@ }; _updateRowAttributes(row, attachment) { - let hidden = !this._inTrash && attachment.deleted; - let context = this._inTrash && !this._item.deleted && !attachment.deleted; + let hidden = !this.inTrash && attachment.deleted; row.attachment = attachment; row.hidden = hidden; - row.contextRow = context; } async _updateAttachmentIDs() { @@ -271,10 +249,6 @@ } this._attachmentIDs = sortedAttachmentIDs; } - - _updateHidden() { - this.hidden = !this._item?.isRegularItem(); - } } customElements.define("attachments-box", AttachmentsBox); } diff --git a/chrome/content/zotero/elements/base.js b/chrome/content/zotero/elements/base.js index c1195e6a1f..dd9ec6d76d 100644 --- a/chrome/content/zotero/elements/base.js +++ b/chrome/content/zotero/elements/base.js @@ -50,6 +50,8 @@ class XULElementBase extends XULElement { document.l10n.connectRoot(this.shadowRoot); } + window.addEventListener("unload", this._handleWindowUnload); + this.initialized = true; this.init(); } @@ -57,6 +59,11 @@ class XULElementBase extends XULElement { disconnectedCallback() { this.replaceChildren(); this.destroy(); + window.removeEventListener("unload", this._handleWindowUnload); this.initialized = false; } + + _handleWindowUnload = () => { + this.disconnectedCallback(); + }; } diff --git a/chrome/content/zotero/elements/collapsibleSection.js b/chrome/content/zotero/elements/collapsibleSection.js index 80eb139f04..cff97949a9 100644 --- a/chrome/content/zotero/elements/collapsibleSection.js +++ b/chrome/content/zotero/elements/collapsibleSection.js @@ -401,9 +401,9 @@ if (document.documentElement.getAttribute('windowtype') !== 'navigator:browser') { return null; } - if (!ZoteroContextPane) return null; + if (typeof ZoteroContextPane == "undefined") return null; // TODO: update this after unifying item pane & context pane - return ZoteroContextPane.getSidenav(); + return ZoteroContextPane.sidenav; } render() { diff --git a/chrome/content/zotero/elements/contextPane.js b/chrome/content/zotero/elements/contextPane.js index cfbbdde8d7..1b90b4c654 100644 --- a/chrome/content/zotero/elements/contextPane.js +++ b/chrome/content/zotero/elements/contextPane.js @@ -43,19 +43,24 @@ sidenav.contextNotesPane = this._notesPaneDeck; } - get viewType() { + get mode() { return ["item", "notes"][this._panesDeck.getAttribute('selectedIndex')]; } - set viewType(viewType) { - let viewTypeMap = { + set mode(mode) { + let modeMap = { item: "0", notes: "1", }; - if (!(viewType in viewTypeMap)) { - throw new Error(`ContextPane.viewType must be one of ["item", "notes"], but got ${viewType}`); + if (!(mode in modeMap)) { + throw new Error(`ContextPane.mode must be one of ["item", "notes"], but got ${mode}`); } - this._panesDeck.setAttribute("selectedIndex", viewTypeMap[viewType]); + this._panesDeck.selectedIndex = modeMap[mode]; + } + + get activeEditor() { + let currentContext = this._getCurrentNotesContext(); + return currentContext?._getCurrentEditor(); } init() { @@ -153,7 +158,7 @@ _handleTabSelect(action, type, ids) { // TEMP: move these variables to ZoteroContextPane - let _contextPaneSplitter = document.getElementById('zotero-context-splitter'); + let _contextPaneSplitter = ZoteroContextPane.splitter; let _contextPane = document.getElementById('zotero-context-pane'); // It seems that changing `hidden` or `collapsed` values might // be related with significant slow down when there are too many @@ -161,7 +166,7 @@ if (Zotero_Tabs.selectedType == 'library') { _contextPaneSplitter.setAttribute('hidden', true); _contextPane.setAttribute('collapsed', true); - ZoteroContextPane.showTabCover(false); + ZoteroContextPane.showLoadingMessage(false); this._sidenav.hidden = true; } else if (Zotero_Tabs.selectedType == 'reader') { @@ -191,9 +196,9 @@ if (!reader) { return; } - ZoteroContextPane.showTabCover(true); + ZoteroContextPane.showLoadingMessage(true); await reader._initPromise; - ZoteroContextPane.showTabCover(false); + ZoteroContextPane.showLoadingMessage(false); // Focus reader pages view if context pane note editor is not selected if (Zotero_Tabs.selectedID == reader.tabID && !Zotero_Tabs.isTabsMenuVisible() @@ -209,7 +214,7 @@ if (attachment) { this._selectNotesContext(attachment.libraryID); let notesContext = this._getNotesContext(attachment.libraryID); - notesContext.updateFromCache(); + notesContext.updateNotesListFromCache(); } let currentNoteContext = this._getCurrentNotesContext(); @@ -217,7 +222,7 @@ let selectedIndex = Array.from(tabNotesDeck.children).findIndex(x => x.getAttribute('data-tab-id') == reader.tabID); if (selectedIndex != -1) { tabNotesDeck.setAttribute('selectedIndex', selectedIndex); - currentNoteContext.viewType = "childNote"; + currentNoteContext.mode = "childNote"; } else { currentNoteContext._restoreViewType(); @@ -253,26 +258,23 @@ context?.remove(); } - _getActiveEditor() { - let currentContext = this._getCurrentNotesContext(); - return currentContext?._getCurrentEditor(); - } - _getItemContext(tabID) { return this._itemPaneDeck.querySelector(`[data-tab-id="${tabID}"]`); } _removeItemContext(tabID) { - this._itemPaneDeck.querySelector(`[data-tab-id="${tabID}"]`).remove(); + this._itemPaneDeck.querySelector(`[data-tab-id="${tabID}"]`)?.remove(); } _selectItemContext(tabID) { - let previousPinnedPane = this._sidenav.container?.pinnedPane || ""; + let previousContainer = this._sidenav.container; let selectedPanel = this._getItemContext(tabID); if (selectedPanel) { this._itemPaneDeck.selectedPanel = selectedPanel; selectedPanel.sidenav = this._sidenav; - if (previousPinnedPane) selectedPanel.pinnedPane = previousPinnedPane; + // Inherits previous pinned states + if (previousContainer) selectedPanel.pinnedPane = previousContainer.pinnedPane; + selectedPanel.render(); } } @@ -286,7 +288,7 @@ return; } libraryID = item.libraryID; - let readOnly = !Zotero.Libraries.get(libraryID).editable; + let editable = Zotero.Libraries.get(libraryID).editable; let parentID = item.parentID; let previousPinnedPane = this._sidenav.container?.pinnedPane || ""; @@ -299,7 +301,8 @@ itemDetails.className = 'zotero-item-pane-content'; this._itemPaneDeck.appendChild(itemDetails); - itemDetails.mode = readOnly ? "view" : null; + itemDetails.editable = editable; + itemDetails.tabType = "reader"; itemDetails.item = targetItem; // Manually cache parentID itemDetails.parentID = parentID; @@ -313,14 +316,13 @@ } } - _focus() { - let splitter = ZoteroContextPane.getSplitter(); - let node; + handleFocus() { + let splitter = ZoteroContextPane.splitter; if (splitter.getAttribute('state') != 'collapsed') { - if (this.viewType == "item") { - node = this._itemPaneDeck.selectedPanel; - node.focus(); + if (this.mode == "item") { + let header = this._itemPaneDeck.selectedPanel.querySelector("pane-header editable-text"); + header.focus(); return true; } else { diff --git a/chrome/content/zotero/elements/duplicatesMergePane.js b/chrome/content/zotero/elements/duplicatesMergePane.js index eda9981466..c3cf509e0c 100644 --- a/chrome/content/zotero/elements/duplicatesMergePane.js +++ b/chrome/content/zotero/elements/duplicatesMergePane.js @@ -166,7 +166,7 @@ this._masterItem = item; itembox.item = item.clone(); // The item.id is null which equals to _lastRenderItemID, so we need to force render it - itembox.render(true); + itembox._forceRenderAll(); } async merge() { diff --git a/chrome/content/zotero/elements/itemBox.js b/chrome/content/zotero/elements/itemBox.js index 49c1b0b55c..9d5b3c783e 100644 --- a/chrome/content/zotero/elements/itemBox.js +++ b/chrome/content/zotero/elements/itemBox.js @@ -31,7 +31,6 @@ super(); this.clickable = false; - this.editable = false; this.saveOnEdit = false; this.showTypeMenu = false; this.hideEmptyFields = false; @@ -41,8 +40,6 @@ this.eventHandlers = []; this.itemTypeMenu = null; - this.showInFeeds = true; - this._mode = 'view'; this._visibleFields = []; this._hiddenFields = []; @@ -231,7 +228,7 @@ this._notifierID = Zotero.Notifier.registerObserver(this, ['item'], 'itemBox'); Zotero.Prefs.registerObserver('fontSize', () => { - this.render(true); + this._forceRenderAll(); }); this.style.setProperty('--comma-character', @@ -253,7 +250,6 @@ set mode(val) { this.clickable = false; - this.editable = false; this.saveOnEdit = false; this.showTypeMenu = false; this.hideEmptyFields = false; @@ -266,7 +262,6 @@ case 'edit': this.clickable = true; - this.editable = true; this.saveOnEdit = true; this.showTypeMenu = true; break; @@ -282,6 +277,19 @@ this._mode = val; this.setAttribute('mode', val); + + this._editable = this.mode == "edit"; + } + + get editable() { + return this._editable; + } + + set editable(editable) { + // TODO: Replace `mode` with `editable`? + this.mode = editable ? "edit" : "view"; + // Use the current `_editable` set by `mode` + super.editable = this._editable; } get item() { @@ -464,12 +472,12 @@ if (document.activeElement == this.itemTypeMenu) { this._selectField = "item-type-menu"; } - this.render(true); + this._forceRenderAll(); break; } } - render(force = false) { + render() { Zotero.debug('Refreshing item box'); if (!this.item) { @@ -481,9 +489,7 @@ // Always update retraction status this.updateRetracted(); - if (!force && this._isAlreadyRendered()) return; - - this.updateRetracted(); + if (this._isAlreadyRendered()) return; // Init tab index to begin after all creator rows this._ztabindex = this._tabIndexMinCreators * (this.item.numCreators() || 1); @@ -708,7 +714,7 @@ menuitem.getAttribute('fieldname'), menuitem.getAttribute('originalValue') ); - this.render(true); + this._forceRenderAll(); }); popup.appendChild(menuitem); } @@ -1141,7 +1147,7 @@ // If the row is still hidden, no 'drop' event happened, meaning creator rows // were not reordered. To make sure everything is in correct order, just refresh. if (row.classList.contains("drag-hidden-creator")) { - this.render(true); + this._forceRenderAll(); } }); @@ -1186,12 +1192,12 @@ rowData.setAttribute("ztabindex", ++this._ztabindex); rowData.addEventListener('click', () => { this._displayAllCreators = true; - this.render(true); + this._forceRenderAll(); }); rowData.addEventListener('keypress', (e) => { if (["Enter", ' '].includes(e.key)) { this._displayAllCreators = true; - this.render(true); + this._forceRenderAll(); } }); rowData.textContent = Zotero.getString('general.numMore', num); @@ -1407,7 +1413,7 @@ await this.item.saveTx(); } else { - this.render(true); + this._forceRenderAll(); } functionsToRun.forEach(f => f.bind(this)()); diff --git a/chrome/content/zotero/elements/itemDetails.js b/chrome/content/zotero/elements/itemDetails.js index 0dc6bb31c5..3358b8857e 100644 --- a/chrome/content/zotero/elements/itemDetails.js +++ b/chrome/content/zotero/elements/itemDetails.js @@ -24,8 +24,6 @@ */ { - const AsyncFunction = (async () => {}).constructor; - const waitFrame = async () => { return waitNoLongerThan(new Promise((resolve) => { requestAnimationFrame(resolve); @@ -93,12 +91,21 @@ this._cachedParentID = parentID; } - get mode() { - return this._mode; + get editable() { + return this._editable; } - set mode(mode) { - this._mode = mode; + set editable(editable) { + this._editable = editable; + this.toggleAttribute('readonly', !editable); + } + + get tabType() { + return this._tabType; + } + + set tabType(tabType) { + this._tabType = tabType; } get pinnedPane() { @@ -106,12 +113,12 @@ } set pinnedPane(val) { - if (!val || !this.getPane(val)) { + if (!val || !this.getEnabledPane(val)) { val = ''; } this.setAttribute('pinnedPane', val); if (val) { - this._pinnedPaneMinScrollHeight = this._getMinScrollHeightForPane(this.getPane(val)); + this._pinnedPaneMinScrollHeight = this._getMinScrollHeightForPane(this.getEnabledPane(val)); } this.sidenav.updatePaneStatus(val); } @@ -180,10 +187,12 @@ }); this._initIntersectionObserver(); - this._unregisterID = Zotero.Notifier.registerObserver(this, ['item'], 'ItemDetails'); + this._unregisterID = Zotero.Notifier.registerObserver(this, ['item', 'itempane'], 'ItemDetails'); this._disableScrollHandler = false; this._pinnedPaneMinScrollHeight = 0; + + this._lastUpdateCustomSection = 0; } destroy() { @@ -204,47 +213,22 @@ Zotero.debug('Viewing item'); this._isRendering = true; + this.renderCustomSections(); + let panes = this.getPanes(); - let pendingBoxes = []; - let inTrash = ZoteroPane.collectionsView.selectedTreeRow && ZoteroPane.collectionsView.selectedTreeRow.isTrash(); - let tabType = Zotero_Tabs.selectedType; for (let box of [this._header, ...panes]) { - if (!box.showInFeeds && item.isFeedItem) { - box.style.display = 'none'; - box.hidden = true; - continue; - } - else { - box.style.display = ''; - box.hidden = false; - } - - if (this.mode) { - box.mode = this.mode; - - if (box.mode == 'view') { - box.hideEmptyFields = true; - } - } - else { - box.mode = 'edit'; - } - + box.editable = this.editable; + box.tabType = this.tabType; box.item = item; - box.inTrash = inTrash; - box.tabType = tabType; - // Render sync boxes immediately + // Execute sync render immediately if (!box.hidden && box.render) { - if (box.render instanceof AsyncFunction) { - pendingBoxes.push(box); - } - else { + if (box.render) { box.render(); } } } - let pinnedPaneElem = this.getPane(this.pinnedPane); + let pinnedPaneElem = this.getEnabledPane(this.pinnedPane); let pinnedIndex = panes.indexOf(pinnedPaneElem); this._paneParent.style.paddingBottom = ''; @@ -257,28 +241,18 @@ this._paneParent.scrollTo(0, 0); } - // Only render visible panes - for (let box of pendingBoxes) { + // Only execute async render for visible panes + for (let box of panes) { + if (!box.asyncRender) { + continue; + } if (pinnedIndex > -1 && panes.indexOf(box) < pinnedIndex) { continue; } if (!this.isPaneVisible(box.dataset.pane)) { continue; } - await waitNoLongerThan(box.render(), 500); - } - // After all panes finish first rendering, try secondary rendering - for (let box of panes) { - if (!box.secondaryRender) { - continue; - } - if (pinnedIndex > -1 && panes.indexOf(box) < pinnedIndex) { - continue; - } - if (this.isPaneVisible(box.dataset.pane)) { - continue; - } - await waitNoLongerThan(box.secondaryRender(), 500); + await waitNoLongerThan(box.asyncRender(), 500); } if (this.item.id == item.id) { this._isRendering = false; @@ -303,20 +277,20 @@ // Create let currentPaneIDs = currentPaneElements.map(elem => elem.dataset.pane); for (let section of targetPanes) { - let { paneID, head, sidenav, fragment, - onInit, onDestroy, onDataChange, onRender, onSecondaryRender, onToggle, + let { paneID, head, sidenav, bodyXHTML, + onInit, onDestroy, onItemChange, onRender, onAsyncRender, onToggle, sectionButtons } = section; if (currentPaneIDs.includes(paneID)) continue; let elem = new (customElements.get("item-pane-custom-section")); elem.dataset.sidenavOptions = JSON.stringify(sidenav || {}); elem.paneID = paneID; - elem.fragment = fragment; + elem.bodyXHTML = bodyXHTML; elem.registerSectionIcon({ icon: head.icon, darkIcon: head.darkIcon }); elem.registerHook({ type: "init", callback: onInit }); elem.registerHook({ type: "destroy", callback: onDestroy }); - elem.registerHook({ type: "dataChange", callback: onDataChange }); + elem.registerHook({ type: "itemChange", callback: onItemChange }); elem.registerHook({ type: "render", callback: onRender }); - elem.registerHook({ type: "secondaryRender", callback: onSecondaryRender }); + elem.registerHook({ type: "asyncRender", callback: onAsyncRender }); elem.registerHook({ type: "toggle", callback: onToggle }); if (sectionButtons) { for (let buttonOptions of sectionButtons) { @@ -335,13 +309,20 @@ this._header.renderCustomHead(callback); } - notify = async (action, _type, _ids, _extraData) => { + notify = async (action, type, _ids, _extraData) => { if (action == 'refresh' && this.item) { + if (type == 'item-pane') { + this.renderCustomSections(); + } await this.render(); } }; getPane(id) { + return this._paneParent.querySelector(`:scope > [data-pane="${CSS.escape(id)}"]`); + } + + getEnabledPane(id) { return this._paneParent.querySelector(`:scope > [data-pane="${CSS.escape(id)}"]:not([hidden])`); } @@ -368,8 +349,12 @@ return visiblePanes; } + getCustomPanes() { + return Array.from(this._paneParent.querySelectorAll(':scope > item-pane-custom-section[data-pane]')); + } + isPaneVisible(paneID) { - let paneElem = this.getPane(paneID); + let paneElem = this.getEnabledPane(paneID); if (!paneElem) return false; let paneRect = paneElem.getBoundingClientRect(); let containerRect = this._paneParent.getBoundingClientRect(); @@ -384,7 +369,7 @@ } async scrollToPane(paneID, behavior = 'smooth') { - let pane = this.getPane(paneID); + let pane = this.getEnabledPane(paneID); if (!pane) return null; let scrollPromise; @@ -486,7 +471,7 @@ // Ignore overscroll (which generates scroll events on Windows 11, unlike on macOS) // and don't shrink below the pinned pane's min scroll height if (newMinScrollHeight > this._paneParent.scrollHeight - || this.getPane(this.pinnedPane) && newMinScrollHeight < this._pinnedPaneMinScrollHeight) { + || this.getEnabledPane(this.pinnedPane) && newMinScrollHeight < this._pinnedPaneMinScrollHeight) { return; } this._minScrollHeight = newMinScrollHeight; @@ -539,6 +524,16 @@ } }; + /** + * This function handles the intersection of panes with the viewport. + * It triggers rendering and discarding of panes based on their visibility. + * Panes are not rendered until they become visible in the viewport. + * This approach prevents unnecessary rendering of all panes at once when switching items, + * which can lead to slow performance and excessive battery usage, + * especially for slow panes, e.g. attachment preview. + * @param {IntersectionObserverEntry[]} entries + * @returns {Promise} + */ _handleIntersection = async (entries) => { if (this._isRendering) return; let needsRefresh = []; @@ -564,8 +559,8 @@ if (needsCheckVisibility && !this.isPaneVisible(paneElem.dataset.pane)) { return; } - await paneElem.render(); - if (paneElem.secondaryRender) await paneElem.secondaryRender(); + if (paneElem.render) paneElem.render(); + if (paneElem.asyncRender) await paneElem.asyncRender(); }); } if (needsDiscard.length > 0) { @@ -573,7 +568,7 @@ if (needsCheckVisibility && this.isPaneVisible(paneElem.dataset.pane)) { return; } - paneElem.discard(); + if (paneElem.discard) paneElem.discard(); }); } }; diff --git a/chrome/content/zotero/elements/itemMessagePane.js b/chrome/content/zotero/elements/itemMessagePane.js index 216ab7520c..aa8e824685 100644 --- a/chrome/content/zotero/elements/itemMessagePane.js +++ b/chrome/content/zotero/elements/itemMessagePane.js @@ -59,9 +59,7 @@ }; if (callback) callback({ doc: document, - append: (...args) => { - append(...Components.utils.cloneInto(args, window, { wrapReflectors: true, cloneFunctions: true })); - } + append, }); } } diff --git a/chrome/content/zotero/elements/itemPane.js b/chrome/content/zotero/elements/itemPane.js index 8c7f1c753e..c2a79c489c 100644 --- a/chrome/content/zotero/elements/itemPane.js +++ b/chrome/content/zotero/elements/itemPane.js @@ -67,12 +67,20 @@ this._data = data; } - get viewMode() { - return this._viewMode; + get collectionTreeRow() { + return this._collectionTreeRow; } - set viewMode(mode) { - this._viewMode = mode; + set collectionTreeRow(val) { + this._collectionTreeRow = val; + } + + get itemsView() { + return this._itemsView; + } + + set itemsView(val) { + this._itemsView = val; } get editable() { @@ -81,17 +89,18 @@ set editable(editable) { this._editable = editable; + this.toggleAttribute('readonly', !editable); } - get viewType() { + get mode() { return ["message", "item", "note", "duplicates"][this._deck.selectedIndex]; } /** - * Set view type + * Set mode of item pane * @param {"message" | "item" | "note" | "duplicates"} type view type */ - set viewType(type) { + set mode(type) { this.setAttribute("view-type", type); } @@ -121,14 +130,14 @@ notify(action, type) { if (type == 'item' && action == 'modify') { - if (this.viewMode.isFeedsOrFeed) { + if (this.collectionTreeRow.isFeedsOrFeed()) { this.updateReadLabel(); } } } renderNoteEditor(item) { - this.viewType = "note"; + this.mode = "note"; let noteEditor = document.getElementById('zotero-note-editor'); noteEditor.mode = this.editable ? 'edit' : 'view'; @@ -139,9 +148,10 @@ } renderItemPane(item) { - this.viewType = "item"; + this.mode = "item"; - this._itemDetails.mode = this.editable ? null : "view"; + this._itemDetails.editable = this.editable; + this._itemDetails.tabType = "library"; this._itemDetails.item = item; if (this.hasAttribute("collapsed")) { @@ -180,7 +190,7 @@ let count = this.data.length; // Display duplicates merge interface in item pane - if (this.viewMode.isDuplicates) { + if (this.collectionTreeRow.isDuplicates()) { if (!this.editable) { if (count) { msg = Zotero.getString('pane.item.duplicates.writeAccessRequired'); @@ -191,11 +201,11 @@ this.setItemPaneMessage(msg); } else if (count) { - this.viewType = "duplicates"; + this.mode = "duplicates"; // On a Select All of more than a few items, display a row // count instead of the usual item type mismatch error - let displayNumItemsOnTypeError = count > 5 && count == this.viewMode.rowCount; + let displayNumItemsOnTypeError = count > 5 && count == this.itemsView.rowCount; // Initialize the merge pane with the selected items this._duplicatesPane.setItems(this.data, displayNumItemsOnTypeError); @@ -211,7 +221,7 @@ msg = Zotero.getString('pane.item.selected.multiple', count); } else { - let rowCount = this.viewMode.rowCount; + let rowCount = this.itemsView.rowCount; let str = 'pane.item.unselected.'; switch (rowCount) { case 0: @@ -235,7 +245,7 @@ } setItemPaneMessage(msg) { - this.viewType = "message"; + this.mode = "message"; this._messagePane.render(msg); } @@ -258,7 +268,7 @@ } // My Publications buttons - var isPublications = this.viewMode.isPublications; + var isPublications = this.collectionTreeRow.isPublications(); // Show in My Publications view if selected items are all notes or non-linked-file attachments var showMyPublicationsButtons = isPublications && this.data.every((item) => { @@ -274,13 +284,13 @@ // Trash button let nonDeletedItemsSelected = this.data.some(item => !item.deleted); - if (this.viewMode.isTrash && !nonDeletedItemsSelected) { + if (this.collectionTreeRow.isTrash() && !nonDeletedItemsSelected) { container.renderCustomHead(this.renderTrashHead.bind(this)); return; } // Feed buttons - if (this.viewMode.isFeedsOrFeed) { + if (this.collectionTreeRow.isFeedsOrFeed()) { container.renderCustomHead(this.renderFeedHead.bind(this)); this.updateReadLabel(); return; @@ -476,6 +486,10 @@ } } + async handleBlur() { + await this._itemDetails.blurOpenField(); + } + handleResize() { if (this.getAttribute("collapsed")) { this.removeAttribute("width"); @@ -491,14 +505,14 @@ if (!width || Number(width) < minWidth) this.setAttribute("width", String(minWidth)); if (!height || Number(height) < minHeight) this.setAttribute("height", String(minHeight)); // Render item pane after open - if ((!width || !height) && this.viewType == "item") { + if ((!width || !height) && this.mode == "item") { this._itemDetails.render(); } } } _handleViewTypeChange(type) { - let previousViewType = this.viewType; + let previousViewType = this.mode; switch (type) { case "message": { this._deck.selectedIndex = 0; diff --git a/chrome/content/zotero/elements/itemPaneSection.js b/chrome/content/zotero/elements/itemPaneSection.js index f414e68dc5..0908187756 100644 --- a/chrome/content/zotero/elements/itemPaneSection.js +++ b/chrome/content/zotero/elements/itemPaneSection.js @@ -25,6 +25,35 @@ class ItemPaneSectionElementBase extends XULElementBase { + get item() { + return this._item; + } + + set item(item) { + let success = this._handleDataChange("item", this._item, item); + if (success === false) return; + this._item = item; + } + + get editable() { + return this._editable; + } + + set editable(editable) { + this._editable = editable; + this.toggleAttribute('readonly', !editable); + } + + get tabType() { + return this._tabType; + } + + set tabType(tabType) { + let success = this._handleDataChange("tabType", this._tabType, tabType); + if (success === false) return; + this._tabType = tabType; + } + connectedCallback() { super.connectedCallback(); if (!this.render) { @@ -58,21 +87,258 @@ class ItemPaneSectionElementBase extends XULElementBase { if (event.target !== this._section || !this._section.open) { return; } - if (this.render) await this.render(true); - if (this.secondaryRender) await this.secondaryRender(true); + await this._forceRenderAll(); }; /** - * @param {"primary" | "secondary"} [type] + * @param {"sync" | "async"} [type] * @returns {boolean} */ - _isAlreadyRendered(type = "primary") { + _isAlreadyRendered(type = "sync") { let key = `_${type}RenderItemID`; let cachedFlag = this[key]; if (cachedFlag && this.item?.id == cachedFlag) { return true; } - this._lastRenderItemID = this.item.id; + this[key] = this.item.id; return false; } + + async _forceRenderAll() { + if (this.hidden) return; + // Clear cached flags to allow re-rendering + delete this._syncRenderItemID; + delete this._asyncRenderItemID; + if (this.render) this.render(); + if (this.asyncRender) await this.asyncRender(); + } +} + +{ + class ItemPaneCustomSection extends ItemPaneSectionElementBase { + _hooks = {}; + + _sectionButtons = {}; + + _refreshDisabled = true; + + get content() { + let extraButtons = Object.keys(this._sectionButtons).join(","); + let content = ` + + + ${this.bodyXHTML || ""} + + + + `; + return MozXULElement.parseXULToFragment(content); + } + + get paneID() { + return this._paneID; + } + + set paneID(paneID) { + this._paneID = paneID; + if (this.initialized) { + this._section.dataset.pane = paneID; + this.dataset.pane = paneID; + } + } + + get bodyXHTML() { + return this._bodyXHTML; + } + + /** + * @param {string} bodyXHTML + */ + set bodyXHTML(bodyXHTML) { + this._bodyXHTML = bodyXHTML; + if (this.initialized) { + this._body.replaceChildren( + document.importNode(MozXULElement.parseXULToFragment(bodyXHTML), true) + ); + } + } + + init() { + this._section = this.querySelector("collapsible-section"); + this._body = this._section.querySelector('[data-type="body"]'); + this._style = this.querySelector(".custom-style"); + + if (this.paneID) this.dataset.pane = this.paneID; + if (this._label) this._section.label = this._label; + this.updateSectionIcon(); + + this._sectionListeners = []; + + let styles = []; + for (let type of Object.keys(this._sectionButtons)) { + let { icon, darkIcon, onClick } = this._sectionButtons[type]; + if (!darkIcon) { + darkIcon = icon; + } + let listener = (event) => { + let props = this._assembleProps(this._getHookProps()); + props.event = event; + onClick(props); + }; + this._section.addEventListener(type, listener); + this._sectionListeners.push({ type, listener }); + let button = this._section.querySelector(`.${type}`); + button.style = `--custom-button-icon-light: url('${icon}'); --custom-button-icon-dark: url('${darkIcon}');`; + } + + this._style.textContent = styles.join("\n"); + + this._section.addEventListener("toggle", this._handleToggle); + this._sectionListeners.push({ type: "toggle", listener: this._handleToggle }); + + this._handleInit(); + + // Disable refresh until data is load + this._refreshDisabled = false; + } + + destroy() { + this._sectionListeners.forEach(data => this._section?.removeEventListener(data.type, data.listener)); + + this._handleDestroy(); + this._hooks = null; + } + + setL10nID(l10nId) { + this._section.dataset.l10nId = l10nId; + } + + setL10nArgs(l10nArgs) { + this._section.dataset.l10nArgs = l10nArgs; + } + + registerSectionIcon(options) { + let { icon, darkIcon } = options; + if (!darkIcon) { + darkIcon = icon; + } + this._lightIcon = icon; + this._darkIcon = darkIcon; + if (this.initialized) { + this.updateSectionIcon(); + } + } + + updateSectionIcon() { + this.style = `--custom-section-icon-light: url('${this._lightIcon}'); --custom-section-icon-dark: url('${this._darkIcon}')`; + } + + registerSectionButton(options) { + let { type, icon, darkIcon, onClick } = options; + if (!darkIcon) { + darkIcon = icon; + } + if (this.initialized) { + Zotero.warn(`ItemPaneCustomSection section button cannot be registered after initialization`); + return; + } + this._sectionButtons[type.replace(/[^a-zA-Z0-9-_]/g, "-")] = { + icon, darkIcon, onClick + }; + } + + /** + * @param {{ type: "render" | "asyncRender" | "itemChange" | "init" | "destroy" | "toggle" }} options + */ + registerHook(options) { + let { type, callback } = options; + if (!callback) return; + this._hooks[type] = callback; + } + + _getBasicHookProps() { + return { + paneID: this.paneID, + doc: document, + body: this._body, + }; + } + + _getUIHookProps() { + return { + item: this.item, + tabType: this.tabType, + editable: this.editable, + setL10nArgs: l10nArgs => this.setL10nArgs(l10nArgs), + setEnabled: enabled => this.hidden = !enabled, + setSectionSummary: summary => this._section.summary = summary, + setSectionButtonStatus: (type, options) => { + let { disabled, hidden } = options; + let button = this._section.querySelector(`.${type}`); + if (!button) return; + if (typeof disabled !== "undefined") button.disabled = disabled; + if (typeof hidden !== "undefined") button.hidden = hidden; + } + }; + } + + _getHookProps() { + return Object.assign({}, this._getBasicHookProps(), this._getUIHookProps()); + } + + _assembleProps(...props) { + return Object.freeze(Object.assign({}, ...props)); + } + + _handleInit() { + if (!this._hooks.init) return; + let props = this._assembleProps( + this._getHookProps(), + { refresh: async () => this._handleRefresh() }, + ); + this._hooks.init(props); + } + + _handleDestroy() { + if (!this._hooks.destroy) return; + let props = this._assembleProps(this._getBasicHookProps()); + this._hooks.destroy(props); + } + + render() { + if (!this._hooks.render) return false; + if (!this.initialized || this._isAlreadyRendered()) return false; + return this._hooks.render(this._assembleProps(this._getHookProps())); + } + + async asyncRender() { + if (!this._hooks.asyncRender) return false; + if (!this.initialized || this._isAlreadyRendered("async")) return false; + return this._hooks.asyncRender(this._assembleProps(this._getHookProps())); + } + + async _handleRefresh() { + if (!this.initialized) return; + await this._forceRenderAll(); + } + + _handleToggle = (event) => { + if (!this._hooks.toggle) return; + let props = this._assembleProps( + this._getHookProps(), + { event }, + ); + this._hooks.toggle(props); + }; + + _handleDataChange(type, _oldValue, _newValue) { + if (type == "item" && this._hooks.itemChange) { + let props = this._assembleProps(this._getHookProps()); + this._hooks.itemChange(props); + } + return true; + } + } + + customElements.define("item-pane-custom-section", ItemPaneCustomSection); } diff --git a/chrome/content/zotero/elements/itemPaneSidenav.js b/chrome/content/zotero/elements/itemPaneSidenav.js index 913df09f08..68a17fc275 100644 --- a/chrome/content/zotero/elements/itemPaneSidenav.js +++ b/chrome/content/zotero/elements/itemPaneSidenav.js @@ -46,7 +46,7 @@ data-l10n-id="sidenav-abstract" data-pane="abstract"/> - + - + - + `, ['chrome://zotero/locale/zotero.dtd']); - _item = null; - _linkedItems = []; - _mode = null; - get item() { return this._item; } set item(item) { - if (item?.isRegularItem()) { + if (item?.isRegularItem() && !item?.isFeedItem) { this.hidden = false; } else { @@ -64,15 +60,6 @@ import { getCSSIcon } from 'components/icons'; this._linkedItems = []; } - get mode() { - return this._mode; - } - - set mode(mode) { - this._mode = mode; - this.setAttribute('mode', mode); - } - init() { this._notifierID = Zotero.Notifier.registerObserver(this, ['item'], 'librariesCollectionsBox'); this._body = this.querySelector('.body'); @@ -82,14 +69,14 @@ import { getCSSIcon } from 'components/icons'; destroy() { Zotero.Notifier.unregisterObserver(this._notifierID); - this._section.removeEventListener('add', this._handleAdd); + this._section?.removeEventListener('add', this._handleAdd); } notify(action, type, ids) { if (action == 'modify' && this._item && (ids.includes(this._item.id) || this._linkedItems.some(item => ids.includes(item.id)))) { - this.render(true); + this._forceRenderAll(); } } @@ -127,7 +114,7 @@ import { getCSSIcon } from 'components/icons'; row.append(box); - if (this._mode == 'edit' && obj instanceof Zotero.Collection && !isContext) { + if (this.editable && obj instanceof Zotero.Collection && !isContext) { let remove = document.createXULElement('toolbarbutton'); remove.className = 'zotero-clicky zotero-clicky-minus'; remove.setAttribute("tabindex", "0"); @@ -226,10 +213,10 @@ import { getCSSIcon } from 'components/icons'; return row; } - render(force = false) { + render() { if (!this._item) return; if (!this._section.open) return; - if (!force && this._isAlreadyRendered()) return; + if (this._isAlreadyRendered()) return; this._body.replaceChildren(); @@ -239,15 +226,13 @@ import { getCSSIcon } from 'components/icons'; this._addObject(collection, item); } } - if (force) { - this.secondaryRender(); - } } - async secondaryRender() { + async asyncRender() { if (!this._item) { return; } + if (this._isAlreadyRendered("async")) return; // Skip if already rendered if (this._linkedItems.length > 0) { return; diff --git a/chrome/content/zotero/elements/noteEditor.js b/chrome/content/zotero/elements/noteEditor.js index fc2ad110e5..65e5bbff8c 100644 --- a/chrome/content/zotero/elements/noteEditor.js +++ b/chrome/content/zotero/elements/noteEditor.js @@ -330,9 +330,7 @@ }; if (callback) callback({ doc: document, - append: (...args) => { - append(...Components.utils.cloneInto(args, window, { wrapReflectors: true, cloneFunctions: true })); - } + append, }); } @@ -393,8 +391,8 @@ set mode(val) { this._mode = val; - this._id('related').mode = val; - this._id('tags').mode = val; + this._id('related').editable = val == "edit"; + this._id('tags').editable = val == "edit"; this.refresh(); } diff --git a/chrome/content/zotero/elements/notesBox.js b/chrome/content/zotero/elements/notesBox.js index 7b97af7644..fd0407c34d 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._mode = 'view'; this._item = null; this._noteIDs = []; this.initCollapsibleSection(); @@ -45,35 +44,16 @@ import { getCSSItemTypeIcon } from 'components/icons'; } destroy() { - this._section.removeEventListener('add', this._handleAdd); + this._section?.removeEventListener('add', this._handleAdd); Zotero.Notifier.unregisterObserver(this._notifierID); } - get mode() { - return this._mode; - } - - set mode(val) { - switch (val) { - case 'view': - case 'merge': - case 'mergeedit': - case 'edit': - break; - - default: - throw new Error(`Invalid mode '${val}'`); - } - this.setAttribute('mode', val); - this._mode = val; - } - get item() { return this._item; } set item(val) { - if (val?.isRegularItem()) { + if (val?.isRegularItem() && !val?.isFeedItem) { this.hidden = false; } else { @@ -85,15 +65,15 @@ import { getCSSItemTypeIcon } from 'components/icons'; notify(event, type, ids, _extraData) { if (['modify', 'delete'].includes(event) && ids.some(id => this._noteIDs.includes(id))) { - this.render(true); + this._forceRenderAll(); } } - render(force = false) { + render() { if (!this._item) { return; } - if (!force && this._isAlreadyRendered()) return; + if (this._isAlreadyRendered()) return; this._noteIDs = this._item.getNotes(); @@ -123,7 +103,7 @@ import { getCSSItemTypeIcon } from 'components/icons'; row.append(box); - if (this._mode == 'edit') { + if (this.editable) { let remove = document.createXULElement("toolbarbutton"); remove.addEventListener('command', () => this._handleRemove(id)); remove.className = 'zotero-clicky zotero-clicky-minus'; diff --git a/chrome/content/zotero/elements/notesContext.js b/chrome/content/zotero/elements/notesContext.js index 5831cbd8bb..12cda1aa78 100644 --- a/chrome/content/zotero/elements/notesContext.js +++ b/chrome/content/zotero/elements/notesContext.js @@ -62,6 +62,7 @@ set editable(editable) { this._editable = editable; + this.toggleAttribute('readonly', !editable); } get libraryID() { @@ -90,20 +91,21 @@ this.node.selectedPanel = selectedPanel; } - get viewType() { + get mode() { return ["notesList", "standaloneNote", "childNote"][this.node.selectedIndex]; } - set viewType(viewType) { - let viewTypeMap = { + set mode(mode) { + let modeMap = { notesList: "0", standaloneNote: "1", childNote: "2", }; - if (!(viewType in viewTypeMap)) { - throw new Error(`NotesContext.viewType must be one of ["notesList", "standaloneNote", "childNote"], but got ${viewType}`); + if (!(mode in modeMap)) { + throw new Error(`NotesContext.mode must be one of ["notesList", "standaloneNote", "childNote"], but got ${mode}`); } - this.node.setAttribute("selectedIndex", viewTypeMap[viewType]); + // fx115: setting attribute doesn't work + this.node.selectedIndex = modeMap[mode]; } static get observedAttributes() { @@ -124,8 +126,6 @@ affectedIDs = new Set(); - updateFromCache = () => this._updateNotesList(true); - init() { this.node = this.querySelector(".context-node"); this.editor = this.querySelector(".zotero-context-pane-pinned-note"); @@ -143,7 +143,7 @@ } focus() { - if (this.viewType == "notesList") { + if (this.mode == "notesList") { this.input.focus(); return true; } @@ -218,7 +218,7 @@ } _createNote(child) { - this.viewType = "standaloneNote"; + this.mode = "standaloneNote"; let item = new Zotero.Item('note'); item.libraryID = this.libraryID; if (child) { @@ -236,17 +236,17 @@ } _isNotesListVisible() { - let splitter = ZoteroContextPane.getSplitter(); + let splitter = ZoteroContextPane.splitter; return Zotero_Tabs.selectedID != 'zotero-pane' - && ZoteroContextPane.viewType == "notes" - && this.viewType == "notesList" + && ZoteroContextPane.context.mode == "notes" + && this.mode == "notesList" && splitter.getAttribute('state') != 'collapsed'; } _getCurrentEditor() { - let splitter = ZoteroContextPane.getSplitter(); - if (splitter.getAttribute('state') == 'collapsed' || ZoteroContextPane.viewType != "notes") return null; + let splitter = ZoteroContextPane.splitter; + if (splitter.getAttribute('state') == 'collapsed' || ZoteroContextPane.context.mode != "notes") return null; return this.node.selectedPanel.querySelector('note-editor'); } @@ -289,13 +289,13 @@ editor.item = item; editor.parentItem = null; - this.viewType = "childNote"; + this.mode = "childNote"; tabNotesDeck.setAttribute('selectedIndex', tabNotesDeck.children.length - 1); parentTitleContainer = this.querySelector('.context-note-child > .zotero-context-pane-editor-parent-line'); } else { - this.viewType = "standaloneNote"; + this.mode = "standaloneNote"; editor.mode = this.editable ? 'edit' : 'view'; editor.item = item; editor.parentItem = null; @@ -313,8 +313,8 @@ returnBtn.addEventListener("command", () => { // Immediately save note content before vbox with note-editor iframe is destroyed below editor.saveSync(); - ZoteroContextPane.viewType = "notes"; - this.viewType = "notesList"; + ZoteroContextPane.context.mode = "notes"; + this.mode = "notesList"; vbox?.remove(); ZoteroContextPane.updateAddToNote(); this._preventViewTypeCache = true; @@ -327,6 +327,10 @@ ZoteroContextPane.updateAddToNote(); } + updateNotesListFromCache() { + this._updateNotesList(true); + } + async _updateNotesList(useCached) { let query = this.input.value; let notes; @@ -417,16 +421,15 @@ } _cacheViewType() { - if (ZoteroContextPane.viewType == "notes" - && this.viewType != "childNote" && !this._preventViewTypeCache) { - this._cachedViewType = this.viewType; + if (ZoteroContextPane.context.mode == "notes" + && this.mode != "childNote" && !this._preventViewTypeCache) { + this._cachedViewType = this.mode; } this._preventViewTypeCache = false; } _restoreViewType() { - if (!this._cachedViewType) return; - this.viewType = this._cachedViewType; + this.mode = this._cachedViewType || "notesList"; this._cachedViewType = ""; } diff --git a/chrome/content/zotero/elements/paneHeader.js b/chrome/content/zotero/elements/paneHeader.js index a1f298d342..25807d2f3a 100644 --- a/chrome/content/zotero/elements/paneHeader.js +++ b/chrome/content/zotero/elements/paneHeader.js @@ -54,8 +54,6 @@ _titleFieldID = null; - _mode = null; - get item() { return this._item; } @@ -64,14 +62,6 @@ this.blurOpenField(); this._item = item; } - - get mode() { - return this._mode; - } - - set mode(mode) { - this._mode = mode; - } init() { this._notifierID = Zotero.Notifier.registerObserver(this, ['item'], 'paneHeader'); @@ -103,7 +93,7 @@ notify(action, type, ids) { if (action == 'modify' && this.item && ids.includes(this.item.id)) { - this.render(true); + this._forceRenderAll(); } } @@ -122,7 +112,7 @@ this.item.setField(this._titleFieldID, this.titleField.value); await this.item.saveTx(); } - this.render(true); + this._forceRenderAll(); } async blurOpenField() { @@ -131,12 +121,12 @@ await this.save(); } } - - render(force = false) { + + render() { if (!this.item) { return; } - if (!force && this._isAlreadyRendered()) return; + if (this._isAlreadyRendered()) return; this._titleFieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(this.item.itemTypeID, 'title'); @@ -149,7 +139,7 @@ else { this.titleField.value = title; } - this.titleField.readOnly = this._mode == 'view'; + this.titleField.readOnly = !this.editable; if (this._titleFieldID) { this.titleField.placeholder = Zotero.ItemFields.getLocalizedString(this._titleFieldID); } @@ -164,9 +154,7 @@ }; if (callback) callback({ doc: document, - append: (...args) => { - append(...Components.utils.cloneInto(args, window, { wrapReflectors: true, cloneFunctions: true })); - } + append, }); } } diff --git a/chrome/content/zotero/elements/relatedBox.js b/chrome/content/zotero/elements/relatedBox.js index e9389826b1..641803b7b6 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._mode = 'view'; this._item = null; this._notifierID = Zotero.Notifier.registerObserver(this, ['item'], 'relatedbox'); this.initCollapsibleSection(); @@ -44,34 +43,16 @@ import { getCSSItemTypeIcon } from 'components/icons'; } destroy() { - this._section.removeEventListener('add', this.add); + this._section?.removeEventListener('add', this.add); Zotero.Notifier.unregisterObserver(this._notifierID); } - get mode() { - return this._mode; - } - - set mode(val) { - switch (val) { - case 'view': - case 'merge': - case 'mergeedit': - case 'edit': - break; - - default: - throw new Error(`Invalid mode '${val}'`); - } - this.setAttribute('mode', val); - this._mode = val; - } - get item() { return this._item; } set item(val) { + this.hidden = val?.isFeedItem; this._item = val; } @@ -80,7 +61,7 @@ import { getCSSItemTypeIcon } from 'components/icons'; // Refresh if this item has been modified if (event == 'modify' && ids.includes(this._item.id)) { - this.render(true); + this._forceRenderAll(); return; } @@ -90,16 +71,16 @@ import { getCSSItemTypeIcon } from 'components/icons'; let relatedItemIDs = new Set(this._item.relatedItems.map(key => Zotero.Items.getIDFromLibraryAndKey(libraryID, key))); for (let id of ids) { if (relatedItemIDs.has(id)) { - this.render(true); + this._forceRenderAll(); return; } } } } - render(force = false) { + render() { if (!this.item) return; - if (!force && this._isAlreadyRendered()) return; + if (this._isAlreadyRendered()) return; let body = this.querySelector('.body'); body.replaceChildren(); @@ -135,7 +116,7 @@ import { getCSSItemTypeIcon } from 'components/icons'; box.appendChild(label); row.append(box); - if (this._mode == 'edit') { + if (this.editable) { let remove = document.createXULElement("toolbarbutton"); remove.addEventListener('command', () => this._handleRemove(id)); remove.className = 'zotero-clicky zotero-clicky-minus'; diff --git a/chrome/content/zotero/elements/tagsBox.js b/chrome/content/zotero/elements/tagsBox.js index f51e6bb51b..ee95743b51 100644 --- a/chrome/content/zotero/elements/tagsBox.js +++ b/chrome/content/zotero/elements/tagsBox.js @@ -48,7 +48,6 @@ this._tabDirection = null; this._tagColors = []; this._notifierID = null; - this._mode = 'view'; this._item = null; this.initCollapsibleSection(); @@ -90,41 +89,16 @@ } destroy() { - this._section.removeEventListener('add', this._handleAddButtonClick); + this._section?.removeEventListener('add', this._handleAddButtonClick); Zotero.Notifier.unregisterObserver(this._notifierID); } - get mode() { - return this._mode; - } - - set mode(val) { - this.clickable = false; - this.editable = false; - - switch (val) { - case 'view': - case 'merge': - case 'mergeedit': - break; - - case 'edit': - this.clickable = true; - this.editable = true; - break; - - default: - throw new Error(`Invalid mode ${val}`); - } - this.setAttribute('mode', val); - this._mode = val; - } - get item() { return this._item; } set item(val) { + this.hidden = val?.isFeedItem; // Don't reload if item hasn't changed if (this._item == val) { return; @@ -134,7 +108,7 @@ notify(event, type, ids, extraData) { if (type == 'setting' && ids.some(val => val.split("/")[1] == 'tagColors') && this.item) { - this.render(true); + this._forceRenderAll(); } else if (type == 'item-tag') { let itemID, _tagID; @@ -164,13 +138,13 @@ this.updateCount(); } else if (type == 'tag' && event == 'modify') { - this.render(true); + this._forceRenderAll(); } } - render(force = false) { + render() { if (!this.item) return; - if (!force && this._isAlreadyRendered()) return; + if (this._isAlreadyRendered()) return; Zotero.debug('Reloading tags box'); @@ -297,7 +271,7 @@ await item.saveTx(); } catch (e) { - this.render(true); + this._forceRenderAll(); throw e; } } @@ -471,7 +445,7 @@ await this.item.saveTx(); } catch (e) { - this.render(true); + this._forceRenderAll(); throw e; } } @@ -488,7 +462,7 @@ await this.item.saveTx(); } catch (e) { - this.render(true); + this._forceRenderAll(); throw e; } } @@ -511,7 +485,7 @@ tags.forEach(tag => this.item.addTag(tag)); await this.item.saveTx(); - this.render(true); + this._forceRenderAll(); } // Single tag at end else { @@ -529,7 +503,7 @@ await this.item.saveTx(); } catch (e) { - this.render(true); + this._forceRenderAll(); throw e; } } diff --git a/chrome/content/zotero/tabs.js b/chrome/content/zotero/tabs.js index 0b51c95e08..7ff330550b 100644 --- a/chrome/content/zotero/tabs.js +++ b/chrome/content/zotero/tabs.js @@ -753,7 +753,7 @@ var Zotero_Tabs = new function () { // Used to move focus back to itemTree or contextPane from the tabs. this.focusWrapAround = function () { // If no item is selected, focus items list. - if (ZoteroPane.itemPane.viewType == "message") { + if (ZoteroPane.itemPane.mode == "message") { document.getElementById("item-tree-main-default").focus(); } else { diff --git a/chrome/content/zotero/xpcom/itemPaneManager.js b/chrome/content/zotero/xpcom/itemPaneManager.js new file mode 100644 index 0000000000..89664e692c --- /dev/null +++ b/chrome/content/zotero/xpcom/itemPaneManager.js @@ -0,0 +1,335 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2024 Corporation for Digital Scholarship + Vienna, Virginia, USA + https://digitalscholar.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + + +/** + * @typedef SectionIcon + * @type {object} + * @property {string} icon - Icon URI + * @property {string} [darkIcon] - Icon URI in dark mode. If not set, use `icon` + * @typedef SectionL10n + * @type {object} + * @property {string} l10nID - data-l10n-id for localization + * @property {string} [l10nArgs] - data-l10n-args for localization + * @typedef SectionButton + * @type {object} + * @property {string} type - Button type, must be valid DOMString and without "," + * @property {(props: SectionEventHookArgs) => void} onClick - Button click callback + * @typedef SectionBasicHookArgs + * @type {object} + * @property {string} paneID - Registered pane id + * @property {Document} doc - Document of section + * @property {HTMLDivElement} body - Section body + * @typedef SectionUIHookArgs + * @type {object} + * @property {Zotero.Item} item - Current item + * @property {string} tabType - Current tab type + * @property {boolean} editable - Whether the section is in edit mode + * @property {(l10nArgs: string) => void} setL10nArgs - Set l10n args for section header + * @property {(l10nArgs: string) => void} setEnabled - Set pane enabled state + * @property {(summary: string) => void} setSectionSummary - Set pane section summary, + * the text shown in the section header when the section is collapsed. + * + * See the Abstract section as an example + * @property {(buttonType: string, options: {disabled?: boolean; hidden?: boolean}) => void} setSectionButtonStatus - Set pane section button status + * @typedef SectionHookArgs + * @type {SectionBasicHookArgs & SectionUIHookArgs} + * @typedef {SectionHookArgs & { refresh: () => Promise }} SectionInitHookArgs + * A `refresh` is exposed to plugins to allows plugins to refresh the section when necessary, + * e.g. item modify notifier callback. Note that calling `refresh` during initialization + * have no effect. + * @typedef {SectionHookArgs & { event: Event }} SectionEventHookArgs + * @typedef ItemDetailsSectionOptions + * @type {object} + * @property {string} paneID - Unique pane ID + * @property {string} pluginID - Set plugin ID to auto remove section when plugin is disabled/removed + * @property {SectionL10n & SectionIcon} head - Header options. Icon should be 16*16 and `label` need to be localized + * @property {SectionL10n & SectionIcon} sidenav - Sidenav options. Icon should be 20*20 and `tooltiptext` need to be localized + * @property {string} [bodyXHTML] - Pane body's innerHTML, default to XUL namespace + * @property {(props: SectionInitHookArgs) => void} [onInit] + * Lifecycle hook called when section is initialized. + * You can use destructuring assignment to get the props: + * ```js + * onInit({ paneID, doc, body, item, editable, tabType, setL10nArgs, setEnabled, + * setSectionSummary, setSectionButtonStatus, refresh }) { + * // Your code here + * } + * ``` + * + * Do: + * 1. Initialize data if necessary + * 2. Set up hooks, e.g. notifier callback + * + * Don't: + * 1. Render/refresh UI + * @property {(props: SectionBasicHookArgs) => void} [onDestroy] + * Lifecycle hook called when section is destroyed + * + * Do: + * 1. Remove data and release resource + * 2. Remove hooks, e.g. notifier callback + * + * Don't: + * 1. Render/refresh UI + * @property {(props: SectionHookArgs) => boolean} [onItemChange] + * Lifecycle hook called when section's item change received + * + * Do: + * 1. Update data (no need to render or refresh); + * 2. Update the section enabled state with `props.setEnabled`. For example, if the section + * is only enabled in the readers, you can use: + * ```js + * onItemChange({ setEnabled }) { + * setEnabled(newData.value === "reader"); + * } + * ``` + * + * Don't: + * 1. Render/refresh UI + * @property {(props: SectionHookArgs) => void} onRender + * Lifecycle hook called when section should do initial render. Cannot be async. + * + * Create elements and append them to `props.body`. + * + * If the rendering is slow, you should make the bottleneck async and move it to `onAsyncRender`. + * + * > Note that the rendering of section is fully controlled by Zotero to minimize resource usage. + * > Only render UI things when you are told to. + * @property {(props: SectionHookArgs) => void | Promise} [onAsyncRender] + * [Optional] Lifecycle hook called when section should do async render + * + * The best practice to time-consuming rendering with runtime decided section height is: + * 1. Compute height and create a box in sync `onRender`; + * 2. Render actual contents in async `onAsyncRender`. + * @property {(props: SectionEventHookArgs) => void} [onToggle] - Called when section is toggled + * @property {SectionButton[]} [sectionButtons] - Section button options + */ + + +class ItemPaneManager { + _customSections = {}; + + _lastUpdateTime = 0; + + /** + * Register a custom section in item pane. All registered sections must be valid, and must have a unique paneID. + * @param {ItemDetailsSectionOptions} options - section data + * @returns {string | false} - The paneID or false if no section were added + */ + registerSection(options) { + let registeredID = this._addSection(options); + if (!registeredID) { + return false; + } + this._addPluginShutdownObserver(); + this._notifyItemPane(); + return registeredID; + } + + /** + * Unregister a custom column. + * @param {string} paneID - The paneID of the section(s) to unregister + * @returns {boolean} true if the column(s) are unregistered + */ + unregisterSection(paneID) { + const success = this._removeSection(paneID); + if (!success) { + return false; + } + this._notifyItemPane(); + return true; + } + + getUpdateTime() { + return this._lastUpdateTime; + } + + /** + * @returns {ItemDetailsSectionOptions[]} + */ + getCustomSections() { + return Object.values(this._customSections).map(opt => Object.assign({}, opt)); + } + + /** + * @param {ItemDetailsSectionOptions} options + * @returns {string | false} + */ + _addSection(options) { + options = Object.assign({}, options); + options.paneID = this._namespacedDataKey(options); + if (!this._validateSectionOptions(options)) { + return false; + } + this._customSections[options.paneID] = options; + return options.paneID; + } + + _removeSection(paneID) { + // If any check fails, return check results and do not remove any section + if (!this._customSections[paneID]) { + Zotero.warn(`ItemPaneManager section option with paneID ${paneID} does not exist.`); + return false; + } + delete this._customSections[paneID]; + return true; + } + + /** + * @param {ItemDetailsSectionOptions} options + * @returns {boolean} + */ + _validateSectionOptions(options) { + let requiredParamsType = { + paneID: "string", + pluginID: "string", + head: (val) => { + if (typeof val != "object") { + return "ItemPaneManager section options head must be object"; + } + if (!val.l10nID || typeof val.l10nID != "string") { + return "ItemPaneManager section options head l10nID must be non-empty string"; + } + if (!val.icon || typeof val.icon != "string") { + return "ItemPaneManager section options head icon must be non-empty string"; + } + return true; + }, + sidenav: (val) => { + if (typeof val != "object") { + return "ItemPaneManager section options sidenav must be object"; + } + if (!val.l10nID || typeof val.l10nID != "string") { + return "ItemPaneManager section options sidenav l10nID must be non-empty string"; + } + if (!val.icon || typeof val.icon != "string") { + return "ItemPaneManager section options sidenav icon must be non-empty string"; + } + return true; + }, + }; + // Keep in sync with itemDetails.js + let builtInPaneIDs = [ + "info", + "abstract", + "attachments", + "notes", + "attachment-info", + "attachment-annotations", + "libraries-collections", + "tags", + "related" + ]; + for (let key of Object.keys(requiredParamsType)) { + let val = options[key]; + if (!val) { + Zotero.warn(`ItemPaneManager section option must have ${key}`); + return false; + } + let requiredType = requiredParamsType[key]; + if (typeof requiredType == "string" && typeof val != requiredType) { + Zotero.warn(`ItemPaneManager section option ${key} must be ${requiredType}, but got ${typeof val}`); + return false; + } + if (typeof requiredType == "function") { + let result = requiredType(val); + if (result !== true) { + Zotero.warn(result); + return false; + } + } + } + if (builtInPaneIDs.includes(options.paneID)) { + Zotero.warn(`ItemPaneManager section option paneID must not conflict with built-in paneID, but got ${options.paneID}`); + return false; + } + if (this._customSections[options.paneID]) { + Zotero.warn(`ItemPaneManager section option paneID must be unique, but got ${options.paneID}`); + return false; + } + + return true; + } + + /** + * Make sure the dataKey is namespaced with the plugin ID + * @param {ItemDetailsSectionOptions} options + * @returns {string} + */ + _namespacedDataKey(options) { + if (options.pluginID && options.paneID) { + // Make sure the return value is valid as class name or element id + return `${options.pluginID}-${options.paneID}`.replace(/[^a-zA-Z0-9-_]/g, "-"); + } + return options.paneID; + } + + async _notifyItemPane() { + this._lastUpdateTime = new Date().getTime(); + await Zotero.DB.executeTransaction(async function () { + Zotero.Notifier.queue( + 'refresh', + 'itempane', + [], + {}, + ); + }); + } + + /** + * Unregister all columns registered by a plugin + * @param {string} pluginID - Plugin ID + */ + async _unregisterSectionByPluginID(pluginID) { + let paneIDs = Object.keys(this._customSections).filter(id => this._customSections[id].pluginID == pluginID); + if (paneIDs.length === 0) { + return; + } + // Remove the columns one by one + // This is to ensure that the columns are removed and not interrupted by any non-existing columns + paneIDs.forEach(id => this._removeSection(id)); + Zotero.debug(`ItemPaneManager sections registered by plugin ${pluginID} unregistered due to shutdown`); + await this._notifyItemPane(); + } + + /** + * Ensure that the shutdown observer is added + * @returns {void} + */ + _addPluginShutdownObserver() { + if (this._observerAdded) { + return; + } + + Zotero.Plugins.addObserver({ + shutdown: ({ id: pluginID }) => { + this._unregisterSectionByPluginID(pluginID); + } + }); + this._observerAdded = true; + } +} + +Zotero.ItemPaneManager = new ItemPaneManager(); diff --git a/chrome/content/zotero/xpcom/notifier.js b/chrome/content/zotero/xpcom/notifier.js index b118056f8c..bd83ea2bc2 100644 --- a/chrome/content/zotero/xpcom/notifier.js +++ b/chrome/content/zotero/xpcom/notifier.js @@ -34,7 +34,7 @@ Zotero.Notifier = new function(){ 'collection', 'search', 'share', 'share-items', 'item', 'file', 'collection-item', 'item-tag', 'tag', 'setting', 'group', 'trash', 'bucket', 'relation', 'feed', 'feedItem', 'sync', 'api-key', 'tab', - 'itemtree' + 'itemtree', 'itempane' ]; var _transactionID = false; var _queue = {}; diff --git a/chrome/content/zotero/xpcom/reader.js b/chrome/content/zotero/xpcom/reader.js index 97fabed71f..cd34e55fb0 100644 --- a/chrome/content/zotero/xpcom/reader.js +++ b/chrome/content/zotero/xpcom/reader.js @@ -907,8 +907,9 @@ class ReaderInstance { let rect = this._iframe.getBoundingClientRect(); x += rect.left; y += rect.top; - tagsbox.mode = 'edit'; + tagsbox.editable = true; tagsbox.item = item; + tagsbox.render(); menupopup.openPopup(null, 'before_start', x, y, true); setTimeout(() => { if (tagsbox.count == 0) { @@ -1125,7 +1126,10 @@ class ReaderTab extends ReaderInstance { _addToNote(annotations) { annotations = annotations.map(x => ({ ...x, attachmentItemID: this._item.id })); - let noteEditor = this._window.ZoteroContextPane && this._window.ZoteroContextPane.getActiveEditor(); + if (!this._window.ZoteroContextPane) { + return; + } + let noteEditor = this._window.ZoteroContextPane.activeEditor; if (!noteEditor) { return; } diff --git a/chrome/content/zotero/zotero.mjs b/chrome/content/zotero/zotero.mjs index 84b0999f07..44c1cee9cf 100644 --- a/chrome/content/zotero/zotero.mjs +++ b/chrome/content/zotero/zotero.mjs @@ -107,6 +107,7 @@ const xpcomFilesLocal = [ 'id', 'integration', 'itemTreeManager', + 'itemPaneManager', 'locale', 'locateManager', 'mime', diff --git a/chrome/content/zotero/zoteroPane.js b/chrome/content/zotero/zoteroPane.js index c8e46682a4..55375d7980 100644 --- a/chrome/content/zotero/zoteroPane.js +++ b/chrome/content/zotero/zoteroPane.js @@ -1221,7 +1221,7 @@ var ZoteroPane = new function() if (this.itemsView && from == this.itemsView.id) { // Focus TinyMCE explicitly on tab key, since the normal focusing doesn't work right if (!event.shiftKey && event.keyCode == event.DOM_VK_TAB) { - if (ZoteroPane.itemPane.viewType == "note") { + if (ZoteroPane.itemPane.mode == "note") { document.getElementById('zotero-note-editor').focus(); event.preventDefault(); return; @@ -1370,7 +1370,7 @@ var ZoteroPane = new function() } } - yield this.itemPane._itemDetails.blurOpenField(); + yield this.itemPane.handleBlur(); if (row !== undefined && row !== null) { var collectionTreeRow = this.collectionsView.getRow(row); @@ -1868,14 +1868,8 @@ var ZoteroPane = new function() // Display buttons at top of item pane depending on context. This needs to run even if the // selection hasn't changed, because the selected items might have been modified. this.itemPane.data = selectedItems; - let viewMode = { - isFeedsOrFeed: collectionTreeRow.isFeedsOrFeed(), - isDuplicates: collectionTreeRow.isDuplicates(), - isPublications: collectionTreeRow.isPublications(), - isTrash: collectionTreeRow.isTrash(), - rowCount: this.itemsView.rowCount, - }; - this.itemPane.viewMode = viewMode; + this.itemPane.collectionTreeRow = collectionTreeRow; + this.itemPane.itemsView = this.itemsView; this.itemPane.editable = this.collectionsView.editable; this.itemPane.updateItemPaneButtons(selectedItems); @@ -2245,11 +2239,11 @@ var ZoteroPane = new function() return; } - this.itemPane.viewType = "duplicates"; + this.itemPane.mode = "duplicates"; // Initialize the merge pane with the selected items this.itemPane._duplicatesPane.setItems(this.getSelectedItems()); - } + }; this.deleteSelectedCollection = function (deleteItems) { diff --git a/scss/_zotero.scss b/scss/_zotero.scss index 3eae0e56a9..c84961e80c 100644 --- a/scss/_zotero.scss +++ b/scss/_zotero.scss @@ -93,4 +93,5 @@ @import "elements/itemMessagePane"; @import "elements/itemDetails"; @import "elements/itemPane"; +@import "elements/itemPaneCustomSection"; @import "elements/contextPane"; diff --git a/scss/elements/_collapsibleSection.scss b/scss/elements/_collapsibleSection.scss index cf7ecd8fdb..a91ce05c20 100644 --- a/scss/elements/_collapsibleSection.scss +++ b/scss/elements/_collapsibleSection.scss @@ -62,14 +62,6 @@ collapsible-section { toolbarbutton.add { @include svgicon-menu("plus", "universal", "16"); - - &:hover { - background: var(--fill-quinary); - } - - &:active { - background: var(--fill-quarternary); - } } toolbarbutton.twisty .toolbarbutton-icon { diff --git a/scss/elements/_contextPane.scss b/scss/elements/_contextPane.scss index 4573bcff99..e9e2b99eb4 100644 --- a/scss/elements/_contextPane.scss +++ b/scss/elements/_contextPane.scss @@ -1,2 +1,3 @@ context-pane { + -moz-box-orient: vertical; } diff --git a/scss/elements/_itemPaneCustomSection.scss b/scss/elements/_itemPaneCustomSection.scss new file mode 100644 index 0000000000..d6a1857b21 --- /dev/null +++ b/scss/elements/_itemPaneCustomSection.scss @@ -0,0 +1,45 @@ +item-pane-custom-section { + display: flex; + flex-direction: column; + + &[hidden] { + display: none; + } + + .body { + display: flex; + flex-direction: column; + margin: 0; + } + + collapsible-section { + &[custom] > .head { + & .title::before { + content: ''; + width: 16px; + height: 16px; + -moz-context-properties: fill, fill-opacity, stroke, stroke-opacity; + fill: currentColor; + stroke: currentColor; + + @media (prefers-color-scheme: light) { + background: var(--custom-section-icon-light) no-repeat center; + } + @media (prefers-color-scheme: dark) { + background: var(--custom-section-icon-dark) no-repeat center; + } + } + + toolbarbutton.section-custom-button { + fill: currentColor; + -moz-context-properties: fill, fill-opacity; + @media (prefers-color-scheme: light) { + list-style-image: var(--custom-button-icon-light) + } + @media (prefers-color-scheme: dark) { + list-style-image: var(--custom-button-icon-dark) + } + } + } + } +} diff --git a/scss/elements/_itemPaneSidenav.scss b/scss/elements/_itemPaneSidenav.scss index d0cd283d7f..fae6abfc8f 100644 --- a/scss/elements/_itemPaneSidenav.scss +++ b/scss/elements/_itemPaneSidenav.scss @@ -107,6 +107,18 @@ item-pane-sidenav { fill: var(--fill-secondary); stroke: var(--fill-secondary); } + + &[custom] { + @media (prefers-color-scheme: light) { + list-style-image: var(--custom-sidenav-icon-light); + } + @media (prefers-color-scheme: dark) { + list-style-image: var(--custom-sidenav-icon-dark); + } + fill: var(--fill-secondary); + stroke: var(--fill-secondary); + -moz-context-properties: fill, fill-opacity, stroke, stroke-opacity; + } } &.stacked toolbarbutton[data-pane="toggle-collapse"] { diff --git a/scss/elements/_librariesCollectionsBox.scss b/scss/elements/_librariesCollectionsBox.scss index bdd52be054..62be43211b 100644 --- a/scss/elements/_librariesCollectionsBox.scss +++ b/scss/elements/_librariesCollectionsBox.scss @@ -54,7 +54,7 @@ libraries-collections-box { } } - &:not([mode=edit]) { + &[readonly] { .add { display: none; } diff --git a/scss/elements/_notesBox.scss b/scss/elements/_notesBox.scss index 6308180d11..df1e2389f3 100644 --- a/scss/elements/_notesBox.scss +++ b/scss/elements/_notesBox.scss @@ -7,7 +7,7 @@ notes-box, related-box { display: none; } - &:not([mode=edit]) { + &[readonly] { .add { display: none; } diff --git a/scss/elements/_tagsBox.scss b/scss/elements/_tagsBox.scss index 414f7a5b19..2dc568df7a 100644 --- a/scss/elements/_tagsBox.scss +++ b/scss/elements/_tagsBox.scss @@ -1,6 +1,10 @@ tags-box { display: flex; flex-direction: column; + + &[hidden] { + display: none; + } .body { display: flex; @@ -72,7 +76,7 @@ tags-box { } } - &:not([mode=edit]) { + &[readonly] { .add { display: none; } diff --git a/scss/scaffold.scss b/scss/scaffold.scss index 75a3f27f3d..b695871d18 100644 --- a/scss/scaffold.scss +++ b/scss/scaffold.scss @@ -100,11 +100,6 @@ tab { -moz-box-align: center; } -#output { - height:200px; - background: var(--material-background); -} - richlistbox { min-width:200px; } @@ -129,11 +124,13 @@ vbox > splitter { #left-tabbox { margin: 5px; + width: 100% !important; } -#checkboxes-translatorType checkbox { - margin-right: 10px; - display: inline-block; +#checkboxes-translatorType { + display: flex; + flex-direction: row; + gap: 10px; } #tabpanel-metadata label:first-child { @@ -143,7 +140,11 @@ vbox > splitter { #right-pane { margin: -16px -16px -16px 0px; border-left: var(--material-panedivider); - textarea { + + #output { + width: 100%; + height: 100%; + background: var(--material-background); outline: none; border: 0; }