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"/>
-
+
-
+
-
+
{
+ 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);
+ });
+
+ 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);
+ }
+
+ removePane(paneID) {
+ let toolbarbutton = this.querySelector(`toolbarbutton[data-pane=${paneID}]`);
+ if (!toolbarbutton) return;
+ toolbarbutton.parentElement.remove();
+ }
+
updatePaneStatus(paneID) {
if (!paneID) {
this.render();
return;
}
+
let toolbarbutton = this.querySelector(`toolbarbutton[data-pane=${paneID}]`);
if (!toolbarbutton) return;
- toolbarbutton.parentElement.hidden = !this.container.getPane(paneID);
+ toolbarbutton.parentElement.hidden = !this.container.getEnabledPane(paneID);
if (this.pinnedPane) {
if (paneID == this.pinnedPane && !toolbarbutton.parentElement.classList.contains("pinned")) {
this.querySelector(".pin-wrapper.pinned")?.classList.remove("pinned");
@@ -303,7 +349,7 @@
this.querySelectorAll('toolbarbutton').forEach((elem) => {
elem.disabled = false;
});
- this.render(true);
+ this.render();
}
}
diff --git a/chrome/content/zotero/elements/librariesCollectionsBox.js b/chrome/content/zotero/elements/librariesCollectionsBox.js
index 592ed15f29..7f6eda27bf 100644
--- a/chrome/content/zotero/elements/librariesCollectionsBox.js
+++ b/chrome/content/zotero/elements/librariesCollectionsBox.js
@@ -42,18 +42,14 @@ import { getCSSIcon } from 'components/icons';
`, ['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;
}