mirror of
https://github.com/zotero/zotero.git
synced 2026-10-05 02:43:38 +00:00
Add Abstract section to merge pane with value chooser
This commit is contained in:
parent
714d4416f8
commit
56296325f9
11 changed files with 481 additions and 231 deletions
|
|
@ -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);
|
||||
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
259
chrome/content/zotero/elements/itemPaneContainer.js
Normal file
259
chrome/content/zotero/elements/itemPaneContainer.js
Normal 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() {}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export default [
|
|||
XULElementMixin: "readonly",
|
||||
XULTextElement: "readonly",
|
||||
ItemPaneSectionElementBase: "readonly",
|
||||
ItemPaneContainerBase: "readonly",
|
||||
Cu: "readonly",
|
||||
ChromeWorker: "readonly",
|
||||
Localization: "readonly",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue