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) {
event.preventDefault();
event.stopPropagation();
event.target.closest("richlistitem").click();
Zotero_QuickFormat._bubbleizeSelected();
}
// Tab will move focus back to the input field
else if (event.key === "Tab") {
@ -127,8 +127,10 @@ var Zotero_QuickFormat = new function () {
_lastFocusedInput.focus();
}
else if (["ArrowDown", "ArrowUp"].includes(event.key)) {
event.preventDefault();
event.stopPropagation();
// 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();
referenceBox.selectedIndex = -1;
}
@ -146,6 +148,34 @@ var Zotero_QuickFormat = new function () {
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.Prefs.get('integration.keepAddCitationDialogRaised')) {
dialog.setAttribute("square", "true");
@ -890,6 +920,15 @@ var Zotero_QuickFormat = new function () {
var nodes = [];
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()) {
var date = Zotero.Date.sqlToDate(item.dateModified, true);
date = Zotero.Date.toFriendlyDate(date);
@ -989,7 +1028,6 @@ var Zotero_QuickFormat = new function () {
rll.setAttribute("aria-describedby", "item-description");
rll.appendChild(titleNode);
rll.appendChild(infoNode);
rll.addEventListener("click", Zotero_QuickFormat._bubbleizeSelected, false);
return rll;
}
@ -1104,6 +1142,14 @@ var Zotero_QuickFormat = new function () {
function getAllBubbles() {
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
@ -1113,64 +1159,85 @@ var Zotero_QuickFormat = new function () {
if (!skipResize) {
_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
*/
this._bubbleizeSelected = Zotero.Promise.coroutine(function* () {
const panelShowing = referencePanel.state === "open" || referencePanel.state === "showing";
let inputExists = _lastFocusedInput || _getCurrentInput()
if(!panelShowing || !referenceBox.hasChildNodes() || !referenceBox.selectedItem || _searchPromise?.isPending()) return false;
if(!referenceBox.hasChildNodes() || !referenceBox.selectedItem || !inputExists) return false;
var citationItem = {"id":referenceBox.selectedItem.getAttribute("zotero-item")};
if (typeof citationItem.id === "string" && citationItem.id.indexOf("/") !== -1) {
var item = Zotero.Cite.getItem(citationItem.id);
citationItem.uris = item.cslURIs;
citationItem.itemData = item.cslItemData;
}
else if (Zotero.Retractions.isRetracted({ id: parseInt(citationItem.id) })) {
citationItem.id = parseInt(citationItem.id);
if (Zotero.Retractions.shouldShowCitationWarning(citationItem)) {
referencePanel.hidden = true;
var ps = Services.prompt;
var buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING
+ ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL
+ ps.BUTTON_POS_2 * ps.BUTTON_TITLE_IS_STRING;
var checkbox = { value: false };
var result = ps.confirmEx(null,
Zotero.getString('general.warning'),
Zotero.getString('retraction.citeWarning.text1') + '\n\n'
+ Zotero.getString('retraction.citeWarning.text2'),
buttonFlags,
Zotero.getString('general.continue'),
null,
Zotero.getString('pane.items.showItemInLibrary'),
Zotero.getString('retraction.citationWarning.dontWarn'), checkbox);
referencePanel.hidden = false;
if (result > 0) {
if (result == 2) {
Zotero_QuickFormat.showInLibrary(parseInt(citationItem.id));
let input = _lastFocusedInput || _getCurrentInput();
if (referenceBox.selectedCount == 0 || referencePanel.state !== 'open' || !input) return false;
let lastAddedBubble;
let multipleSelected = referenceBox.selectedCount > 1;
// It is technically possible for referenceBox.selectedItems to include nodes that were removed
// (e.g. during panel refreshing). This should never happen but this is a sanity check to make sure
let selectedItems = [...referenceBox.selectedItems].filter(node => referenceBox.contains(node));
for (let selectedItem of selectedItems) {
let itemID = parseInt(selectedItem.getAttribute("zotero-item"));
let item = { id: itemID };
if (Zotero.Retractions.isRetracted(item)) {
if (Zotero.Retractions.shouldShowCitationWarning(item)) {
var ps = Services.prompt;
var buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING
+ ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL
+ ps.BUTTON_POS_2 * ps.BUTTON_TITLE_IS_STRING;
var checkbox = { value: false };
var result = ps.confirmEx(null,
Zotero.getString('general.warning'),
Zotero.getString('retraction.citeWarning.text1') + '\n\n'
+ Zotero.getString('retraction.citeWarning.text2'),
buttonFlags,
Zotero.getString('general.continue'),
null,
Zotero.getString('pane.items.showItemInLibrary'),
Zotero.getString('retraction.citationWarning.dontWarn'), checkbox);
if (result > 0) {
if (result == 2) {
Zotero_QuickFormat.showInLibrary(itemID);
}
// Retraction should not be added
referenceBox.removeItemFromSelection(selectedItem);
return false;
}
if (checkbox.value) {
Zotero.Retractions.disableCitationWarningsForItem(item);
}
return false;
}
if (checkbox.value) {
Zotero.Retractions.disableCitationWarningsForItem(citationItem);
}
}
citationItem.ignoreRetraction = true;
}
_updateLocator(_getEditorContent());
if(currentLocator) {
citationItem["locator"] = currentLocator;
if(currentLocatorLabel) {
citationItem["label"] = currentLocatorLabel;
for (let selectedItem of selectedItems) {
var citationItem = { id: selectedItem.getAttribute("zotero-item") };
if (typeof citationItem.id === "string" && citationItem.id.indexOf("/") !== -1) {
var item = Zotero.Cite.getItem(citationItem.id);
citationItem.uris = item.cslURIs;
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;
_clearEntryList();
clearLastFocused(input);
@ -1178,7 +1245,10 @@ var Zotero_QuickFormat = new function () {
yield _previewAndSort();
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;
});
@ -1270,7 +1340,7 @@ var Zotero_QuickFormat = new function () {
// to position references panel properly
let dialogBottom = dialog.getBoundingClientRect().bottom;
let panelTop = referencePanel.getBoundingClientRect().top;
if (Math.abs(dialogBottom - panelTop) > 5) {
if (Math.abs(dialogBottom - panelTop) > 5 && referencePanel.state == "open") {
referencePanel.hidePopup();
// Skip a tick, otherwise the panel may just remain open where it was
setTimeout(_openReferencePanel);
@ -1709,9 +1779,6 @@ var Zotero_QuickFormat = new function () {
function _onQuickSearchClick(event) {
if (qfGuidance) qfGuidance.hide();
if (!event.target.classList.contains("editor")) {
return;
}
let clickX = event.clientX;
let clickY = event.clientY;
let { lastBubble, startOfTheLine } = getLastBubbleBeforePoint(clickX, clickY);
@ -1746,15 +1813,20 @@ var Zotero_QuickFormat = new function () {
else {
editor.prepend(newInput);
}
locatorNode = null;
newInput.focus();
}
// Essentially a rewrite of default richlistbox arrow navigation
// so that it works with voiceover on CMD-ArrowUp/Down
var handleItemSelection = (event) => {
event.preventDefault();
event.stopPropagation();
let selected = referenceBox.selectedItem;
let selected = referenceBox.contains(document.activeElement) ? document.activeElement : referenceBox.selectedItem;
// Multiselect happens during arrowUp/Down navigation when Shift/Cmd is being held
let selectMultiple = event.shiftKey || event.metaKey;
let initiallySelected = null;
if (referenceBox.contains(document.activeElement) && selectMultiple) {
initiallySelected = selected;
}
let selectNext = (node) => {
return event.key == "ArrowDown" ? node.nextElementSibling : node.previousElementSibling;
};
@ -1764,8 +1836,27 @@ var Zotero_QuickFormat = new function () {
}
while (selected && selected.disabled);
if (selected) {
referenceBox.selectedItem = selected;
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.
// If there are two inputs next to each other as a result, they are merged
if (this.previousElementSibling) {
this.previousElementSibling.remove();
_deleteBubble(this.previousElementSibling);
_combineNeighboringInputs();
}
// Rerun search to update opened documents section if needed
_resetSearchTimer();
}
else if (["ArrowDown", "ArrowUp"].includes(event.key) && referencePanel.state === "open") {
event.preventDefault();
event.stopPropagation();
// ArrowUp when item is selected does nothing
if (referenceBox.selectedIndex < 1 && event.key == "ArrowUp") {
return;
}
// Arrow up/down will navigate the references panel if that's opened
if (referenceBox.selectedIndex < 1) {
referenceBox.selectedIndex = 1;
_selectFirstReference();
referenceBox.selectedItem.focus();
}
else {
@ -1820,7 +1913,7 @@ var Zotero_QuickFormat = new function () {
event.preventDefault();
event.stopPropagation();
if (referenceBox.selectedIndex < 1) {
referenceBox.selectedIndex = 1;
_selectFirstReference();
}
referenceBox.selectedItem.focus();
}
@ -1888,7 +1981,7 @@ var Zotero_QuickFormat = new function () {
if (!moveFocusBack(this)) {
moveFocusForward(this);
}
this.remove();
_deleteBubble(this);
// Removed item bubble may belong to opened documents section. Reference panel
// needs to be reset so that it appears among other items.
_clearEntryList();
@ -2247,7 +2340,7 @@ var Zotero_QuickFormat = new function () {
* Show an item in the library it came from
*/
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 pane = Zotero.getActiveZoteroPane();
// 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>
<panel class="citation-dialog reference-panel" noautofocus="true" norestorefocus="true"
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 id="citation-properties" type="arrow" orient="vertical"
onkeydown="Zotero_QuickFormat.onPanelKeyPress(event)"