QuickFormat: adding multiple items with shift-arrow (#3851)

- multiple reference items can be selected with
shift-arrowUp/Down or Cmd/Ctrl-click and added as bubbles
via Enter or click on one of selected items
- Shift-click can be used to select the range of items
- if there is a retracted item among multiselected
that is not OK'd (cancel or view in library clicked),
no bubbles are added and the item is removed from
the multiselection
- added a red note to item's description indicating
if an item is retracted
This commit is contained in:
abaevbog 2024-06-04 23:38:56 -05:00 committed by GitHub
parent 4d7c641f7b
commit 56980080ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 157 additions and 64 deletions

View file

@ -106,7 +106,7 @@ var Zotero_QuickFormat = new function () {
if ((event.key == "Enter" && !event.shiftKey) || event.charCode == 59) { if ((event.key == "Enter" && !event.shiftKey) || event.charCode == 59) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
event.target.closest("richlistitem").click(); Zotero_QuickFormat._bubbleizeSelected();
} }
// Tab will move focus back to the input field // Tab will move focus back to the input field
else if (event.key === "Tab") { else if (event.key === "Tab") {
@ -127,8 +127,10 @@ var Zotero_QuickFormat = new function () {
_lastFocusedInput.focus(); _lastFocusedInput.focus();
} }
else if (["ArrowDown", "ArrowUp"].includes(event.key)) { else if (["ArrowDown", "ArrowUp"].includes(event.key)) {
event.preventDefault();
event.stopPropagation();
// ArrowUp from first item focuses the input // ArrowUp from first item focuses the input
if (referenceBox.selectedIndex == 1 && event.key == "ArrowUp") { if (event.key == "ArrowUp" && referenceBox.selectedIndex == 1 && referenceBox.selectedItem == document.activeElement) {
_lastFocusedInput.focus(); _lastFocusedInput.focus();
referenceBox.selectedIndex = -1; referenceBox.selectedIndex = -1;
} }
@ -146,6 +148,34 @@ var Zotero_QuickFormat = new function () {
moveFocusForward(_lastFocusedInput); moveFocusForward(_lastFocusedInput);
} }
}); });
referenceBox.addEventListener("click", (e) => {
let item = e.target.closest("richlistitem");
if (!item || e.button !== 0 || item.disabled) return;
let mouseMultiSelect = e.shiftKey || (Zotero.isMac && e.metaKey) || (!Zotero.isMac && e.ctrlKey);
let multipleSelected = referenceBox.selectedCount > 1;
let isItemSelected = [...referenceBox.selectedItems].findIndex(node => node == item) !== -1;
// Click without multiselect modifier will confirm the selection
if (!mouseMultiSelect) {
// If item is multi-selected, do not discard the selection
if (multipleSelected && isItemSelected) {
e.preventDefault();
}
Zotero_QuickFormat._bubbleizeSelected();
return;
}
// Make sure there is a selected item when shift-click is handled
if (e.shiftKey && referenceBox.selectedCount < 1) {
_selectFirstReference();
}
// Shift-click can end up selecting disabled separator, so make sure it's removed
setTimeout(() => {
let selectedSeparators = [...document.querySelectorAll("richlistitem[disabled='true'][selected='true']")];
for (let node of selectedSeparators) {
referenceBox.removeItemFromSelection(node);
}
});
}, true);
if (Zotero.isWin) { if (Zotero.isWin) {
if (Zotero.Prefs.get('integration.keepAddCitationDialogRaised')) { if (Zotero.Prefs.get('integration.keepAddCitationDialogRaised')) {
dialog.setAttribute("square", "true"); dialog.setAttribute("square", "true");
@ -890,6 +920,15 @@ var Zotero_QuickFormat = new function () {
var nodes = []; var nodes = [];
var str = ""; var str = "";
// Add a red label to retracted items
if (Zotero.Retractions.isRetracted(item)) {
var label = document.createXULElement("label");
label.setAttribute("value", Zotero.getString("retraction.banner"));
label.setAttribute("crop", "end");
label.style.color = 'red';
label.style['margin-inline-end'] = '5px';
infoHbox.appendChild(label);
}
if (item.isNote()) { if (item.isNote()) {
var date = Zotero.Date.sqlToDate(item.dateModified, true); var date = Zotero.Date.sqlToDate(item.dateModified, true);
date = Zotero.Date.toFriendlyDate(date); date = Zotero.Date.toFriendlyDate(date);
@ -989,7 +1028,6 @@ var Zotero_QuickFormat = new function () {
rll.setAttribute("aria-describedby", "item-description"); rll.setAttribute("aria-describedby", "item-description");
rll.appendChild(titleNode); rll.appendChild(titleNode);
rll.appendChild(infoNode); rll.appendChild(infoNode);
rll.addEventListener("click", Zotero_QuickFormat._bubbleizeSelected, false);
return rll; return rll;
} }
@ -1104,6 +1142,14 @@ var Zotero_QuickFormat = new function () {
function getAllBubbles() { function getAllBubbles() {
return [...editor.querySelectorAll(".bubble")]; return [...editor.querySelectorAll(".bubble")];
} }
// Delete the bubble and clear locator node if it pointed at this bubble
function _deleteBubble(bubble) {
if (bubble == locatorNode) {
locatorNode = null;
}
bubble.remove();
}
/** /**
* Clear reference box * Clear reference box
@ -1113,64 +1159,85 @@ var Zotero_QuickFormat = new function () {
if (!skipResize) { if (!skipResize) {
_resizeReferencePanel(); _resizeReferencePanel();
} }
referenceBox.selectedIndex = -1;
}
// Select the first appropriate reference from the items list.
// If there are multi-selected items, select the first one of them.
// Otherwise, select the first non-header row.
function _selectFirstReference() {
if (referenceBox.selectedIndex > 0) return;
let firstItem = [...referenceBox.selectedItems].find(node => referenceBox.contains(node));
if (firstItem) {
referenceBox.selectedItem = firstItem;
}
else {
referenceBox.selectedIndex = 1;
}
} }
/** /**
* Converts the selected item to a bubble * Converts the selected item to a bubble
*/ */
this._bubbleizeSelected = Zotero.Promise.coroutine(function* () { this._bubbleizeSelected = Zotero.Promise.coroutine(function* () {
const panelShowing = referencePanel.state === "open" || referencePanel.state === "showing"; let input = _lastFocusedInput || _getCurrentInput();
let inputExists = _lastFocusedInput || _getCurrentInput() if (referenceBox.selectedCount == 0 || referencePanel.state !== 'open' || !input) return false;
if(!panelShowing || !referenceBox.hasChildNodes() || !referenceBox.selectedItem || _searchPromise?.isPending()) return false; let lastAddedBubble;
let multipleSelected = referenceBox.selectedCount > 1;
if(!referenceBox.hasChildNodes() || !referenceBox.selectedItem || !inputExists) return false; // It is technically possible for referenceBox.selectedItems to include nodes that were removed
var citationItem = {"id":referenceBox.selectedItem.getAttribute("zotero-item")}; // (e.g. during panel refreshing). This should never happen but this is a sanity check to make sure
if (typeof citationItem.id === "string" && citationItem.id.indexOf("/") !== -1) { let selectedItems = [...referenceBox.selectedItems].filter(node => referenceBox.contains(node));
var item = Zotero.Cite.getItem(citationItem.id); for (let selectedItem of selectedItems) {
citationItem.uris = item.cslURIs; let itemID = parseInt(selectedItem.getAttribute("zotero-item"));
citationItem.itemData = item.cslItemData; let item = { id: itemID };
} if (Zotero.Retractions.isRetracted(item)) {
else if (Zotero.Retractions.isRetracted({ id: parseInt(citationItem.id) })) { if (Zotero.Retractions.shouldShowCitationWarning(item)) {
citationItem.id = parseInt(citationItem.id); var ps = Services.prompt;
if (Zotero.Retractions.shouldShowCitationWarning(citationItem)) { var buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING
referencePanel.hidden = true; + ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL
var ps = Services.prompt; + ps.BUTTON_POS_2 * ps.BUTTON_TITLE_IS_STRING;
var buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING var checkbox = { value: false };
+ ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL var result = ps.confirmEx(null,
+ ps.BUTTON_POS_2 * ps.BUTTON_TITLE_IS_STRING; Zotero.getString('general.warning'),
var checkbox = { value: false }; Zotero.getString('retraction.citeWarning.text1') + '\n\n'
var result = ps.confirmEx(null, + Zotero.getString('retraction.citeWarning.text2'),
Zotero.getString('general.warning'), buttonFlags,
Zotero.getString('retraction.citeWarning.text1') + '\n\n' Zotero.getString('general.continue'),
+ Zotero.getString('retraction.citeWarning.text2'), null,
buttonFlags, Zotero.getString('pane.items.showItemInLibrary'),
Zotero.getString('general.continue'), Zotero.getString('retraction.citationWarning.dontWarn'), checkbox);
null, if (result > 0) {
Zotero.getString('pane.items.showItemInLibrary'), if (result == 2) {
Zotero.getString('retraction.citationWarning.dontWarn'), checkbox); Zotero_QuickFormat.showInLibrary(itemID);
referencePanel.hidden = false; }
if (result > 0) { // Retraction should not be added
if (result == 2) { referenceBox.removeItemFromSelection(selectedItem);
Zotero_QuickFormat.showInLibrary(parseInt(citationItem.id)); return false;
}
if (checkbox.value) {
Zotero.Retractions.disableCitationWarningsForItem(item);
} }
return false;
}
if (checkbox.value) {
Zotero.Retractions.disableCitationWarningsForItem(citationItem);
} }
} }
citationItem.ignoreRetraction = true;
} }
for (let selectedItem of selectedItems) {
_updateLocator(_getEditorContent()); var citationItem = { id: selectedItem.getAttribute("zotero-item") };
if(currentLocator) { if (typeof citationItem.id === "string" && citationItem.id.indexOf("/") !== -1) {
citationItem["locator"] = currentLocator; var item = Zotero.Cite.getItem(citationItem.id);
if(currentLocatorLabel) { citationItem.uris = item.cslURIs;
citationItem["label"] = currentLocatorLabel; citationItem.itemData = item.cslItemData;
} }
if (currentLocator) {
citationItem.locator = currentLocator;
if (currentLocatorLabel) {
citationItem.label = currentLocatorLabel;
}
}
lastAddedBubble = _insertBubble(citationItem, input);
}
if (!lastAddedBubble) {
return false;
} }
let input = _getCurrentInput() || _lastFocusedInput;
let newBubble = _insertBubble(citationItem, input);
isPaste = false; isPaste = false;
_clearEntryList(); _clearEntryList();
clearLastFocused(input); clearLastFocused(input);
@ -1178,7 +1245,10 @@ var Zotero_QuickFormat = new function () {
yield _previewAndSort(); yield _previewAndSort();
refocusInput(false); refocusInput(false);
locatorNode = getAllBubbles().filter(bubble => bubble.textContent == newBubble.textContent)[0]; // Do not record locator node if multiple bubbles are added
if (!multipleSelected) {
locatorNode = getAllBubbles().filter(bubble => bubble.textContent == lastAddedBubble.textContent)[0];
}
return true; return true;
}); });
@ -1270,7 +1340,7 @@ var Zotero_QuickFormat = new function () {
// to position references panel properly // to position references panel properly
let dialogBottom = dialog.getBoundingClientRect().bottom; let dialogBottom = dialog.getBoundingClientRect().bottom;
let panelTop = referencePanel.getBoundingClientRect().top; let panelTop = referencePanel.getBoundingClientRect().top;
if (Math.abs(dialogBottom - panelTop) > 5) { if (Math.abs(dialogBottom - panelTop) > 5 && referencePanel.state == "open") {
referencePanel.hidePopup(); referencePanel.hidePopup();
// Skip a tick, otherwise the panel may just remain open where it was // Skip a tick, otherwise the panel may just remain open where it was
setTimeout(_openReferencePanel); setTimeout(_openReferencePanel);
@ -1709,9 +1779,6 @@ var Zotero_QuickFormat = new function () {
function _onQuickSearchClick(event) { function _onQuickSearchClick(event) {
if (qfGuidance) qfGuidance.hide(); if (qfGuidance) qfGuidance.hide();
if (!event.target.classList.contains("editor")) {
return;
}
let clickX = event.clientX; let clickX = event.clientX;
let clickY = event.clientY; let clickY = event.clientY;
let { lastBubble, startOfTheLine } = getLastBubbleBeforePoint(clickX, clickY); let { lastBubble, startOfTheLine } = getLastBubbleBeforePoint(clickX, clickY);
@ -1746,15 +1813,20 @@ var Zotero_QuickFormat = new function () {
else { else {
editor.prepend(newInput); editor.prepend(newInput);
} }
locatorNode = null;
newInput.focus(); newInput.focus();
} }
// Essentially a rewrite of default richlistbox arrow navigation // Essentially a rewrite of default richlistbox arrow navigation
// so that it works with voiceover on CMD-ArrowUp/Down // so that it works with voiceover on CMD-ArrowUp/Down
var handleItemSelection = (event) => { var handleItemSelection = (event) => {
event.preventDefault(); let selected = referenceBox.contains(document.activeElement) ? document.activeElement : referenceBox.selectedItem;
event.stopPropagation(); // Multiselect happens during arrowUp/Down navigation when Shift/Cmd is being held
let selected = referenceBox.selectedItem; let selectMultiple = event.shiftKey || event.metaKey;
let initiallySelected = null;
if (referenceBox.contains(document.activeElement) && selectMultiple) {
initiallySelected = selected;
}
let selectNext = (node) => { let selectNext = (node) => {
return event.key == "ArrowDown" ? node.nextElementSibling : node.previousElementSibling; return event.key == "ArrowDown" ? node.nextElementSibling : node.previousElementSibling;
}; };
@ -1764,8 +1836,27 @@ var Zotero_QuickFormat = new function () {
} }
while (selected && selected.disabled); while (selected && selected.disabled);
if (selected) { if (selected) {
referenceBox.selectedItem = selected;
selected.focus(); selected.focus();
let multiSelected = [...referenceBox.selectedItems];
if (selectMultiple) {
// If the selected item is already selected, the previous one
// should be un-selected
if (multiSelected.includes(selected)) {
referenceBox.removeItemFromSelection(initiallySelected);
}
else {
referenceBox.addItemToSelection(selected);
// If there are multiple selected items, focus the last one
let following = selectNext(selected);
while (following && following.selected) {
following.focus();
following = selectNext(following);
}
}
}
else {
referenceBox.selectedItem = selected;
}
} }
}; };
@ -1795,20 +1886,22 @@ var Zotero_QuickFormat = new function () {
// Backspace/Delete from the beginning of an input will delete the previous bubble. // Backspace/Delete from the beginning of an input will delete the previous bubble.
// If there are two inputs next to each other as a result, they are merged // If there are two inputs next to each other as a result, they are merged
if (this.previousElementSibling) { if (this.previousElementSibling) {
this.previousElementSibling.remove(); _deleteBubble(this.previousElementSibling);
_combineNeighboringInputs(); _combineNeighboringInputs();
} }
// Rerun search to update opened documents section if needed // Rerun search to update opened documents section if needed
_resetSearchTimer(); _resetSearchTimer();
} }
else if (["ArrowDown", "ArrowUp"].includes(event.key) && referencePanel.state === "open") { else if (["ArrowDown", "ArrowUp"].includes(event.key) && referencePanel.state === "open") {
event.preventDefault();
event.stopPropagation();
// ArrowUp when item is selected does nothing // ArrowUp when item is selected does nothing
if (referenceBox.selectedIndex < 1 && event.key == "ArrowUp") { if (referenceBox.selectedIndex < 1 && event.key == "ArrowUp") {
return; return;
} }
// Arrow up/down will navigate the references panel if that's opened // Arrow up/down will navigate the references panel if that's opened
if (referenceBox.selectedIndex < 1) { if (referenceBox.selectedIndex < 1) {
referenceBox.selectedIndex = 1; _selectFirstReference();
referenceBox.selectedItem.focus(); referenceBox.selectedItem.focus();
} }
else { else {
@ -1820,7 +1913,7 @@ var Zotero_QuickFormat = new function () {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (referenceBox.selectedIndex < 1) { if (referenceBox.selectedIndex < 1) {
referenceBox.selectedIndex = 1; _selectFirstReference();
} }
referenceBox.selectedItem.focus(); referenceBox.selectedItem.focus();
} }
@ -1888,7 +1981,7 @@ var Zotero_QuickFormat = new function () {
if (!moveFocusBack(this)) { if (!moveFocusBack(this)) {
moveFocusForward(this); moveFocusForward(this);
} }
this.remove(); _deleteBubble(this);
// Removed item bubble may belong to opened documents section. Reference panel // Removed item bubble may belong to opened documents section. Reference panel
// needs to be reset so that it appears among other items. // needs to be reset so that it appears among other items.
_clearEntryList(); _clearEntryList();
@ -2247,7 +2340,7 @@ var Zotero_QuickFormat = new function () {
* Show an item in the library it came from * Show an item in the library it came from
*/ */
this.showInLibrary = async function (itemID) { this.showInLibrary = async function (itemID) {
let citationItem = JSON.parse(panelRefersToBubble.dataset.citationItem || "{}"); let citationItem = JSON.parse(panelRefersToBubble?.dataset.citationItem || "{}");
var id = itemID || citationItem.id; var id = itemID || citationItem.id;
var pane = Zotero.getActiveZoteroPane(); var pane = Zotero.getActiveZoteroPane();
// Open main window if it's not open (Mac) // Open main window if it's not open (Mac)

View file

@ -85,7 +85,7 @@
<html:div id="item-description" class="aria-hidden" role="tooltip" data-l10n-id="quickformat-aria-item"></html:div> <html:div id="item-description" class="aria-hidden" role="tooltip" data-l10n-id="quickformat-aria-item"></html:div>
<panel class="citation-dialog reference-panel" noautofocus="true" norestorefocus="true" <panel class="citation-dialog reference-panel" noautofocus="true" norestorefocus="true"
height="0" width="0" flip="none"> height="0" width="0" flip="none">
<richlistbox class="citation-dialog reference-list" flex="1"/> <richlistbox class="citation-dialog reference-list" flex="1" seltype="multiple"/>
</panel> </panel>
<panel id="citation-properties" type="arrow" orient="vertical" <panel id="citation-properties" type="arrow" orient="vertical"
onkeydown="Zotero_QuickFormat.onPanelKeyPress(event)" onkeydown="Zotero_QuickFormat.onPanelKeyPress(event)"