diff --git a/chrome/content/zotero/collectionViewItemTree.jsx b/chrome/content/zotero/collectionViewItemTree.jsx index 22d1326536..e3a309632b 100644 --- a/chrome/content/zotero/collectionViewItemTree.jsx +++ b/chrome/content/zotero/collectionViewItemTree.jsx @@ -350,7 +350,9 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider { let rowChanged; switch (type) { case 'search': - rowChanged = collectionTreeRow.setSearch(data, Zotero.Prefs.get('search.quicksearch-mode')); + rowChanged = collectionTreeRow.setSearch( + data, Zotero.Prefs.getForWindow(window, 'search.quicksearch-mode') + ); break; case 'citation-search': rowChanged = collectionTreeRow.setSearch(data, 'fields'); diff --git a/chrome/content/zotero/elements/collapsibleSection.js b/chrome/content/zotero/elements/collapsibleSection.js index c509da76db..a6bda3dba9 100644 --- a/chrome/content/zotero/elements/collapsibleSection.js +++ b/chrome/content/zotero/elements/collapsibleSection.js @@ -200,7 +200,9 @@ this.render(); }); - this._prefsObserverID = Zotero.Prefs.registerObserver(`panes.${this.dataset.pane}.open`, this._restoreOpenState.bind(this)); + this._unobserveOpenState = Zotero.Prefs.observeForWindow( + window, `panes.${this.dataset.pane}.open`, this._restoreOpenState.bind(this) + ); if (this.hasAttribute('data-l10n-id') && !this.hasAttribute('data-l10n-args')) { this.setAttribute('data-l10n-args', JSON.stringify({ count: 0 })); @@ -358,12 +360,13 @@ this._contextMenu?.remove(); - Zotero.Prefs.unregisterObserver(this._prefsObserverID); + this._unobserveOpenState?.(); + this._unobserveOpenState = null; } _saveOpenState() { if (this._disableSavingOpenState || this._skipSaveOpenState) return; - Zotero.Prefs.set(`panes.${this.dataset.pane}.open`, this.open); + Zotero.Prefs.setForWindow(window, `panes.${this.dataset.pane}.open`, this.open); } _restoreOpenState() { @@ -372,7 +375,7 @@ return; } this._restoringOpenState = true; - this.open = Zotero.Prefs.get(`panes.${this.dataset.pane}.open`) ?? true; + this.open = Zotero.Prefs.getForWindow(window, `panes.${this.dataset.pane}.open`) ?? true; this._restoringOpenState = false; } diff --git a/chrome/content/zotero/elements/itemDetails.js b/chrome/content/zotero/elements/itemDetails.js index 437d1de60b..d05b447805 100644 --- a/chrome/content/zotero/elements/itemDetails.js +++ b/chrome/content/zotero/elements/itemDetails.js @@ -187,7 +187,9 @@ this._notifierID = Zotero.Notifier.registerObserver( this, ['item', 'itempane', 'tab'], 'ItemDetails'); - this._prefsObserverID = Zotero.Prefs.registerObserver('pinnedPane', this._restorePinnedPane.bind(this)); + this._unobservePinnedPane = Zotero.Prefs.observeForWindow( + window, 'pinnedPane', this._restorePinnedPane.bind(this) + ); this._disableScrollHandler = false; this._pinnedPaneMinScrollHeight = 0; @@ -211,7 +213,8 @@ this._intersectionOb.disconnect(); Zotero.Notifier.unregisterObserver(this._notifierID); - Zotero.Prefs.unregisterObserver(this._prefsObserverID); + this._unobservePinnedPane?.(); + this._unobservePinnedPane = null; } async render() { @@ -666,19 +669,14 @@ return; } let pinnedPane = this.pinnedPane; - if (pinnedPane) { - Zotero.Prefs.set('pinnedPane', pinnedPane); - } - else { - Zotero.Prefs.clear('pinnedPane'); - } + Zotero.Prefs.setForWindow(window, 'pinnedPane', pinnedPane || undefined); } _restorePinnedPane() { if (this.tabType !== 'library') { return; } - let pinnedPane = Zotero.Prefs.get('pinnedPane') || ''; + let pinnedPane = Zotero.Prefs.getForWindow(window, 'pinnedPane') || ''; if (this.pinnedPane !== pinnedPane) { this.pinnedPane = pinnedPane; } diff --git a/chrome/content/zotero/elements/itemPaneSidenav.js b/chrome/content/zotero/elements/itemPaneSidenav.js index c376a35d45..b8a92ed72e 100644 --- a/chrome/content/zotero/elements/itemPaneSidenav.js +++ b/chrome/content/zotero/elements/itemPaneSidenav.js @@ -87,7 +87,7 @@ _dropIndicator = null; - _prefObserverID = null; + _unobservePaneOrder = null; get observedAttributes() { return ["no-context-notes"]; @@ -341,7 +341,9 @@ }); this.setAttribute("role", "tablist"); - this._prefObserverID = Zotero.Prefs.registerObserver("sidenav.order", this.handlePaneOrderChange); + this._unobservePaneOrder = Zotero.Prefs.observeForWindow( + window, "sidenav.order", this.handlePaneOrderChange + ); this._initialized = true; } @@ -357,7 +359,8 @@ this._buttonContainer.removeEventListener('dragend', this.handleButtonDragEnd); this._buttonContainer.removeEventListener('dragleave', this.handleButtonDragLeave); - Zotero.Prefs.unregisterObserver(this._prefObserverID); + this._unobservePaneOrder?.(); + this._unobservePaneOrder = null; this._initialized = false; } @@ -456,13 +459,13 @@ currentOrder.push(paneID); } - Zotero.Prefs.set("sidenav.order", currentOrder.join(",")); + Zotero.Prefs.setForWindow(window, "sidenav.order", currentOrder.join(",")); } getPersistedOrder(value = null) { if (value === null) { - value = Zotero.Prefs.get("sidenav.order"); + value = Zotero.Prefs.getForWindow(window, "sidenav.order"); } if (!value) return this._builtInPanes; try { @@ -991,10 +994,10 @@ } }; - handlePaneOrderChange = async (value) => { + handlePaneOrderChange = async () => { // If no container, wait until it's set if (!this.container) return; - let order = this.getPersistedOrder(value); + let order = this.getPersistedOrder(); let hasChange = false; for (let i = 0; i < order.length; i++) { let paneID = order[i]; diff --git a/chrome/content/zotero/elements/libraryView.js b/chrome/content/zotero/elements/libraryView.js index 616a329ace..a0fbd92bfb 100644 --- a/chrome/content/zotero/elements/libraryView.js +++ b/chrome/content/zotero/elements/libraryView.js @@ -1131,7 +1131,7 @@ // items list refreshes once let searchText = state.quickSearch || ''; let tags = new Set(state.tags?.selected || []); - let mode = Zotero.Prefs.get('search.quicksearch-mode'); + let mode = Zotero.Prefs.getForWindow(window, 'search.quicksearch-mode'); // A quick search with conditions in it is run as a search, as search() does let query = Zotero.SearchQuery.getSearch(searchText, { mode }); this._quickSearchIsQuery = !!query; @@ -1551,7 +1551,7 @@ // carries none of its own -- with one, a multi-library selection would // match only in that library let query = Zotero.SearchQuery.getSearch(searchVal, { - mode: Zotero.Prefs.get('search.quicksearch-mode') + mode: Zotero.Prefs.getForWindow(window, 'search.quicksearch-mode') }); if (query) { this._quickSearchIsQuery = true; diff --git a/chrome/content/zotero/elements/quickSearchTextbox.js b/chrome/content/zotero/elements/quickSearchTextbox.js index f20c25ca1d..a1ce4c6d96 100644 --- a/chrome/content/zotero/elements/quickSearchTextbox.js +++ b/chrome/content/zotero/elements/quickSearchTextbox.js @@ -113,7 +113,7 @@ event.stopPropagation(); // If there's text in the field, seed the Advanced Search with it, // reproducing the current quick search mode as editable conditions. - let mode = Zotero.Prefs.get('search.quicksearch-mode'); + let mode = Zotero.Prefs.getForWindow(window, 'search.quicksearch-mode'); // Shift-click opens the Advanced Search in a new window, and // Cmd/Ctrl-click in a new tab if (event.shiftKey) { @@ -173,7 +173,7 @@ item.value = mode; item.addEventListener('command', () => { - Zotero.Prefs.set("search.quicksearch-mode", mode); + Zotero.Prefs.setForWindow(window, "search.quicksearch-mode", mode); this.updateMode(); if (this.value) { @@ -193,10 +193,10 @@ } updateMode() { - let mode = Zotero.Prefs.get("search.quicksearch-mode"); + let mode = Zotero.Prefs.getForWindow(window, "search.quicksearch-mode"); if (!this._searchModes[mode]) { - Zotero.Prefs.set("search.quicksearch-mode", "fields"); + Zotero.Prefs.setForWindow(window, "search.quicksearch-mode", "fields"); mode = 'fields'; } diff --git a/chrome/content/zotero/itemTree.jsx b/chrome/content/zotero/itemTree.jsx index c66fbe78be..8fffab0862 100644 --- a/chrome/content/zotero/itemTree.jsx +++ b/chrome/content/zotero/itemTree.jsx @@ -2504,6 +2504,7 @@ var ItemTree = class ItemTree extends LibraryTree { } } this._columnPrefs = prefs; + window.ZoteroPane?.setItemTreeColumnPrefs?.(this.prefsId, prefs); // Invalidate the cached _columns array; next _getColumns() call will // rebuild canonical state from COLUMNS defs + _columnPrefs + viewType. this._columnsId = null; @@ -2519,8 +2520,16 @@ var ItemTree = class ItemTree extends LibraryTree { _loadColumnPrefsFromFile = async () => { if (!this.props.columnPicker) return; + // A main window keeps its own column settings, starting with the ones saved most + // recently in any window + let windowPrefs = window.ZoteroPane?.getItemTreeColumnPrefs?.(this.prefsId); + if (windowPrefs) { + this._columnPrefs = windowPrefs; + return; + } await _migrateTreePrefsFile(); this._columnPrefs = await TreePrefs.get(this.prefsId); + window.ZoteroPane?.setItemTreeColumnPrefs?.(this.prefsId, this._columnPrefs); } /** diff --git a/chrome/content/zotero/xpcom/prefs.js b/chrome/content/zotero/xpcom/prefs.js index 9e80ad3c23..14e8682be1 100644 --- a/chrome/content/zotero/xpcom/prefs.js +++ b/chrome/content/zotero/xpcom/prefs.js @@ -410,16 +410,10 @@ Zotero.Prefs = new function () { Zotero.logError(e); } }], + // Main windows keep their own mode (see ZoteroPane.getWindowSetting()) [ "search.quicksearch-mode", function (val) { var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] .getService(Components.interfaces.nsIWindowMediator); - var enumerator = wm.getEnumerator("navigator:browser"); - while (enumerator.hasMoreElements()) { - var win = enumerator.getNext(); - if (!win.ZoteroPane) continue; - Zotero.updateQuickSearchBox(win.ZoteroPane.document); - } - var enumerator = wm.getEnumerator("zotero:item-selector"); while (enumerator.hasMoreElements()) { var win = enumerator.getNext(); @@ -477,6 +471,64 @@ Zotero.Prefs = new function () { var _observers = {}; var _observersBySymbol = {}; + // Whether a pref is being set as a main window's own setting, which other windows ignore + this.settingForWindow = false; + + /** + * Get a setting that each main window keeps for itself (see ZoteroPane.getWindowSetting()), + * or the pref of the same name outside of a main window + * + * @param {Window} win + * @param {String} name + * @return {*} + */ + this.getForWindow = function (win, name) { + if (win.ZoteroPane?.getWindowSetting) { + return win.ZoteroPane.getWindowSetting(name); + } + return this.get(name); + }; + + /** + * @param {Window} win + * @param {String} name + * @param {*} value - undefined to clear + */ + this.setForWindow = function (win, name, value) { + if (win.ZoteroPane?.setWindowSetting) { + win.ZoteroPane.setWindowSetting(name, value); + } + else if (value === undefined) { + this.clear(name); + } + else { + this.set(name, value); + } + }; + + /** + * Observe a setting that each main window keeps for itself, or the pref of the same name + * outside of a main window + * + * @param {Window} win + * @param {String} name + * @param {Function} handler + * @return {Function} - Call to stop observing + */ + this.observeForWindow = function (win, name, handler) { + if (win.ZoteroPane?.getWindowSetting) { + let listener = (event) => { + if (event.detail.name == name) { + handler(); + } + }; + win.addEventListener('zotero-window-setting-change', listener); + return () => win.removeEventListener('zotero-window-setting-change', listener); + } + let symbol = this.registerObserver(name, handler); + return () => this.unregisterObserver(symbol); + }; + /** * @param {String} name - Preference name; if not global, this is on the extensions.zotero branch * @param {Function} handler diff --git a/chrome/content/zotero/xpcom/zotero.js b/chrome/content/zotero/xpcom/zotero.js index 7f0ff2529a..776b728f05 100644 --- a/chrome/content/zotero/xpcom/zotero.js +++ b/chrome/content/zotero/xpcom/zotero.js @@ -1175,6 +1175,8 @@ const { CommandLineOptions } = ChromeUtils.importESModule("chrome://zotero/conte * @param {Object} [args.movedTab] - { windowID, tabID } of a tab in another window to move * into this one as its only tab * @param {Object} [args.geometry] - { screenX, screenY, width, height } to open the window at + * @param {Object} [args.windowSettings] - The window's own settings, as returned by + * ZoteroPane.getWindowSettings() -- by default, those of the most recent window * @return {ChromeWindow} */ this.openMainWindow = function (args) { @@ -1182,8 +1184,12 @@ const { CommandLineOptions } = ChromeUtils.importESModule("chrome://zotero/conte var flags = "chrome,all,dialog=no,resizable=yes"; var ww = Components.classes['@mozilla.org/embedcomp/window-watcher;1'] .getService(Components.interfaces.nsIWindowWatcher); - // A new window takes its pane layout from the window it's opened from - Services.wm.getMostRecentWindow("navigator:browser")?.ZoteroPane?.serializePersist(); + // A new window takes its pane layout and settings from the window it's opened from + let sourcePane = Services.wm.getMostRecentWindow("navigator:browser")?.ZoteroPane; + sourcePane?.serializePersist(); + if (sourcePane?.getWindowSettings && !args?.sessionState && !args?.windowSettings) { + args = Object.assign({}, args, { windowSettings: sourcePane.getWindowSettings() }); + } // Box the args so they reliably cross openWindow()'s nsISupports argument return ww.openWindow(null, chromeURI, '_blank', flags, args ? { wrappedJSObject: args } : null); } diff --git a/chrome/content/zotero/zoteroPane.js b/chrome/content/zotero/zoteroPane.js index 5028a10108..0f133acc71 100644 --- a/chrome/content/zotero/zoteroPane.js +++ b/chrome/content/zotero/zoteroPane.js @@ -244,6 +244,10 @@ var ZoteroPane = new function () { var _initialReaderTabState = null; var _initialNoteTabState = null; var _initialMovedTab = null; + // Settings this window keeps for itself (see getWindowSetting()) + var _windowSettings = normalizeWindowSettings(); + // Observers of the prefs behind this window's settings, by pref name + var _windowSettingObservers = new Map(); var lastFocusedElement = null; this.lastKeyPress = null; // When this window was last activated, for ordering saved window states @@ -606,6 +610,7 @@ var ZoteroPane = new function () { */ function takeWindowState() { let args = getWindowArgs(); + _windowSettings = normalizeWindowSettings(args.windowSettings); // Where a tab torn off from another window was dropped if (args.geometry) { restoreWindowGeometry(args.geometry); @@ -629,6 +634,7 @@ var ZoteroPane = new function () { } if (args.sessionState) { _sessionPaneState = args.sessionState; + _windowSettings = normalizeWindowSettings(_sessionPaneState.settings); restoreWindowGeometry(_sessionPaneState.geometry); return; } @@ -638,6 +644,9 @@ var ZoteroPane = new function () { let states = Zotero.Session.getPendingPaneStates(); _sessionPaneState = states[0] || null; _otherSessionPaneStates = states.slice(1); + if (_sessionPaneState?.settings) { + _windowSettings = normalizeWindowSettings(_sessionPaneState.settings); + } // The window opens with the persisted geometry, which is shared by all windows and // holds the position of whichever closed last, so when there are other windows to // restore, use its own saved geometry instead @@ -798,6 +807,12 @@ var ZoteroPane = new function () { Zotero.Keys.windowInit(document); + window.addEventListener('zotero-window-setting-change', (event) => { + if (event.detail.name == 'search.quicksearch-mode') { + Zotero.updateQuickSearchBox(document); + } + }); + if (Zotero.restoreFromServer) { Zotero.restoreFromServer = false; @@ -1057,6 +1072,10 @@ var ZoteroPane = new function () { ZoteroContextPane.destroy(); Zotero.Plugins.notifyMainWindowUnload(window); + for (let symbol of _windowSettingObservers.values()) { + Zotero.Prefs.unregisterObserver(symbol); + } + _windowSettingObservers.clear(); // A window closed before it restored its saved state shouldn't come back in the next // session, unless it's the last one @@ -7100,10 +7119,110 @@ var ZoteroPane = new function () { height: window.outerHeight, sizemode: document.documentElement.getAttribute('sizemode') }, - tabs: Zotero_Tabs.getState() + tabs: Zotero_Tabs.getState(), + settings: this.getWindowSettings() }; }; + /** + * Get a setting that this window keeps for itself, such as an item pane or quick search + * setting + * + * A window's settings are saved with its state and copied to windows opened from it, so + * changing one in a window doesn't affect other windows. A setting the window hasn't + * changed or been opened with comes from the pref of the same name, which holds the value + * set most recently in any window, and a change to the pref from outside of a main window + * applies to all windows. + * + * @param {String} name - Pref name + * @return {*} + */ + this.getWindowSetting = function (name) { + observeWindowSettingPref(name); + if (!(name in _windowSettings.prefs)) { + _windowSettings.prefs[name] = Zotero.Prefs.get(name); + } + return structuredClone(_windowSettings.prefs[name]); + }; + + /** + * Set a setting for this window, and save it to the pref of the same name for windows that + * open without one + * + * @param {String} name - Pref name + * @param {*} value - undefined to clear + */ + this.setWindowSetting = function (name, value) { + observeWindowSettingPref(name); + _windowSettings.prefs[name] = structuredClone(value); + Zotero.Prefs.settingForWindow = true; + try { + if (value === undefined) { + Zotero.Prefs.clear(name); + } + else { + Zotero.Prefs.set(name, value); + } + } + finally { + Zotero.Prefs.settingForWindow = false; + } + dispatchWindowSettingChange(name); + }; + + /** + * Get the column settings this window keeps for an item tree, if it has any + * + * @param {String} id - The tree's prefsId + * @return {Object|null} + */ + this.getItemTreeColumnPrefs = function (id) { + let prefs = _windowSettings.itemTreeColumns[id]; + return prefs ? structuredClone(prefs) : null; + }; + + /** + * @param {String} id - The tree's prefsId + * @param {Object} prefs + */ + this.setItemTreeColumnPrefs = function (id, prefs) { + _windowSettings.itemTreeColumns[id] = structuredClone(prefs); + }; + + /** + * @return {Object} - The settings this window keeps, for its state + */ + this.getWindowSettings = function () { + return structuredClone(_windowSettings); + }; + + function normalizeWindowSettings(settings) { + return { + prefs: structuredClone(settings?.prefs || {}), + itemTreeColumns: structuredClone(settings?.itemTreeColumns || {}) + }; + } + + function observeWindowSettingPref(name) { + if (_windowSettingObservers.has(name)) { + return; + } + _windowSettingObservers.set(name, Zotero.Prefs.registerObserver(name, () => { + // A change made as a window's own setting applies only to that window + if (Zotero.Prefs.settingForWindow) { + return; + } + _windowSettings.prefs[name] = Zotero.Prefs.get(name); + dispatchWindowSettingChange(name); + })); + } + + function dispatchWindowSettingChange(name) { + window.dispatchEvent( + new CustomEvent('zotero-window-setting-change', { detail: { name } }) + ); + } + /** * Unserializes zotero-persist elements from preferences * diff --git a/test/tests/tabsTest.js b/test/tests/tabsTest.js index 275e5745e9..5f27d06f7d 100644 --- a/test/tests/tabsTest.js +++ b/test/tests/tabsTest.js @@ -741,6 +741,72 @@ describe("Zotero_Tabs", function() { } }); + it("should keep item pane and quick search settings to each window", async function () { + await selectCollection(win, collection); + await win.ZoteroPane.selectItem(item.id); + await selectCollection(win2, collection); + await zp2.selectItem(item.id); + let getSection = w => w.ZoteroPane.itemPane._itemDetails + .querySelector('collapsible-section[data-pane="info"]'); + let open = getSection(win).open; + let mode = Zotero.Prefs.getForWindow(win, 'search.quicksearch-mode'); + let otherMode = mode == 'fields' ? 'titleCreatorYear' : 'fields'; + try { + getSection(win2).open = !open; + assert.equal(getSection(win).open, open); + // The pref holds the most recent change, for windows that open without one + assert.equal(Zotero.Prefs.get('panes.info.open'), !open); + + zp2.libraryView.getElement('zotero-tb-search').searchModePopup + .querySelector(`menuitem[value="${otherMode}"]`).doCommand(); + assert.equal(Zotero.Prefs.getForWindow(win2, 'search.quicksearch-mode'), otherMode); + assert.equal(Zotero.Prefs.getForWindow(win, 'search.quicksearch-mode'), mode); + } + finally { + getSection(win2).open = open; + Zotero.Prefs.setForWindow(win2, 'search.quicksearch-mode', mode); + } + }); + + it("should reopen a window with its own settings", async function () { + let mode = Zotero.Prefs.getForWindow(win, 'search.quicksearch-mode'); + let otherMode = mode == 'fields' ? 'titleCreatorYear' : 'fields'; + let isDateAddedHidden = view => view.itemsView._getColumns() + .find(column => column.dataKey == 'dateAdded').hidden; + let dateAddedHidden = isDateAddedHidden(zp.libraryView); + + let win3 = await loadZoteroPane(); + Zotero.Prefs.setForWindow(win3, 'search.quicksearch-mode', otherMode); + let itemsView = win3.ZoteroPane.itemsView; + itemsView.tree._columns.toggleHidden( + itemsView._getColumns().findIndex(column => column.dataKey == 'dateAdded') + ); + let openWindows = Zotero.getAllMainWindows(); + win3.close(); + await waitForCallback(() => win3.closed, 50, 20); + + await tabs.undoClose(); + let newWin; + try { + await waitForCallback( + () => (newWin = Zotero.getAllMainWindows().find(x => !openWindows.includes(x))) + && newWin.ZoteroPane.libraryView?.loaded, + 100, + 20 + ); + assert.equal(Zotero.Prefs.getForWindow(newWin, 'search.quicksearch-mode'), otherMode); + assert.equal(isDateAddedHidden(newWin.ZoteroPane.libraryView), !dateAddedHidden); + + assert.equal(Zotero.Prefs.getForWindow(win, 'search.quicksearch-mode'), mode); + await zp.itemsView.reloadColumnPrefs(); + assert.equal(isDateAddedHidden(zp.libraryView), dateAddedHidden); + } + finally { + newWin?.close(); + Zotero.Prefs.set('search.quicksearch-mode', mode); + } + }); + it("should open each selected collection in a tab of its own in a new window", async function () { let otherCollection = await createDataObject('collection'); await zp2.collectionsView.restoreSelection([collection.treeViewID, otherCollection.treeViewID]);