Add Abstract section to merge pane with value chooser

This commit is contained in:
Abe Jellinek 2026-05-14 11:50:09 -04:00
parent 714d4416f8
commit 56296325f9
11 changed files with 481 additions and 231 deletions

View file

@ -29,6 +29,7 @@ Services.scriptloader.loadSubScript("chrome://zotero/content/include.js", this);
Services.scriptloader.loadSubScript("chrome://global/content/customElements.js", this);
Services.scriptloader.loadSubScript("chrome://zotero/content/elements/base.js", this);
Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemPaneSection.js', this);
Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemPaneContainer.js', this);
Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMenuBar.js', this);
{

View file

@ -39,6 +39,10 @@
</collapsible-section>
`);
_mode = 'view';
_fieldAlternatives = {};
get item() {
return this._item;
}
@ -66,6 +70,36 @@
super.editable = editable;
}
get mode() {
return this._mode;
}
set mode(val) {
switch (val) {
case 'view':
case 'edit':
case 'merge':
break;
case 'fieldmerge':
this._fieldAlternatives = {};
break;
default:
throw new Error(`Invalid mode '${val}'`);
}
this._mode = val;
this.setAttribute('mode', val);
}
set fieldAlternatives(val) {
if (val.constructor.name != 'Object') {
throw Error('fieldAlternatives must be an Object in <abstract-box>.fieldAlternatives');
}
if (this._mode != 'fieldmerge') {
throw Error('fieldAlternatives is valid only in fieldmerge mode in <abstract-box>.fieldAlternatives');
}
this._fieldAlternatives = val;
}
init() {
this._notifierID = Zotero.Notifier.registerObserver(this, ['item', 'tab'], 'abstractBox');
@ -157,6 +191,59 @@
}
this._abstractField.readOnly = !this.editable;
this._abstractField.setAttribute('aria-label', Zotero.ItemFields.getLocalizedString('abstractNote'));
this._renderFieldVersionButton();
}
_renderFieldVersionButton() {
let existing = this.querySelector('.zotero-field-version-button');
if (existing) {
existing.remove();
}
if (this._mode !== 'fieldmerge') {
return;
}
let alternatives = this._fieldAlternatives?.abstractNote;
if (!alternatives || !alternatives.length) {
return;
}
let button = document.createXULElement('toolbarbutton');
button.className = 'zotero-field-version-button zotero-clicky-merge';
document.l10n.setAttributes(button, 'itembox-button-merge', {
field: Zotero.ItemFields.getLocalizedString('abstractNote') || ''
});
let popup = button.appendChild(document.createXULElement('menupopup'));
for (let v of alternatives) {
let menuitem = document.createXULElement('menuitem');
let sv = Zotero.Utilities.ellipsize(v, 60);
menuitem.setAttribute('label', sv);
if (v != sv) {
menuitem.setAttribute('tooltiptext', v);
}
menuitem.setAttribute('originalValue', v);
menuitem.addEventListener('command', () => {
this.item.setField('abstractNote', menuitem.getAttribute('originalValue'));
this._forceRenderAll();
});
popup.appendChild(menuitem);
}
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (event.screenX) {
popup.openPopupAtScreen(event.screenX - 5, event.screenY + 5, true);
}
else {
popup.openPopup(button, 'after_start');
}
});
this.querySelector('.body').appendChild(button);
}
_ensureFeedAbstractBrowserExists = Zotero.Utilities.Internal.serial(async () => {

View file

@ -199,7 +199,7 @@
}
_buildContextMenu() {
let containerRoot = this.closest('.zotero-view-item-container, context-notes-list');
let containerRoot = this.closest('.item-pane-container-root, .zotero-view-item-container, context-notes-list');
let contextMenu = document.createXULElement('menupopup');
@ -298,7 +298,7 @@
let canMoveUp = sidenav?.isPaneMovable(this.dataset.pane, 'up');
let canMoveDown = sidenav?.isPaneMovable(this.dataset.pane, 'down');
let canReset = sidenav?.isOrderChanged();
let canReset = !!sidenav?.container?.supportsReorder && sidenav?.isOrderChanged();
moveSectionUp.hidden = !canMoveUp;
moveSectionDown.hidden = !canMoveDown;

View file

@ -29,26 +29,29 @@
mergeItems: "chrome://zotero/content/mergeItems.mjs",
});
class DuplicatesMergePane extends XULElementBase {
class DuplicatesMergePane extends ItemPaneContainerBase {
content = MozXULElement.parseXULToFragment(`
<groupbox>
<button id="zotero-duplicates-merge-button" data-l10n-id="item-pane-duplicates-merge-items" />
</groupbox>
<groupbox id="zotero-duplicates-merge-version-select">
<description>&zotero.duplicatesMerge.versionSelect;</description>
<hbox>
<richlistbox id="zotero-duplicates-merge-original-date" rows="0"/>
</hbox>
</groupbox>
<groupbox id="zotero-duplicates-merge-field-select">
<description>&zotero.duplicatesMerge.fieldSelect;</description>
</groupbox>
<vbox id="zotero-duplicates-merge-controls">
<groupbox>
<button id="zotero-duplicates-merge-button" data-l10n-id="item-pane-duplicates-merge-items" />
</groupbox>
<vbox id="zotero-duplicates-merge-info-box-container">
<info-box id="zotero-duplicates-merge-info-box"/>
<groupbox id="zotero-duplicates-merge-version-select">
<description>&zotero.duplicatesMerge.versionSelect;</description>
<hbox>
<richlistbox id="zotero-duplicates-merge-original-date" rows="0"/>
</hbox>
</groupbox>
<groupbox id="zotero-duplicates-merge-field-select">
<description>&zotero.duplicatesMerge.fieldSelect;</description>
</groupbox>
</vbox>
<html:div id="zotero-duplicates-merge-view-item" class="zotero-view-item" tabindex="0">
<info-box id="zotero-duplicates-merge-info-box" data-pane="info"/>
<abstract-box id="zotero-duplicates-merge-abstract-box" data-pane="abstract"/>
</html:div>
`, ['chrome://zotero/locale/zotero.dtd']);
init() {
@ -57,6 +60,10 @@
this._otherItems = [];
this._ignoreFields = ['dateAdded', 'dateModified', 'accessDate'];
this._paneParent = this.querySelector('#zotero-duplicates-merge-view-item');
this._infoBox = this.querySelector('#zotero-duplicates-merge-info-box');
this._abstractBox = this.querySelector('#zotero-duplicates-merge-abstract-box');
this.querySelector("#zotero-duplicates-merge-button").addEventListener(
"command", () => this.merge());
this.querySelector("#zotero-duplicates-merge-original-date").addEventListener(
@ -113,15 +120,14 @@
// Update the UI
//
let button = document.getElementById('zotero-duplicates-merge-button');
let versionSelect = document.getElementById('zotero-duplicates-merge-version-select');
let itembox = document.getElementById('zotero-duplicates-merge-info-box');
let fieldSelect = document.getElementById('zotero-duplicates-merge-field-select');
let button = this.querySelector('#zotero-duplicates-merge-button');
let versionSelect = this.querySelector('#zotero-duplicates-merge-version-select');
let fieldSelect = this.querySelector('#zotero-duplicates-merge-field-select');
let alternatives = oldestItem.multiDiff(otherItems, this._ignoreFields);
if (alternatives) {
// Populate richlistbox with Date Added values from all items
let dateList = document.getElementById('zotero-duplicates-merge-original-date');
let dateList = this.querySelector('#zotero-duplicates-merge-original-date');
dateList.innerHTML = '';
let numRows = 0;
@ -143,9 +149,9 @@
document.l10n.setArgs(button, { count: otherItems.length + 1 });
versionSelect.hidden = fieldSelect.hidden = !alternatives;
itembox.hiddenFields = alternatives ? [] : ['dateAdded', 'dateModified'];
// Since the header of the collapsible section is hidden, the section has to be opened
itembox.open = true;
this._infoBox.hiddenFields = alternatives ? [] : ['dateAdded', 'dateModified'];
this._infoBox.open = true;
this._abstractBox.open = true;
this.setMaster(0);
@ -153,8 +159,8 @@
}
setMaster(pos) {
let itembox = document.getElementById('zotero-duplicates-merge-info-box');
itembox.mode = 'fieldmerge';
this._infoBox.mode = 'fieldmerge';
this._abstractBox.mode = 'fieldmerge';
this._otherItems = this._items.concat();
let item = this._otherItems.splice(pos, 1)[0];
@ -168,21 +174,32 @@
for (let i in alternatives) {
alternatives[i].unshift(itemValues[i] !== undefined ? itemValues[i] : '');
}
itembox.fieldAlternatives = alternatives;
this._infoBox.fieldAlternatives = alternatives;
this._abstractBox.fieldAlternatives = {
abstractNote: alternatives.abstractNote
};
}
else {
this._infoBox.fieldAlternatives = {};
this._abstractBox.fieldAlternatives = {};
}
this._masterItem = item;
itembox.item = item.clone();
// The item.id is null which equals to _lastRenderItemID, so we need to force render it
itembox._forceRenderAll();
// Share the same clone between info-box and abstract-box so that
// alternative selections from either side accumulate on a single item.
let clone = item.clone();
this._infoBox.item = clone;
this._abstractBox.item = clone;
// The clone's item.id is null which equals to _lastRenderItemID, so we need to force render
this._infoBox._forceRenderAll();
this._abstractBox._forceRenderAll();
}
async merge() {
let itembox = document.getElementById('zotero-duplicates-merge-info-box');
// Update master item with any field alternatives from the item box
// Update master item with any field alternatives chosen in the boxes
let json = this._masterItem.toJSON();
// Exclude certain properties that are empty in the cloned object, so we don't clobber them
const { relations: _r, collections: _c, tags: _t, ...keep } = itembox.item.toJSON();
const { relations: _r, collections: _c, tags: _t, ...keep } = this._infoBox.item.toJSON();
Object.assign(json, keep);
this._masterItem.fromJSON(json);

View file

@ -24,32 +24,7 @@
*/
{
const waitFrame = async () => {
return waitNoLongerThan(new Promise((resolve) => {
requestAnimationFrame(resolve);
}), 30);
};
const waitFrames = async (n) => {
for (let i = 0; i < n; i++) {
await waitFrame();
}
};
const waitDOMUpdate = async (timeout = 50) => {
return new Promise((resolve) => {
requestIdleCallback(resolve, { timeout });
});
};
const waitNoLongerThan = async (promise, ms = 1000) => {
return Promise.race([
promise,
Zotero.Promise.delay(ms)
]);
};
class ItemDetails extends XULElementBase {
class ItemDetails extends ItemPaneContainerBase {
content = MozXULElement.parseXULToFragment(`
<hbox id="zotero-view-item-container" class="zotero-view-item-container" flex="1">
<html:div class="zotero-view-item-main">
@ -116,14 +91,6 @@
this._tabID = tabID;
}
get tabType() {
return this.getAttribute('tabType');
}
set tabType(tabType) {
this.setAttribute('tabType', tabType);
}
get collectionTreeRow() {
return this._collectionTreeRow;
}
@ -132,6 +99,14 @@
this._collectionTreeRow = collectionTreeRow;
}
get supportsPinning() {
return true;
}
get supportsReorder() {
return true;
}
get pinnedPane() {
return this.getAttribute('pinnedPane');
}
@ -151,47 +126,12 @@
this._savePinnedPane();
}
get _minScrollHeight() {
return parseFloat(this._paneParent.style.getPropertyValue('--min-scroll-height') || 0);
}
set _minScrollHeight(val) {
this._paneParent.style.setProperty('--min-scroll-height', val + 'px');
}
/**
* Convenience getter that delegates to our <item-pane> / <context-pane>
* parent. This exists because the sidenav controls us, not the <item-pane>,
* but needs to get/set its collapsed state.
* @returns {boolean}
*/
get _collapsed() {
let parentPane = this.closest('item-pane, context-pane');
if (!parentPane) {
return false;
}
return parentPane.collapsed;
}
/**
* Convenience setter for collapsed state. See above.
* @param {boolean} val
*/
set _collapsed(val) {
let parentPane = this.closest('item-pane, context-pane');
if (!parentPane) {
return;
}
parentPane.collapsed = val;
}
get sidenav() {
return this._sidenav;
return super.sidenav;
}
set sidenav(sidenav) {
this._sidenav = sidenav;
sidenav.container = this;
super.sidenav = sidenav;
// Manually update once and further changes will be synced automatically to sidenav
this.forceUpdateSideNav();
}
@ -337,9 +277,9 @@
if (!this.isPaneVisible(box.dataset.pane)) {
continue;
}
await waitNoLongerThan(box.asyncRender(), 500);
await this._waitNoLongerThan(box.asyncRender(), 500);
// Make sure the layout is updated for next isPaneVisible check
await waitDOMUpdate();
await this._waitDOMUpdate();
}
}
@ -431,22 +371,6 @@
}
};
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])`);
}
getPanes() {
return Array.from(this._paneParent.querySelectorAll(':scope > [data-pane]'));
}
getEnabledPanes() {
return Array.from(this._paneParent.querySelectorAll(':scope > [data-pane]:not([hidden])'));
}
getVisiblePanes() {
let panes = this.getPanes();
let visiblePanes = [];
@ -543,111 +467,29 @@
}
}
async scrollToPane(paneID, behavior = 'smooth') {
let panes = this.getEnabledPanes();
let paneIndex = panes.findIndex(elem => elem.dataset.pane == paneID);
let pane = panes[paneIndex];
if (!pane) return null;
let scrollPromise;
// If the itemPane is collapsed, just remember which pane needs to be scrolled to
// when itemPane is expanded.
if (this._collapsed) {
this._lastScrollPaneID = paneID;
return null;
}
// If the pane is already at the top, no need to scroll
if (Math.abs(pane.getBoundingClientRect().top - this._paneParent.getBoundingClientRect().top) < 1) {
return true;
}
// Temporarily disable intersection observer to prevent unwanted rendering
_beforeScrollToPane(_pane) {
// Suspend the intersection observer for the duration of the scroll so
// the pane we're scrolling toward (and any panes that swing through
// the viewport on the way) don't trigger lazy renders mid-flight.
this._toggleIntersectionObserver(false);
}
// The pane should always be at the very top
// If there isn't enough stuff below it for it to be at the top, we add padding
// We use a ::before pseudo-element for this so that we don't need to add another level to the DOM
this._makeSpaceForPane(pane);
if (behavior == 'smooth') {
this._disableScrollHandler = true;
scrollPromise = this._waitForScroll();
scrollPromise.then(() => this._disableScrollHandler = false);
}
else {
// Wait for the next DOM update to make sure the height is updated before rendering
scrollPromise = waitDOMUpdate();
}
pane.scrollIntoView({ block: 'start', behavior });
pane.focus();
await scrollPromise;
// Check current and following panes for async render
async _afterScrollToPane(_pane, panes, paneIndex) {
// After the scroll settles, async-render the pane we landed on and
// any visible panes below it, then re-enable the intersection observer
for (let i = paneIndex; i < panes.length; i++) {
let nextPane = panes[i];
// Stop if the pane is not visible anymore
// Stop at the first pane no longer in view
if (!this.isPaneVisible(nextPane.dataset.pane)) {
break;
}
if (nextPane.asyncRender) {
await nextPane.asyncRender();
// Wait for the next DOM update to make sure the layout is updated
await waitDOMUpdate();
await this._waitDOMUpdate();
}
}
// Re-enable intersection observer
this._toggleIntersectionObserver(true);
return true;
}
_makeSpaceForPane(pane) {
let oldMinScrollHeight = this._minScrollHeight;
let newMinScrollHeight = this._getMinScrollHeightForPane(pane);
if (newMinScrollHeight > oldMinScrollHeight) {
this._minScrollHeight = newMinScrollHeight;
}
}
_getMinScrollHeightForPane(pane) {
let paneRect = pane.getBoundingClientRect();
let containerRect = this._paneParent.getBoundingClientRect();
// No offsetTop property for XUL elements
let offsetTop = paneRect.top - containerRect.top + this._paneParent.scrollTop;
return offsetTop + containerRect.height;
}
async _waitForScroll() {
let scrollPromise = Zotero.Promise.defer();
let lastScrollTop = this._paneParent.scrollTop;
const checkScrollStart = () => {
// If the scrollTop is not changed, wait for scroll to happen
if (lastScrollTop === this._paneParent.scrollTop) {
requestAnimationFrame(checkScrollStart);
}
// Wait for scroll to end
else {
requestAnimationFrame(checkScrollEnd);
}
};
const checkScrollEnd = async () => {
// Wait for 3 frames to make sure not further scrolls
await waitFrames(3);
if (lastScrollTop === this._paneParent.scrollTop) {
scrollPromise.resolve();
}
else {
lastScrollTop = this._paneParent.scrollTop;
requestAnimationFrame(checkScrollEnd);
}
};
checkScrollStart();
// Abort after 3 seconds, which should be enough
return Promise.race([
scrollPromise.promise,
Zotero.Promise.delay(3000)
]);
}
async blurOpenField() {

View file

@ -636,6 +636,7 @@
if (this.previousElementSibling.localName === "splitter") {
this.previousElementSibling.setAttribute("state", "open");
}
this._sidenav.container = this._duplicatesPane;
break;
}
case "annotations": {
@ -644,10 +645,15 @@
}
}
let isViewingItem = type == "item";
let isViewingDuplicates = type == "duplicates";
if (previousViewType != "item" && isViewingItem) {
this._itemDetails.forceUpdateSideNav();
}
this._itemDetails.sidenav.toggleDefaultStatus(!isViewingItem);
// Switch sidenav back to item-details when leaving duplicates mode
if (!isViewingDuplicates && this._sidenav.container !== this._itemDetails) {
this._sidenav.container = this._itemDetails;
}
this._sidenav.toggleDefaultStatus(!isViewingItem && !isViewingDuplicates);
}
}
customElements.define("item-pane", ItemPane);

View file

@ -0,0 +1,259 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2026 Corporation for Digital Scholarship
Vienna, Virginia, USA
https://www.zotero.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 <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
/**
* Base class for elements that host a vertical stack of [data-pane] sections
* controlled by an <item-pane-sidenav>.
*
* Subclasses must set this._paneParent (the scrollable element directly
* containing the [data-pane] children) in their init() before any of the
* inherited container methods are called.
*
* Pin support and pane reordering are opt-in -- subclasses override
* pinnedPane, changePaneOrder(), and initPaneOrder if they want them.
* scrollToPane() handles smooth scrolling and adds bottom padding when
* necessary so the target pane can sit at the very top of the container;
* subclasses with extra per-scroll bookkeeping (intersection observers, async
* render of following panes, etc.) can override the _beforeScrollToPane() and
* _afterScrollToPane() hooks.
*/
// eslint-disable-next-line no-unused-vars
class ItemPaneContainerBase extends XULElementBase {
_paneParent = null;
connectedCallback() {
super.connectedCallback();
this.classList.add('item-pane-container-root');
}
get sidenav() {
return this._sidenav;
}
set sidenav(sidenav) {
this._sidenav = sidenav;
sidenav.container = this;
}
get tabType() {
return this.getAttribute('tabType');
}
set tabType(val) {
this.setAttribute('tabType', val);
}
get _collapsed() {
let parentPane = this.closest('item-pane, context-pane');
return parentPane ? parentPane.collapsed : false;
}
set _collapsed(val) {
let parentPane = this.closest('item-pane, context-pane');
if (parentPane) parentPane.collapsed = val;
}
/**
* Whether this container supports user-driven pinning of a pane.
* The sidenav and section context menus honor this when deciding whether
* to expose the pin/unpin UI. Subclasses that implement `pinnedPane`
* should override this to return true.
*/
get supportsPinning() {
return false;
}
/**
* Whether this container supports user-driven pane reordering (drag in
* the sidenav, reorder menuitems in the sidenav and section context
* menus). Subclasses that implement changePaneOrder() and initPaneOrder()
* should return true.
*/
get supportsReorder() {
return false;
}
/**
* Pin support is opt-in. Default: not supported.
*/
get pinnedPane() {
return null;
}
set pinnedPane(_val) {}
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])`);
}
getPanes() {
return Array.from(this._paneParent.querySelectorAll(':scope > [data-pane]'));
}
getEnabledPanes() {
return Array.from(this._paneParent.querySelectorAll(':scope > [data-pane]:not([hidden])'));
}
async _waitFrame() {
return this._waitNoLongerThan(new Promise((resolve) => {
requestAnimationFrame(resolve);
}), 30);
}
async _waitFrames(n) {
for (let i = 0; i < n; i++) {
await this._waitFrame();
}
}
async _waitDOMUpdate(timeout = 50) {
return new Promise((resolve) => {
requestIdleCallback(resolve, { timeout });
});
}
async _waitNoLongerThan(promise, ms = 1000) {
return Promise.race([promise, Zotero.Promise.delay(ms)]);
}
get _minScrollHeight() {
return parseFloat(this._paneParent.style.getPropertyValue('--min-scroll-height') || 0);
}
set _minScrollHeight(val) {
this._paneParent.style.setProperty('--min-scroll-height', val + 'px');
}
_makeSpaceForPane(pane) {
let oldMinScrollHeight = this._minScrollHeight;
let newMinScrollHeight = this._getMinScrollHeightForPane(pane);
if (newMinScrollHeight > oldMinScrollHeight) {
this._minScrollHeight = newMinScrollHeight;
}
}
_getMinScrollHeightForPane(pane) {
let paneRect = pane.getBoundingClientRect();
let containerRect = this._paneParent.getBoundingClientRect();
// No offsetTop property for XUL elements
let offsetTop = paneRect.top - containerRect.top + this._paneParent.scrollTop;
return offsetTop + containerRect.height;
}
async _waitForScroll() {
let scrollPromise = Zotero.Promise.defer();
let lastScrollTop = this._paneParent.scrollTop;
const checkScrollStart = () => {
// If the scrollTop is not changed, wait for scroll to happen
if (lastScrollTop === this._paneParent.scrollTop) {
requestAnimationFrame(checkScrollStart);
}
// Wait for scroll to end
else {
requestAnimationFrame(checkScrollEnd);
}
};
const checkScrollEnd = async () => {
// Wait for 3 frames to make sure not further scrolls
await this._waitFrames(3);
if (lastScrollTop === this._paneParent.scrollTop) {
scrollPromise.resolve();
}
else {
lastScrollTop = this._paneParent.scrollTop;
requestAnimationFrame(checkScrollEnd);
}
};
checkScrollStart();
// Abort after 3 seconds, which should be enough
return Promise.race([
scrollPromise.promise,
Zotero.Promise.delay(3000)
]);
}
/**
* Called immediately before the scroll begins, after we've decided we will scroll.
*/
_beforeScrollToPane(_pane) {}
/**
* Called after the scroll has settled.
*/
async _afterScrollToPane(_pane, _panes, _paneIndex) {}
async scrollToPane(paneID, behavior = 'smooth') {
let panes = this.getEnabledPanes();
let paneIndex = panes.findIndex(elem => elem.dataset.pane == paneID);
let pane = panes[paneIndex];
if (!pane) return null;
// If the container is collapsed, just remember which pane needs to be
// scrolled to when the container is expanded.
if (this._collapsed) {
this._lastScrollPaneID = paneID;
return null;
}
// If the pane is already at the top, no need to scroll
if (Math.abs(pane.getBoundingClientRect().top - this._paneParent.getBoundingClientRect().top) < 1) {
return true;
}
this._beforeScrollToPane(pane);
// The pane should always be at the very top.
// If there isn't enough stuff below it for it to be at the top, we add
// padding via the --min-scroll-height CSS variable so we don't need
// to add another level to the DOM.
this._makeSpaceForPane(pane);
let scrollPromise;
if (behavior == 'smooth') {
this._disableScrollHandler = true;
scrollPromise = this._waitForScroll();
scrollPromise.then(() => this._disableScrollHandler = false);
}
else {
// Wait for the next DOM update to make sure the height is updated before rendering
scrollPromise = this._waitDOMUpdate();
}
pane.scrollIntoView({ block: 'start', behavior });
pane.focus();
await scrollPromise;
await this._afterScrollToPane(pane, panes, paneIndex);
return true;
}
changePaneOrder() {}
initPaneOrder() {}
}

View file

@ -112,6 +112,15 @@
set container(val) {
if (this._container == val) return;
this._container = val;
// When changing containers, hide all data-pane buttons up front; they'll
// be unhidden during render() for panes present in the new container.
// Otherwise, buttons for panes from the previous container would linger.
if (val) {
for (let button of this.querySelectorAll('.btn[data-pane]')) {
if (button.dataset.pane === 'context-notes') continue;
button.parentElement.hidden = true;
}
}
this.render();
}
@ -188,6 +197,10 @@
}
isPanePinnable(id) {
// The active container can opt out of pin support entirely
if (!this.container?.supportsPinning) {
return false;
}
if (['context-notes', 'context-all-notes', 'context-item-notes'].includes(id)) {
return false;
}
@ -199,6 +212,10 @@
}
isPaneOrderable(paneID) {
// The active container can opt out of reordering entirely
if (!this.container?.supportsReorder) {
return false;
}
let orderable
// Built-in or orderable custom sections
= this._builtInPanes.includes(paneID) || Zotero.ItemPaneManager.isSectionOrderable(paneID);
@ -823,13 +840,24 @@
this._contextMenuTarget = paneID;
let isPinnable = this.isPanePinnable(paneID);
this.querySelector('.zotero-menuitem-pin').hidden = !isPinnable || this.pinnedPane == paneID;
this.querySelector('.zotero-menuitem-unpin').hidden = !isPinnable || this.pinnedPane != paneID;
let pinHidden = !isPinnable || this.pinnedPane == paneID;
let unpinHidden = !isPinnable || this.pinnedPane != paneID;
this.querySelector('.zotero-menuitem-pin').hidden = pinHidden;
this.querySelector('.zotero-menuitem-unpin').hidden = unpinHidden;
this.querySelector('.zotero-menuitem-pin-separator').hidden = !isPinnable;
this.querySelector('.zotero-menuitem-reorder-up').hidden = !this.isPaneMovable(paneID, 'up');
this.querySelector('.zotero-menuitem-reorder-down').hidden = !this.isPaneMovable(paneID, 'down');
this.querySelector('.zotero-menuitem-reorder-reset').hidden = !this.isOrderChanged();
let canReorder = !!this.container?.supportsReorder;
let moveUpHidden = !this.isPaneMovable(paneID, 'up');
let moveDownHidden = !this.isPaneMovable(paneID, 'down');
let resetHidden = !canReorder || !this.isOrderChanged();
this.querySelector('.zotero-menuitem-reorder-up').hidden = moveUpHidden;
this.querySelector('.zotero-menuitem-reorder-down').hidden = moveDownHidden;
this.querySelector('.zotero-menuitem-reorder-reset').hidden = resetHidden;
// Don't open an empty popup
if (pinHidden && unpinHidden && moveUpHidden && moveDownHidden && resetHidden) {
return;
}
this.querySelector('.context-menu')
.openPopupAtScreen(event.screenX, event.screenY, true);

View file

@ -48,6 +48,7 @@ export default [
XULElementMixin: "readonly",
XULTextElement: "readonly",
ItemPaneSectionElementBase: "readonly",
ItemPaneContainerBase: "readonly",
Cu: "readonly",
ChromeWorker: "readonly",
Localization: "readonly",

View file

@ -9,6 +9,7 @@ abstract-box {
abstract-box .body {
display: flex;
align-items: flex-start;
editable-text {
line-height: 1.5;
@ -17,4 +18,9 @@ abstract-box .body {
editable-text, browser {
flex: 1;
}
.zotero-field-version-button {
padding: 3px;
flex-shrink: 0;
}
}

View file

@ -1,5 +1,5 @@
duplicates-merge-pane {
&, #zotero-duplicates-merge-version-select, #zotero-duplicates-merge-info-box-container {
&, #zotero-duplicates-merge-version-select, #zotero-duplicates-merge-controls {
&:not([hidden]) {
display: flex;
flex-direction: column;
@ -10,7 +10,11 @@ duplicates-merge-pane {
padding-top: 9px;
> groupbox {
#zotero-duplicates-merge-controls {
flex: 0 0 auto;
}
> #zotero-duplicates-merge-controls > groupbox {
// Override default margin/padding that breaks our styles here
margin: 0;
padding: 0;
@ -22,18 +26,17 @@ duplicates-merge-pane {
}
}
#zotero-duplicates-merge-info-box-container {
#zotero-duplicates-merge-view-item {
flex: 1;
min-height: 0;
padding-inline: 8px;
overflow-y: scroll;
display: flex;
flex-direction: column;
#zotero-duplicates-merge-info-box {
> [data-pane]:first-child {
border-top: var(--material-border-quinary);
}
collapsible-section > .head {
display: none;
}
}
/* Show duplicates date list item as selected even when not focused