Redesigned citation dialog (#4872)

Implemented redesigned citation dialog with library
and list modes one can switch between. This dialog is
a direct replacement of quickFormat and classic citation
dialogs.

In Library Mode:
- items table has a new + button column to add items from the
citation and rows of selected items are highlighted
- open, cited and selected items appear in a section
between the items table and bubbleInput. When there are no
matches, a message is shown.
- only top-level items are shown when citing
items and only notes/notes' parents - when adding a note
- selected items are gathered in a collapsible deck to save space.
Click on the deck will expand it. All selected items can be
added via "Add all" button.
- when an item is added, bubble-input may increase in height and
push itemTree lower. To try to preserve relative positioning
of the mouse, itemTree will scroll to be over the row that was just clicked

In List mode:
- arrow up/down from the input will change the selected item
with the focus remaining in the input
- selected items are a collapsible list section

Other behaviors and fixes:
- one can add any locator (not just pages) by typing its full or short name
in an input and pressing Enter (e.g. line 10, or l. 10, or chap. "test chapter")
- Added a new preference to select if the citation dialog
should always open in list mode, library mode or in the last
mode that was used
- bubbles whose items are selected in library or list mode
are highlighted
- arrow up/down from a bubble will focus the bubble
above/below it for easier navigation across bubble-input
- multi-select is supported on items in list mode or item cards in
library mode via Shift-arrow or Cmd+click to select multiple items
- Cmd/Ctrl + Enter will always accept the dialog no matter what
is focused
- when there are no bubbles, accept button is disabled
- after an item is added, bubble-input is always refocused
- added suppressed property to itemTreeMenubar to be able to
hide if in list mode, where it is not relevant. Fixed menubar
getting stuck or re-appearing on Alt keypress on Linux by setting
height: 0 vs hiding it via hidden.
- added initialFolder and onActivate prop to collectionTree to set which collection
should be reopened when dialog opens and to be able to set a custom
onActivate handler.
- added getExtraField prop to itemTree to get data on if
an item is in a citation or not

Implementation details:
- citationDialog.js is the main file. It relies on a number of
helper files in citationDialog/* directory to keep the main
file less cluttered. popupHandler.js contains the logic of
opening/closing the item details popup to add locator/prefix/suffix/etc.
keyboardHandler.js is responsible for overall keyboard navigation
throughout the dialog. searchHandler.js contains the logic
for running the search based on user's query. Finally, Helpers.js
has general helper functions that don't handle any actual logic.
- SearchHandler is set to run search in two ways: for cited/selected/open
items (which are cached and do not involve any actual SQL query) and
general search for items across all libraries. When layout.search
runs, firstly cited/selected/open items are updated after which the
SQL search runs.
- bubbleInput.js is a customElement responsible for bubbles interface.
bubbleInput.refresh takes a list of items and handles adding/removing/reordering
of bubbles as needed. It always has two inputs on each side of
a bubble, as opposed to having inputs inserted dynamically. It
allows keyboardHandler.js to handle navigation with arrows within
bubbleInput.
- when user interacts with bubbleInput, it emits custom events that are
handled by IOManager singleton in citationDialog.js, which
may update the items information and pass them back to bubbleInput.refresh
to have the list of bubbles updated
- CitationDataManager singleton is responsible for storing
items added into the citation in CitationDataManager.items
in an object with both Zotero.Item and the actual citation item.
It is easier to pass both items to other components
and helpers, as opposed to sharing functions to convert
items back and forth.
This commit is contained in:
Bogdan Abaev 2025-01-21 13:38:42 -08:00 committed by Dan Stillman
parent 27f1103d88
commit 4a00304925
29 changed files with 4310 additions and 547 deletions

View file

@ -113,7 +113,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
async makeVisible() {
await this.refresh();
var lastViewedID = Zotero.Prefs.get('lastViewedFolder');
var lastViewedID = this.props.initialFolder || Zotero.Prefs.get('lastViewedFolder');
if (lastViewedID) {
var selected = await this.selectByID(lastViewedID);
}
@ -472,7 +472,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
onItemContextMenu: (...args) => this.props.onContextMenu && this.props.onContextMenu(...args),
onKeyDown: this.handleKeyDown,
onActivate: this.handleActivate,
onActivate: (...args) => (this.props.onActivate ? this.props.onActivate(...args) : this.handleActivate(...args)),
role: 'tree',
label: Zotero.getString('pane.collections.title')
@ -514,7 +514,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
libraryIncluded = this._includedInTree({ libraryID: Zotero.Libraries.userLibraryID });
if (libraryIncluded) {
newRows.splice(added++, 0,
new Zotero.CollectionTreeRow(this, 'library', { libraryID: Zotero.Libraries.userLibraryID }));
new Zotero.CollectionTreeRow(this, 'library', Zotero.Libraries.userLibrary));
newRows[0].isOpen = true;
added += await this._expandRow(newRows, 0);
}

View file

@ -73,6 +73,7 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
['notes-context', 'chrome://zotero/content/elements/notesContext.js'],
['libraries-collections-box', 'chrome://zotero/content/elements/librariesCollectionsBox.js'],
['autocomplete-textarea', 'chrome://zotero/content/elements/autocompleteTextArea.js'],
['bubble-input', 'chrome://zotero/content/elements/bubbleInput.js'],
]) {
customElements.setElementCreationCallback(tag, () => {
Services.scriptloader.loadSubScript(script, window);

View file

@ -34,387 +34,504 @@
init() {
this._body = this.querySelector('.bubble-input.body');
this._body.addEventListener('keydown', this._onBodyKeydown.bind(this), { capture: true });
this._body.addEventListener('click', this._onBodyClick.bind(this));
this._body.addEventListener('dragenter', event => event.preventDefault());
this._body.addEventListener('dragover', (event) => {
event.preventDefault();
if (event.target === this._body) {
const { lastBubble: lastBeforeDrop } = this._getLastBubbleBeforePoint(event.clientX, event.clientY);
const lastBubble = this._body.querySelector('.bubble:last-of-type');
if (lastBubble !== lastBeforeDrop) return;
// Only drop to final position if dragging after all bubbles, not in-between
this._dragOver?.classList.remove('drop-after', 'drop-before');
this._dragOver = event.target;
lastBubble.classList.add('drop-after');
}
});
this._body.addEventListener('dragleave', (event) => {
this._dragOver?.classList.remove('drop-after', 'drop-before');
this._dragOver = null;
});
this._body.addEventListener('drop', (event) => {
event.preventDefault();
event.stopPropagation();
if (!this._dragBubble || !this._dragOver && this._dragBubble != this._dragOver) return;
this._dragBubble.remove();
if (this._dragOver === this._body) {
this._body.querySelector('.bubble:last-of-type').after(this._dragBubble);
}
else {
this._dragOver.before(this._dragBubble);
}
this._dragOver?.classList.remove('drop-after', 'drop-before');
this._lastFocusedInput = null;
// // Find old position in list
// var oldPosition = this._getBubbleIndex(this._dragBubble);
//
// // Move bubble
// var range = document.createRange();
// // Prevent dragging out of qfe
// if (event.target === qfe) {
// range.setStartAfter(qfe.childNodes[qfe.childNodes.length-1]);
// }
// else {
// range.setStartAfter(event.target);
// }
// dragging.parentNode.removeChild(dragging);
// var bubble = _insertBubble(JSON.parse(dragging.dataset.citationItem), range);
// this._dragBubble = null;
//
// // If moved out of order, turn off "Keep Sources Sorted"
// if(io.sortable && keepSorted && keepSorted.hasAttribute("checked") && oldPosition !== -1 &&
// oldPosition != _getBubbleIndex(bubble)) {
// keepSorted.removeAttribute("checked");
// }
//
// yield _previewAndSort();
// _moveCursorToEnd();
});
this._appendInput();
this._dragBubble = null;
this._dragOver = null;
Utils.init(this);
DragDropHandler.init(this);
}
_onBodyClick(event) {
if (event.target !== this._body) {
return;
}
let clickX = event.clientX;
let clickY = event.clientY;
let { lastBubble, startOfTheLine } = this._getLastBubbleBeforePoint(clickX, clickY);
// If click happened right before another input, focus that input
// instead of adding another one. There may be a br node on the way, so we have to check
// more than just the next node.
if (lastBubble) {
let nextNode = lastBubble.nextElementSibling;
while (nextNode && !nextNode.classList.contains("bubble")) {
if (this._isInput(nextNode)) {
nextNode.focus();
return;
}
nextNode = nextNode.nextElementSibling;
}
}
let newInput = this._createInputElem();
if (lastBubble !== null) {
lastBubble.after(newInput);
if (startOfTheLine) {
let lineBreak = document.createElement("br");
lastBubble.after(lineBreak);
}
}
else {
this._body.prepend(newInput);
}
newInput.focus();
focus() {
this.refocusInput();
}
_onBodyKeydown(event) {
const focused = document.activeElement;
if (this._isInput(focused) && event.key == "Enter") {
this.convertInputToBubble();
}
else if (["ArrowLeft", "ArrowRight"].includes(event.key) && !event.shiftKey) {
// On arrow left from the beginning of the input, move to previous bubble
if (event.key === "ArrowLeft" && (!this._isInput(focused) || focused.selectionStart === 0)) {
this._moveFocusBack(focused);
event.preventDefault();
}
// On arrow right from the end of the input, move to next bubble
else if (event.key === "ArrowRight" && (!this._isInput(focused) || focused.selectionStart === focused.value.length)) {
this._moveFocusForward(focused);
event.preventDefault();
}
}
else if (this._isInput(focused) && ["Backspace", "Delete"].includes(event.key)
&& (focused.selectionStart + focused.selectionEnd) === 0) {
event.preventDefault();
// 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();
this._combineNeighboringInputs();
}
}
}
convertInputToBubble(text) {
const input = this.getLastInput();
text = text || input.value;
const bubble = this._createBubble(text);
input.before(bubble);
input.value = "";
return bubble;
}
_createBubble(str) {
let bubble = document.createElement("div");
bubble.setAttribute("draggable", "true");
bubble.setAttribute("role", "button");
bubble.setAttribute("tabindex", "0");
bubble.setAttribute("aria-describedby", "bubble-description");
bubble.setAttribute("aria-haspopup", true);
bubble.className = "bubble";
// VoiceOver works better without it
if (!Zotero.isMac) {
bubble.setAttribute("aria-label", str);
}
// bubble.addEventListener("click", _onBubbleClick);
bubble.addEventListener("dragstart", (event) => {
this._dragBubble = event.currentTarget;
event.dataTransfer.setData("text/plain", '<span id="zotero-drag"/>');
event.stopPropagation();
});
bubble.addEventListener("dragover", (event) => {
this._dragOver?.classList.remove('drop-after', 'drop-before');
this._dragOver = bubble;
bubble.classList.add('drop-before');
});
// bubble.addEventListener("dragend", onBubbleDragEnd);
// bubble.addEventListener("keypress", onBubblePress);
// bubble.addEventListener("mousedown", (_) => {
// _bubbleMouseDown = true;
// });
// bubble.addEventListener("mouseup", (_) => {
// _bubbleMouseDown = false;
// });
// bubble.dataset.citationItem = JSON.stringify(citationItem);
let text = document.createElement("span");
text.textContent = str;
text.className = "text";
bubble.append(text);
let cross = document.createElement("div");
cross.className = "cross";
cross.addEventListener("click", () => {
bubble.remove();
});
cross.addEventListener("keydown", (e) => {
if (e.target === cross && e.key === "Enter") {
/**
* Synchronize bubbles with the given citation data. Add bubbles for citation items that
* are not present, remove bubbles whose citation items were removed, rearrange bubbles
* if the items were moved, update bubble text if locator/prefix/suffix was changed.
* Make sure that there is an input for user to type in before and after every bubble.
* @param {Object[]} combinedItems - array of objects { zoteroItem, citationItem, dialogReferenceID, selected }.
* zoteroItem - Zotero.Item, citationItem - object from io.citation.citationItems
* dialogReferenceID - String ID of this citation entry, selected - Boolean indicator if bubble should be highlighted
*/
refresh(combinedItems) {
// Remove bubbles of items that are no longer in the citations
for (let bubble of this.getAllBubbles()) {
let bubbleDialogReferenceID = bubble.getAttribute("dialogReferenceID");
let itemExistsForBubble = combinedItems.find(({ dialogReferenceID }) => dialogReferenceID == bubbleDialogReferenceID);
if (!itemExistsForBubble) {
bubble.remove();
}
});
bubble.append(cross);
return bubble;
}
_isInput(node) {
if (!node) return false;
return node.tagName === "input";
}
// Determine if the input is empty
_isInputEmpty(input) {
if (!input) {
return true;
}
return input.value.length == 0;
}
getLastInput() {
return this.getCurrentInput() || this._lastFocusedInput;
// Ensure each item in the citation has a bubble in the right position
for (let [index, { dialogReferenceID, bubbleString }] of Object.entries(combinedItems)) {
let allBubbles = this.getAllBubbles();
let bubbleNode = allBubbles.find(candidate => candidate.getAttribute("dialogReferenceID") == dialogReferenceID);
// Create bubble if it does not exist and append to the input
if (!bubbleNode) {
bubbleNode = this._createBubble(bubbleString, dialogReferenceID);
this._body.append(bubbleNode);
allBubbles = this.getAllBubbles();
}
// Update bubble string
if (bubbleNode.textContent !== bubbleString) {
bubbleNode.textContent = bubbleString;
}
// Move bubble if it's index does not correspond to the position of the item
let expectedIndex = allBubbles.indexOf(bubbleNode);
if (expectedIndex != index) {
let referenceNode = allBubbles[index];
this._body.insertBefore(bubbleNode, referenceNode);
}
}
// Make sure there is an input following every bubble
for (let bubble of this.getAllBubbles()) {
let nextNode = bubble.nextElementSibling;
if (!nextNode || !Utils.isInput(nextNode)) {
let input = this._createInputElem();
bubble.after(input);
}
}
// Highlight bubbles selected in the library view
for (let bubble of this.getAllBubbles()) {
let bubbleDialogReferenceID = bubble.getAttribute("dialogReferenceID");
let itemObj = combinedItems.find(({ dialogReferenceID }) => dialogReferenceID == bubbleDialogReferenceID);
if (itemObj) {
bubble.classList.toggle("has-item-selected", !!itemObj.selected);
}
}
// Make sure that all inputs occupy the right width
for (let input of [...this.querySelectorAll(".input")]) {
let requiredWidth = Utils.getContentWidth(input);
input.style.width = `${requiredWidth}px`;
}
// Prepend first input
if (!this._body.firstChild || !Utils.isInput(this._body.firstChild)) {
let input = this._createInputElem();
this._body.prepend(input);
}
// Add placeholder and a special aria-description to the first input when there are no bubbles
let isOnlyInput = this.getAllBubbles().length == 0;
this._body.firstChild.classList.toggle("full-width", isOnlyInput);
if (isOnlyInput) {
document.l10n.setAttributes(this._body.firstChild, "integration-citationDialog-single-input");
}
// otherwise, add a regular aria descriptions and placeholders to all inputs
else {
for (let input of [...this.querySelectorAll(".input")]) {
document.l10n.setAttributes(input, "integration-citationDialog-input");
}
}
// If any two inputs end up next to each other (e.g. after bubble is deleted),
// have them merged
Utils.combineNeighboringInputs(this._body.firstChild);
// if bubble input is scrollable, scroll to the bottom
if (this._body.scrollHeight > this._body.clientHeight) {
this._body.scrollTop = this._body.scrollHeight;
}
}
/**
* Return the focus to an input. Try to focus on the previously active input first.
* Otherwise, focus the last non-empty input in the editor.
* If all inputs are empty, focus the last one.
*/
refocusInput() {
let input = this.getCurrentInput();
let allInputs = [...this._body.querySelectorAll('.input')];
if (!input) {
input = allInputs.find(inp => inp.value.length);
}
if (!input) {
input = allInputs[allInputs.length - 1];
}
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
return input;
}
/**
* Get the input that the user interacted with last. If an input is focused, return that.
* Otherwise, return last focused input, if it is still part of the bubbleInput.
*/
getCurrentInput() {
if (this._isInput(document.activeElement)) {
if (Utils.isInput(document.activeElement)) {
return document.activeElement;
}
if (this._lastFocusedInput && this.contains(this._lastFocusedInput)) {
return this._lastFocusedInput;
}
return false;
}
// Create input in the end of the editor and focus it
_appendInput() {
let newInput = this._createInputElem();
this._body.appendChild(newInput);
setTimeout(() => newInput.focus());
return newInput;
}
isEmpty() {
return this._body.childElementCount == 1 && this._isInput(this._body.firstChild);
/**
* Get the index that a bubble inserted after current input would have.
* Used by CitationDialog to know at which index to save the newly added item.
*/
getFutureBubbleIndex() {
let input = this.getCurrentInput();
if (!input) return -1;
input.classList.add("future-bubble");
let allElements = [...this._body.querySelectorAll(".bubble,.future-bubble")];
let index = allElements.findIndex(node => node == input);
input.classList.remove("future-bubble");
return index;
}
// If this input field was counted as previously focused,
// it will be cleared. Call before removing the field
_clearLastFocused(input) {
if (input == this._lastFocusedInput) {
this._lastFocusedInput = null;
/**
* Shortcut to get all existing bubbles as an array
*/
getAllBubbles() {
return [...this.querySelectorAll(".bubble")];
}
/**
* On click of the body, find the last bubble before the click and
* focus input following that bubble. If no such bubble was found, focus
* the very first input.
*/
_onBodyClick(event) {
if (event.target !== this._body) {
return;
}
let { clientX, clientY } = event;
let lastBubble = Utils.getLastBubbleBeforePoint(clientX, clientY);
if (lastBubble) {
lastBubble.nextSibling.focus();
}
else {
this._body.firstChild.focus();
}
}
_getContentWidth(input) {
let span = document.createElement("span");
span.classList = "input";
span.innerText = input.value;
this._body.appendChild(span);
let spanWidth = span.getBoundingClientRect().width;
span.remove();
return spanWidth + 2;
/**
* Create a bubble node representing item present in the citation
* @param {String} content - textual content of the bubble
* Contains Item's title/author/locator/prefix/suffix/etc.
* @param {String} dialogReferenceID - ID used by citationDialog to relate bubbles to cited items
* @returns {Node} - bubble node
*/
_createBubble(content, dialogReferenceID) {
let bubble = document.createElement("div");
bubble.setAttribute("draggable", "true");
bubble.setAttribute("role", "button");
bubble.setAttribute("tabindex", "0");
bubble.setAttribute("data-l10n-id", "integration-citationDialog-aria-bubble");
bubble.setAttribute("aria-haspopup", true);
bubble.setAttribute("dialogReferenceID", dialogReferenceID);
bubble.setAttribute("data-arrow-nav-enabled", true);
bubble.className = "bubble";
// VoiceOver works better without it
if (!Zotero.isMac) {
bubble.setAttribute("aria-label", content);
}
// On click, tell citationDialog to display the details popup
bubble.addEventListener("click", () => Utils.notifyDialog("show-details-popup", { dialogReferenceID: bubble.getAttribute("dialogReferenceID") }));
bubble.addEventListener("keydown", this._onBubbleKeydown.bind(this));
let text = document.createElement("span");
text.textContent = content;
text.className = "text";
bubble.append(text);
let deleteBtn = document.createElement("div");
deleteBtn.className = "delete-btn";
let cross = document.createElement("span");
cross.className = "icon icon-css icon-x-8 icon-16";
deleteBtn.addEventListener("click", (event) => {
this._deleteBubble(bubble);
event.stopPropagation();
});
deleteBtn.appendChild(cross);
bubble.append(deleteBtn);
return bubble;
}
/**
* Handle keypresses on a bubble.
*/
_onBubbleKeydown(event) {
let bubble = event.target;
if (["ArrowLeft", "ArrowRight"].includes(event.key) && event.shiftKey) {
// On Shift-Left/Right swap focused bubble with it's neighbor
event.preventDefault();
event.stopPropagation();
let nextBubble = Utils.findNextClass("bubble", bubble, event.key == Zotero.arrowNextKey);
if (nextBubble) {
let nextBubbleIndex = [...this._body.querySelectorAll(".bubble")].findIndex(bubble => bubble == nextBubble);
Utils.notifyDialog('move-item', { dialogReferenceID: bubble.getAttribute("dialogReferenceID"), index: nextBubbleIndex });
}
bubble.focus();
}
else if (["Backspace", "Delete"].includes(event.key)) {
event.preventDefault();
// On backspace or delete, shift focus to previous or next bubble if possible,
// otherwise, refocus input after the bubble is deleted
let previousBubble = Utils.findNextClass("bubble", bubble, false);
let nextBubble = Utils.findNextClass("bubble", bubble, true);
if (previousBubble) {
previousBubble.focus();
}
else if (nextBubble) {
nextBubble.focus();
}
else {
this.refocusInput();
}
this._deleteBubble(bubble);
}
else if (Utils.isKeypressPrintable(event) && event.key !== " ") {
event.preventDefault();
let input = this.refocusInput();
// Typing when you are focused on the bubble will re-focus the last input
input.value += event.key;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
// Space on bubble simulates a click
if (event.key == " ") {
event.target.click();
event.preventDefault();
event.stopPropagation();
}
// Home - focus the first input
if (event.key == "Home") {
this._body.firstChild.focus();
}
// End - focus the last input
if (event.key == "End") {
this._body.lastChild.focus();
}
// Navigate bubble rows on arrow up/down
if (["ArrowUp", "ArrowDown"].includes(event.key)) {
let { x, y, width } = bubble.getBoundingClientRect();
let nextBubble = Utils.getLastBubbleBeforePoint(x + (width / 2), event.key == "ArrowUp" ? y - 25 : y + 30);
// Focus the next bubble if it exists. Otherwise, event will propagate to be handled
// by keyboardHandler.js of citationDialog.js
if (nextBubble) {
nextBubble.focus();
event.preventDefault();
event.stopPropagation();
}
}
}
// Citation dialog will record that the item is removed and the bubble will be gone after refresh()
_deleteBubble(bubble) {
Utils.notifyDialog('delete-item', { dialogReferenceID: bubble.getAttribute("dialogReferenceID") });
}
/**
* Create input element placed on each side of a bubble to accept user input.
*/
_createInputElem() {
let input = document.createElement('input');
input.setAttribute("aria-describedby", "input-description");
// tabindex for keyboard handling
input.setAttribute("tabindex", 0);
// hide windows appearance from _input.scss
input.setAttribute("no-windows-native", true);
input.setAttribute("data-arrow-nav-enabled", true);
input.className = "input empty";
input.setAttribute("data-l10n-id", "integration-citationDialog-input");
input.addEventListener("input", (_) => {
// _resetSearchTimer();
// Expand/shrink the input field to match the width of content
let width = this._getContentWidth(input);
input.style.width = width + 'px';
// .full-width class is used on first input to fully display placeholder
// in that case, resizing does not happen
if (!input.classList.contains("full-width")) {
// Expand/shrink the input field to match the width of content
input.style.width = Utils.getContentWidth(input) + 'px';
}
input.classList.toggle("empty", input.value.length == 0);
Utils.notifyDialog("handle-input", { query: input.value, eventType: "input" });
});
// input.addEventListener("keypress", onInputPress);
input.addEventListener("keypress", e => this._onInputKeypress(input, e));
// input.addEventListener("paste", _onPaste, false);
input.addEventListener("keydown", e => this._onInputKeydown(input, e));
input.addEventListener("focus", (_) => {
// // If the input used for the last search run is refocused,
// // just make sure the reference panel is opened if it has items.
// if (this._lastFocusedInput == input && referenceBox.childElementCount > 0) {
// _openReferencePanel();
// return;
// }
// // Otherwise, run the search if the input is non-empty.
// if (!isInputEmpty(input)) {
// _resetSearchTimer();
// }
// else {
// _updateItemList({ citedItems: [] });
// }
this._lastFocusedInput = input;
// When input is re-focused, tell citationDialog that search can be rerun
// without debounce
Utils.notifyDialog("handle-input", { query: input.value, eventType: "focus" });
});
// // Delete empty input on blur unless it's the last input
input.addEventListener("blur", (_) => {
// Timeout to know where the focus landed after
setTimeout(() => {
const inputFocused = this._isInput(document.activeElement);
if (this._isInputEmpty(input) && inputFocused) {
// // Resizing window right before drag-drop reordering starts, will interrupt the
// // drag event. To avoid it, hide the input immediately and delete it after delay.
// if (_bubbleMouseDown) {
// input.style.display = "none";
// setTimeout(() => {
// input.remove();
// }, 500);
// clearLastFocused(input);
// }
// else
if (document.activeElement !== input && !this.isEmpty()) {
// If no dragging, delete it if focus has moved elsewhere.
// If focus remained, the entire dialog lost focus, so do nothing
// If this is the last, non-removable, input - do not remove it as well.
input.remove();
this._clearLastFocused(input);
}
}
});
// If there was a br added before input so that it doesn't appear on the previous line,
// remove it
if (input.previousElementSibling?.tagName == "br") {
input.previousElementSibling.remove();
input.addEventListener("blur", async (event) => {
// record this input as last focused if it's not empty OR if the focus left bubbleInput altogether
if (!Utils.isInputEmpty(input) || !this.contains(event.relatedTarget)) {
this._lastFocusedInput = input;
}
});
return input;
}
_onInputKeypress(input, event) {
if (event.target === input && event.key == "Enter") {
this.convertInputToBubble();
/**
* Handle keypresses on inputs created in _createInputElem()
*/
_onInputKeydown(input, event) {
// Do not allow focus handler to interfere on arrow key navigation within the input
if ((event.key == Zotero.arrowPreviousKey && input.selectionStart !== 0)
|| (event.key == Zotero.arrowNextKey && input.selectionEnd !== input.value.length)) {
event.stopPropagation();
}
else if (["ArrowLeft", "ArrowRight"].includes(event.key) && !event.shiftKey) {
// On arrow left from the beginning of the input, move to previous bubble
if (event.key === "ArrowLeft" && input.selectionStart === 0) {
this._moveFocusBack(input);
event.preventDefault();
}
// On arrow right from the end of the input, move to next bubble
else if (event.key === "ArrowRight" && input.selectionStart === input.value.length) {
this._moveFocusForward(input);
event.preventDefault();
}
// Enter on an input can have multiple outcomes, they are handled in citationDialog
if (event.key == "Enter" && !event.shiftKey) {
Utils.notifyDialog("input-enter", { input });
event.stopPropagation();
}
else if (["Backspace", "Delete"].includes(event.key)
if (["Backspace", "Delete"].includes(event.key)
&& (input.selectionStart + input.selectionEnd) === 0) {
event.preventDefault();
// 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();
this._combineNeighboringInputs();
if (input.previousElementSibling) {
this._deleteBubble(input.previousElementSibling);
}
}
}
_moveFocusForward(node) {
if (node.nextElementSibling?.focus) {
node.nextElementSibling.focus();
return true;
// Home from the beginning of an input - focus the first input
if (event.key == "Home" && Utils.isCursorAtInputStart(input)) {
this._body.firstChild.focus();
}
return false;
}
_moveFocusBack(node) {
// Skip line break if it's before the node
if (node.previousElementSibling?.tagName == "br") {
node = node.previousElementSibling;
// End from the end of an input - focus the last input
if (event.key == "End" && Utils.isCursorAtInputEnd(input)) {
this._body.lastChild.focus();
}
if (node.previousElementSibling?.focus) {
node.previousElementSibling.focus();
return true;
}
return false;
}
}
// If a bubble is removed between two inputs we need to combine them
_combineNeighboringInputs() {
let node = this._body.firstChild;
while (node && node.nextElementSibling) {
if (this._isInput(node)
&& this._isInput(node.nextElementSibling)) {
let secondInputValue = node.nextElementSibling.value;
node.value += ` ${secondInputValue}`;
node.dispatchEvent(new Event('input', { bubbles: true }));
// Make sure focus is not lost when two inputs are combined
if (node.nextElementSibling == document.activeElement) {
node.focus();
// Singleton handling drag-drop behavior of bubbles
const DragDropHandler = {
init(bubbleInput) {
this.bubbleInput = bubbleInput;
this.dragBubble = null;
this.dragOver = null;
this.doc = bubbleInput.ownerDocument;
bubbleInput.addEventListener("dragstart", this.handleDragStart.bind(this));
bubbleInput.addEventListener("dragenter", this.handleDragEnter.bind(this));
bubbleInput.addEventListener("dragover", this.handleDragOver.bind(this));
bubbleInput.addEventListener("drop", this.handleDrop.bind(this));
this.doc.addEventListener("dragend", this.handleDragEnd.bind(this));
},
handleDragStart(event) {
// No drag on X button
if (event.target.closest(".delete-btn")) {
event.preventDefault();
return;
}
this.dragBubble = event.target;
event.dataTransfer.setData("text/plain", '<span id="zotero-drag"/>');
event.stopPropagation();
},
handleDragEnter(event) {
event.preventDefault();
},
handleDragOver(event) {
event.preventDefault();
// Find the last bubble before current mouse position
let lastBeforeDrop = Utils.getLastBubbleBeforePoint(event.clientX, event.clientY);
// If no bubble, mouse may be at the very start of the input so use the first bubble
if (!lastBeforeDrop) {
lastBeforeDrop = this.bubbleInput.getAllBubbles()[0];
}
// There may be no bubbles at all
if (!lastBeforeDrop) return;
this.dragOver?.classList.remove('drop-after', 'drop-before');
this.dragOver = lastBeforeDrop;
// Add indicator after or before the hovered bubble depending on mouse position
let bubbleRect = lastBeforeDrop.getBoundingClientRect();
let midpoint = (bubbleRect.right + bubbleRect.left) / 2;
if (event.clientX > midpoint) {
this.dragOver.classList.add('drop-after');
}
else {
this.dragOver.classList.add('drop-before');
}
},
handleDrop(event) {
event.preventDefault();
event.stopPropagation();
let itemIDs = event.dataTransfer.getData("zotero/item");
// Handle drag-drop of items from the citationDialog into bubble-input to add them
if (itemIDs) {
itemIDs = itemIDs.split(",");
console.log(itemIDs);
let newIndex = 0;
if (this.dragOver) {
newIndex = [...this.bubbleInput.querySelectorAll(".bubble")].findIndex(node => this.dragOver == node);
if (this.dragOver.classList.contains("drop-after")) {
newIndex++;
}
node.nextElementSibling.remove();
}
node = node.nextElementSibling;
Utils.notifyDialog('add-dragged-item', { itemIDs, index: newIndex });
setTimeout(() => {
this.handleDragEnd();
});
return;
}
}
if (!this.dragBubble || !this.dragOver) return;
if (this.dragOver.classList.contains("drop-after")) {
this.dragOver.after(this.dragBubble);
}
else {
this.dragOver.before(this.dragBubble);
}
this.dragOver.classList.remove('drop-after', 'drop-before');
_getBubbleIndex(bubble) {
return this.body.querySelectorAll('.bubble').indexOf(bubble);
}
// Tell citationDialog.js where the bubble moved
let newIndex = [...this.bubbleInput.querySelectorAll(".bubble")].findIndex(node => node == this.dragBubble);
Utils.notifyDialog('move-item', { dialogReferenceID: this.dragBubble.getAttribute("dialogReferenceID"), index: newIndex });
},
handleDragEnd(_) {
this.bubbleInput.querySelector(".drop-after,.drop-before")?.classList.remove('drop-after', 'drop-before');
this.dragBubble = null;
this.dragOver = null;
},
};
const Utils = {
init(bubbleInput) {
this.bubbleInput = bubbleInput;
},
isInput(node) {
if (!node) return false;
return node.tagName === "input";
},
isInputEmpty(input) {
if (!input) {
return true;
}
return input.value.length == 0;
},
isCursorAtInputStart(input) {
return Zotero.rtl ? input.selectionStart == input.value.length : input.selectionStart == 0;
},
isCursorAtInputEnd(input) {
return Zotero.rtl ? input.selectionStart == 0 : input.selectionStart == input.value.length;
},
findNextClass(className, startNode, isForward) {
let node = startNode;
do {
node = isForward ? node.nextElementSibling : node.previousElementSibling;
} while (node && !(node.classList.contains(className)));
if (node == startNode) return false;
return node;
},
/**
* Find the last bubble (lastBubble) before a given coordinate and indicate if there are no bubbles
* to the left of the x-coordinate (startOfTheLine). If there is no last bubble, null is returned.
* startOfTheLine indicates if a <br> should be added so that a new input placed after lastBubble
* does not land on the previous line.
* Outputs for a sample of coordinates (with #3 having startOfTheLine=true):
* Find the last bubble (lastBubble) before a given coordinate.
* If there is no last bubble, null is returned.
* Outputs for a sample of coordinates:
* NULL #1 #2 #3
*
* [ bubble_1 bubble_2 bubble_3
@ -423,18 +540,21 @@
* #3 #4 #5 #5
* @param {Int} x - X coordinate
* @param {Int} y - Y coordinate
* @returns {lastBubble: Node, startOfTheLine: Bool}
* @returns {Node} lastBubble
*/
_getLastBubbleBeforePoint(x, y) {
let bubbles = this._body.querySelectorAll('.bubble');
getLastBubbleBeforePoint(x, y) {
let bubbles = this.bubbleInput.querySelectorAll('.bubble');
let lastBubble = null;
let startOfTheLine = false;
let verticalBubbleMargin = parseInt(getComputedStyle(this.bubbleInput).getPropertyValue("--bubble-vertical-margin")) || 0;
let isClickAfterBubble = (clickX, bubbleRect) => {
return Zotero.rtl ? clickX <= bubbleRect.right : clickX >= bubbleRect.left;
};
for (let i = 0; i < bubbles.length; i++) {
let rect = bubbles[i].getBoundingClientRect();
// If within the vertical range of a bubble
if (y >= rect.top && y <= rect.bottom) {
if (y >= (rect.top - verticalBubbleMargin) && y <= (rect.bottom + verticalBubbleMargin)) {
// If the click is to the right of a bubble, it becomes a candidate
if (x > rect.right) {
if (isClickAfterBubble(x, rect)) {
lastBubble = i;
}
// Otherwise, stop and return the last bubble we saw if any
@ -443,8 +563,6 @@
lastBubble = null;
}
else {
// Indicate there is no bubble before this one
startOfTheLine = lastBubble === null;
lastBubble = Math.max(i - 1, 0);
}
break;
@ -454,9 +572,76 @@
if (lastBubble !== null) {
lastBubble = bubbles[lastBubble];
}
return { lastBubble: lastBubble, startOfTheLine: startOfTheLine };
return lastBubble;
},
notifyDialog(eventType, data = {}) {
let event = new CustomEvent(eventType, {
bubbles: true,
detail: data
});
this.bubbleInput.dispatchEvent(event);
},
// Determine if keypress event is on a printable character.
/* eslint-disable array-element-newline */
isKeypressPrintable(event) {
if (event.ctrlKey || event.metaKey || event.altKey) return false;
// If it's a single character, for latin locales it has to be printable
if (event.key.length === 1) {
return true;
}
// Otherwise, check against a list of common control keys
let nonPrintableKeys = [
'Enter', 'Escape', 'Backspace', 'Tab',
'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown',
'Home', 'End', 'PageUp', 'PageDown',
'Delete', 'Insert',
'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12',
'Control', 'Meta', 'Alt', 'Shift', 'CapsLock'
];
/* eslint-enable array-element-newline */
return !nonPrintableKeys.includes(event.key);
},
getContentWidth(input) {
let span = document.createElement("span");
span.classList = "input";
span.innerText = input.value;
this.bubbleInput._body.appendChild(span);
let spanWidth = span.getBoundingClientRect().width;
span.remove();
return spanWidth;
},
// If a bubble is removed between two inputs we need to combine them
combineNeighboringInputs(startingNode) {
let node = startingNode;
let initiallyFocusedInputValue = this.isInput(document.activeElement) ? document.activeElement.value : "";
while (node && node.nextElementSibling) {
if (this.isInput(node) && this.isInput(node.nextElementSibling)) {
// Place the string in the input that has focus (if either of them does)
// to avoid triggering search rerun due to focus change
let combinedValue = `${node.value}${node.value.length ? ' ' : ''}${node.nextElementSibling.value}`;
let remainingInput = node;
let inputToDelete = node.nextElementSibling;
if (document.activeElement == node.nextElementSibling) {
remainingInput = node.nextElementSibling;
inputToDelete = node;
}
remainingInput.value = combinedValue;
inputToDelete.remove();
// Ensure the width of the combined input is correct
remainingInput.style.width = Utils.getContentWidth(node) + 'px';
}
node = node.nextElementSibling;
}
// Rerun the search in the end if the focused input has a different value than before
if (this.isInput(document.activeElement) && document.activeElement.value !== initiallyFocusedInputValue) {
this.notifyDialog("handle-input", { query: document.activeElement.value, eventType: "focus" });
}
}
}
};
customElements.define('bubble-input', BubbleInput);
}
}

View file

@ -68,10 +68,21 @@ class ItemTreeMenuBar extends XULElement {
`, ['chrome://zotero/locale/standalone.dtd']);
}
get suppressed() {
return this.hasAttribute("no-menubar");
}
set suppressed(val) {
this.toggleAttribute("no-menubar", !!val);
for (let menu of this.querySelectorAll("menu")) {
menu.hidden = this.suppressed;
}
}
connectedCallback() {
this.append(document.importNode(this.content, true));
this.hidden = true;
this.setAttribute("inactive", "");
}
// Show View > Columns, Sort By menus for windows that have an itemTree
@ -119,21 +130,15 @@ class ItemTreeMenuBar extends XULElement {
});
}
if (!Zotero.isMac) {
// On Windows and Linux, display and focus menubar on Alt keypress
document.addEventListener("keydown", (event) => {
if (event.key == "Alt") {
this.hidden = !this.hidden;
document.getElementById("main-menubar").focus();
}
}, true);
// Hide menubar on click or tab away. If a selected menu is clicked, it will
// fire DOMMenuBarInactive event first followed by DOMMenuBarActive.
// Listen to both events and hide inactive menu after delay if it is not cancelled.
// On Alt keypress, DOMMenuBarActive event is fired. On click or tab away from the menubar, DOMMenuBarInactive is fired.
// Handle these event to display/hide menubar accordingly.
// If a selected menu is clicked, DOMMenuBarInactive event will fire first followed by DOMMenuBarActive.
// To keep the menu visible in that case, hide inactive menu after delay if it is not cancelled.
// https://searchfox.org/mozilla-central/source/browser/base/content/browser-customization.js#165
document.addEventListener("DOMMenuBarInactive", (_) => {
this._inactiveTimeout = setTimeout(() => {
this._inactiveTimeout = null;
this.hidden = true;
this.setAttribute("inactive", "");
});
});
document.addEventListener("DOMMenuBarActive", (_) => {
@ -141,7 +146,7 @@ class ItemTreeMenuBar extends XULElement {
clearTimeout(this._inactiveTimeout);
this._inactiveTimeout = null;
}
this.hidden = false;
this.removeAttribute("inactive");
});
}
}

File diff suppressed because it is too large Load diff

View file

@ -24,54 +24,120 @@
***** END LICENSE BLOCK *****
-->
<!DOCTYPE html SYSTEM "chrome://zotero/locale/zotero.dtd">
<html
id="citation-dialog"
class="vbox flex"
persist="screenX screenY width height sizemode"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:html="http://www.w3.org/1999/xhtml"
xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
drawintitlebar="true"
drawintitlebar-platforms="mac,win"
resizable="false">
<head>
<title>&zotero.integration.quickFormatDialog.title;</title>
<title data-l10n-id="integration-citationDialog"></title>
<link rel="localization" href="zotero.ftl"/>
<link rel="stylesheet" href="chrome://global/skin/global.css" />
<link rel="stylesheet" href="chrome://zotero/skin/zotero.css" />
<link rel="stylesheet" href="chrome://zotero/skin/overlay.css" />
<link rel="stylesheet" href="chrome://zotero-platform/content/zotero.css" />
<script src="../include.js"/>
<script src="../customElements.js"/>
<script src="citationDialog.js"/>
<script src="../titlebar.js" type="text/javascript"/>
</head>
<body class="vbox flex">
<div id="search-area" class="layout">
<div id="search-row" class="hbox">
<div id="z-icon"></div>
<xul:bubble-input class="flex" placeholder="Type to search, or add selected and open items"/>
<xul:button id="mode-button" class="icon icon-css icon-citation-dialog-library"/>
<xul:button id="settings-button" class="icon icon-css icon-citation-dialog-library"/>
<div id="z-icon-container">
<div id="z-icon"></div>
</div>
<xul:bubble-input id="bubble-input" class="flex" data-arrow-nav="horizontal" data-tabindex="10" tabindex="-1"/>
<progress id="progress" max="100" hidden="true"></progress>
<div id="top-level-btn-group">
<image id="loading-spinner" class="zotero-spinner-16"/>
<button id="accept-button" class="btn-icon icon-citation-dialog-accept" data-l10n-id="integration-citationDialog-btn-accept"/>
<div class="vertical-separator"></div>
<button id="cancel-button" class="btn-icon icon-citation-dialog-cancel" data-l10n-id="integration-citationDialog-btn-cancel"/>
</div>
</div>
<div id="notification" style="display: none;"></div>
</div>
<div id="list-layout" class="layout">
<div id="list-selected-items"></div>
<div id="list-open-items"></div>
<div id="list-cited-items"></div>
<div id="list-found-items"></div>
<div id="list-layout" class="layout" hidden="true">
<div class="divider"></div>
<div id="list-layout-wrapper" class="vbox search-items" data-arrow-nav="vertical" data-multiselectable="true" role="listbox" aria-describedby="item-description"></div>
</div>
<div id="library-layout" class="layout" style="display: none;">
<div id="library-other-items" class="hbox">
<div id="library-selected-items"></div>
<div id="library-open-items"></div>
<div id="library-cited-items"></div>
<div id="library-layout" class="layout" hidden="true">
<div class="secondary-divider"></div>
<div id="library-other-items" class="hbox" data-arrow-nav="horizontal" data-multiselectable="true" role="listbox" aria-orientation="horizontal" aria-describedby="item-description">
<div class="search-items"/>
<span id="library-no-suggested-items-message" data-l10n-id="integration-citationDialog-lib-no-items"/>
</div>
<div id="library-trees">
<div class="divider"></div>
<div id="library-trees" class="hbox">
<div id="collections-tree-container" class="vbox virtualized-table-container">
<div id="zotero-collections-tree" data-tabindex="50"/>
</div>
<div id="item-tree-container" class="hbox virtualized-table-container">
<div id="zotero-items-tree" data-tabindex="60" style="--highlight-color: var(--accent-blue30);"/>
</div>
</div>
</div>
<div id="bottom-area" class="layout">
<div id="bottom-area-wrapper" class="hbox">
<div class="hbox" style="flex:1"></div>
<div id="bottom-btn-group" class="hbox">
<button id="mode-button" class="btn-icon icon-citation-dialog-library" data-l10n-id="integration-citationDialog-btn-mode" tabindex="-1" data-tabindex="71"/>
<button id="settings-button" class="btn-icon icon-citation-dialog-settings" data-l10n-id="integration-citationDialog-btn-settings" tabindex="-1" data-tabindex="72"/>
</div>
</div>
</div>
<div id="popups">
<xul:panel id="itemDetails" focus-target-id="locator">
<div class="vbox popup">
<div class="details-header hbox">
<span class="icon icon-item-type"></span>
<div id="itemDetails-combinedInfo" class="vbox">
<div id="itemTitle"></div>
</div>
</div>
<div class="details" role="group">
<div class="row">
<!--fx128: size="0" forces select have default native style -->
<select name="locator" id="label" class="details-label" size="0"></select>
<input id="locator" class="details-data" aria-labelledby="label" aria-describedby="itemDetails-combinedInfo"/>
</div>
<div class="row">
<label class="details-label" for="prefix" data-l10n-id="integration-citationDialog-details-prefix"></label>
<input id="prefix" class="details-data"/>
</div>
<div class="row">
<label class="details-label" for="suffix" data-l10n-id="integration-citationDialog-details-suffix"></label>
<input id="suffix" class="details-data"/>
</div>
<div id="suppress-author-row" class="row">
<input id="suppress-author" type="checkbox" class="keyboard-clickable"/>
<label for="suppress-author" data-l10n-id="integration-citationDialog-details-suppressAuthor"></label>
</div>
</div>
<div class="buttons">
<button class="remove" data-l10n-id="integration-citationDialog-details-remove"></button>
<button class="show" data-l10n-id="integration-citationDialog-details-showInLibrary"></button>
<!-- use xul button for native primary style -->
<xul:button class="done" default="true" data-l10n-id="integration-citationDialog-details-done"></xul:button>
</div>
</div>
</xul:panel>
<xul:panel id="settings-popup">
<div class="vbox popup">
<div class="title" data-l10n-id="integration-citationDialog-settings-title"></div>
<div class="hbox">
<input id="keepSorted" type="checkbox" class="keyboard-clickable"/>
<label for="keepSorted" data-l10n-id="integration-citationDialog-settings-keepSorted"></label>
</div>
</div>
</xul:panel>
</div>
</body>
</html>
</html>

View file

@ -0,0 +1,270 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2024 Corporation for Digital Scholarship
Vienna, Virginia, USA
http://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 *****
*/
var { Zotero } = ChromeUtils.importESModule("chrome://zotero/content/zotero.mjs");
// Helper functions for citationDialog.js
export class CitationDialogHelpers {
constructor({ doc }) {
this.doc = doc;
}
// shortcut to create a node with specified class and attributes
createNode(type, attributes, className) {
let node = this.doc.createElement(type);
for (let [key, val] of Object.entries(attributes)) {
node.setAttribute(key, val);
}
node.className = className;
return node;
}
// build and return a node with the description (e.g. creator/published/date/etc) of an item
buildItemDescription(item) {
let descriptionWrapper = this.doc.createElement("div");
descriptionWrapper.classList = "description";
let wrapTextInSpan = (text, styles = {}) => {
let span = this.doc.createElement("span");
for (let [style, value] of Object.entries(styles)) {
span.style[style] = value;
}
span.textContent = text;
return span;
};
let addPeriodIfNeeded = (node) => {
if (node.textContent.length && node.textContent[node.textContent.length - 1] !== ".") {
let period = this.doc.createElement("span");
period.textContent = ".";
descriptionWrapper.lastChild.setAttribute("no-comma", true);
descriptionWrapper.appendChild(period);
}
};
if (item.isNote()) {
var date = Zotero.Date.sqlToDate(item.dateModified, true);
date = Zotero.Date.toFriendlyDate(date);
let dateLabel = wrapTextInSpan(date);
var text = item.note;
text = Zotero.Utilities.unescapeHTML(text);
text = text.trim();
text = text.slice(0, 500);
var parts = text.split('\n').map(x => x.trim()).filter(x => x.length);
if (parts[1]) {
dateLabel.textContent += ` ${parts[1]}.`;
}
descriptionWrapper.appendChild(dateLabel);
addPeriodIfNeeded(descriptionWrapper);
return descriptionWrapper;
}
var nodes = [];
// Add a red label to retracted items
if (Zotero.Retractions.isRetracted(item)) {
let label = wrapTextInSpan(Zotero.getString("retraction.banner"), { color: 'var(--accent-red)', 'margin-inline-end': '5px' });
label.setAttribute("no-comma", true);
nodes.push(label);
}
var authorDate = "";
if (item.firstCreator) authorDate = item.firstCreator;
var date = item.getField("date", true, true);
if (date && (date = date.substr(0, 4)) !== "0000") {
authorDate += ` (${parseInt(date)})`;
}
authorDate = authorDate.trim();
if (authorDate) nodes.push(wrapTextInSpan(authorDate));
var publicationTitle = item.getField("publicationTitle", false, true);
if (publicationTitle) {
let label = wrapTextInSpan(publicationTitle, { fontStyle: 'italics' });
nodes.push(label);
}
var volumeIssue = item.getField("volume");
if (item.getField("issue")) volumeIssue += `(${item.getField("issue")})`;
if (volumeIssue) nodes.push(wrapTextInSpan(volumeIssue));
var publisherPlace = [];
if (item.getField("publisher")) publisherPlace.push(item.getField("publisher"));
if (item.getField("place")) publisherPlace.push(item.getField("place"));
if (publisherPlace.length) nodes.push(wrapTextInSpan(publisherPlace.join(": ")));
if (item.getField("pages")) nodes.push(wrapTextInSpan(item.getField("pages")));
if (!nodes.length && item.getField("url")) {
nodes.push(wrapTextInSpan(item.getField("url")));
}
descriptionWrapper.replaceChildren(...nodes);
addPeriodIfNeeded(descriptionWrapper);
// If no info, add a space so the rows are of the same length
if (descriptionWrapper.childElementCount === 0) {
descriptionWrapper.innerText = " ";
}
return descriptionWrapper;
}
// build a container for the item nodes in both layouts
buildItemsSection(id, headerText, isCollapsible, deckLength, dialogMode) {
let section = this.createNode("div", { id }, "section");
let header = this.createNode("div", {}, "header");
let headerSpan = this.createNode("span", {}, "header-label");
let divider = this.createNode("div", {}, "divider");
headerSpan.innerText = headerText;
header.append(headerSpan);
let itemContainer = this.createNode("div", { role: "group", "aria-label": headerText }, "itemsContainer");
section.append(header, itemContainer, divider);
if (isCollapsible) {
headerSpan.id = `header_${id}`;
section.classList.add("expandable");
section.style.setProperty('--deck-length', deckLength);
let buttonGroup = this.createNode("div", { }, "header-btn-group");
header.append(buttonGroup);
let addAllBtn = this.createNode("span", { tabindex: -1, 'data-tabindex': 22, role: "button", "aria-describedby": headerSpan.id }, "add-all keyboard-clickable");
buttonGroup.append(addAllBtn);
if (dialogMode == "list") {
headerSpan.setAttribute("role", "button");
headerSpan.setAttribute("tabindex", -1);
headerSpan.setAttribute("data-tabindex", 21);
headerSpan.classList.add("keyboard-clickable");
}
if (dialogMode == "library") {
itemContainer.setAttribute("tabindex", -1);
itemContainer.setAttribute("data-tabindex", 30);
let collapseSectionBtn = this.createNode("button", { tabindex: -1, 'data-tabindex': 21, "aria-describedby": headerSpan.id }, "btn-icon collapse-section-btn keyboard-clickable");
this.doc.l10n.setAttributes(collapseSectionBtn, "integration-citationDialog-collapse-section");
buttonGroup.prepend(collapseSectionBtn);
}
}
return section;
}
// Extract locator from a string and return an object: { label: string, page: string, onlyLocator: bool}
// to identify the locator and pass that info to the dialog
extractLocator(string) {
// Check for different ways of typing the page locator
const pageRegex = /^(?:,? *(p{1,2})(?:\. *| *)|:)([0-9\-]+) *$/;
let pageLocator = pageRegex.exec(string);
if (pageLocator && pageLocator.length) {
return {
label: "page",
locator: pageLocator[2],
onlyLocator: pageLocator[0].length == string.length,
fullLocatorString: pageLocator[0]
};
}
// Check for a generalized way of typing any other locator in full or short form
// Capture the first word (e.g. "act") followed by optional : or . with any number of whitespaces.
// Then, capture either any text surrounded with " or ' (e.g. book: "Book title")
// or just any word (e.g. l. 10)
const generalRegex = /(\w+)\s*[:.]?\s*(?:(['"])(.*?)\2|(\w+))$/;
let generalLocator = generalRegex.exec(string);
if (generalLocator?.length) {
let typedLocatorLabel = generalLocator[1].toLowerCase();
let existingLocators = Zotero.Cite.labels;
for (let existingLocator of existingLocators) {
let locatorLabel = Zotero.Cite.getLocatorString(existingLocator).toLowerCase();
// strip short locator labels of punctuation, so that e.g. for line locator, "l 10" is still counted as "l. 10"
let locatorLabelShort = Zotero.Cite.getLocatorString(existingLocator, "short").replace(/[.,;:{}()]/g, "").toLowerCase();
if (typedLocatorLabel == locatorLabel || typedLocatorLabel == locatorLabelShort) {
// fetch either text in quotes or the last word without quotes as locator value
let locatorValue = generalLocator[3] || generalLocator[4];
return {
label: existingLocator,
locator: locatorValue,
onlyLocator: generalLocator[0].length == string.length,
fullLocatorString: generalLocator[0]
};
}
}
}
return null;
}
// calculate the height of the #search-row accounting for margins and border
getSearchRowHeight() {
let searchRow = this.doc.querySelector("#search-row");
let height = searchRow.getBoundingClientRect().height;
let win = this.doc.defaultView;
let style = win.getComputedStyle(searchRow);
let margins = parseInt(style.marginTop) + parseInt(style.marginBottom);
let border = 1;
return height + margins + border;
}
buildBubbleString({ citationItem, zoteroItem }) {
// Creator
var title;
var str = zoteroItem.getField("firstCreator");
// Title, if no creator (getDisplayTitle in order to get case, e-mail, statute which don't have a title field)
title = zoteroItem.getDisplayTitle();
title = title.substr(0, 32) + (title.length > 32 ? "…" : "");
if (!str && title) {
str = Zotero.getString("punctuation.openingQMark") + title + Zotero.getString("punctuation.closingQMark");
}
else if (!str) {
str = Zotero.getString("integration-citationDialog-bubble-empty");
}
// Date
var date = zoteroItem.getField("date", true, true);
if (date && (date = date.substr(0, 4)) !== "0000") {
str += ", " + parseInt(date);
}
// Locator
if (citationItem.locator) {
// Try to fetch the short form of the locator label. E.g. "p." for "page"
// If there is no locator label, default to "page" for now
let label = (Zotero.Cite.getLocatorString(citationItem.label || 'page', 'short') || '').toLocaleLowerCase();
str += `, ${label} ${citationItem.locator}`;
}
// Prefix
if (citationItem.prefix && Zotero.CiteProc.CSL.ENDSWITH_ROMANESQUE_REGEXP) {
let prefix = citationItem.prefix.substr(0, 10) + (citationItem.prefix.length > 10 ? "…" : "");
str = prefix
+ (Zotero.CiteProc.CSL.ENDSWITH_ROMANESQUE_REGEXP.test(citationItem.prefix) ? " " : "")
+ str;
}
// Suffix
if (citationItem.suffix && Zotero.CiteProc.CSL.STARTSWITH_ROMANESQUE_REGEXP) {
let suffix = citationItem.suffix.substr(0, 10) + (citationItem.suffix.length > 10 ? "…" : "");
str += (Zotero.CiteProc.CSL.STARTSWITH_ROMANESQUE_REGEXP.test(citationItem.suffix) ? " " : "") + suffix;
}
return str;
}
}

View file

@ -0,0 +1,314 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2024 Corporation for Digital Scholarship
Vienna, Virginia, USA
http://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 *****
*/
var { Zotero } = ChromeUtils.importESModule("chrome://zotero/content/zotero.mjs");
// Keyboard handler for citationDialog
export class CitationDialogKeyboardHandler {
constructor({ doc }) {
this.doc = doc;
this._multiselectStart = null;
}
_id(id) {
return this.doc.getElementById(id);
}
// main keydown listener that will call more specific handlers
// until the event is handled
handleKeydown(event) {
let handled = this._handleTopLevelKeydown(event);
if (!handled) {
handled = this._handleKeyboardNavigation(event);
}
}
// capturing keydown listener to handle keypresses regardless of if they are handled by
// lower-level components
captureKeydown(event) {
let cmdOrCtrl = Zotero.isMac ? event.metaKey : event.ctrlKey;
// Cmd/Ctrl-Enter will always accept the dialog regardless of the target (unless within a panel)
if (event.key == "Enter" && cmdOrCtrl && !event.target.closest("panel")) {
this.doc.dispatchEvent(new CustomEvent("dialog-accepted"));
event.stopPropagation();
event.preventDefault();
return;
}
// arrowUp from the top-most row of the itemTree will focus suggested items or bubble-input
let noModifiers = !['ctrlKey', 'metaKey', 'shiftKey', 'altKey'].some(key => event[key]);
if (this._id("zotero-items-tree").contains(event.target) && event.key == "ArrowUp" && noModifiers) {
let focusedRow = this._id("zotero-items-tree").querySelector(".row.focused");
if (!focusedRow) return;
// fetch index from the row's id (e.g. item-tree-citationDialog-row-0)
let rowIndex = focusedRow.id.split("-")[4];
if (rowIndex !== "0") return;
// if there are suggested items, focus them
if (this._id("library-other-items").querySelector(".item")) {
let current = this.doc.querySelector(".selected.current");
if (current) {
current.focus();
}
else {
this._navigateGroup({ group: this._id("library-other-items"), current: null, forward: true, shouldSelect: true, shouldFocus: true, multiSelect: false });
}
}
// otherwise, focus bubble-input
else {
this._id("bubble-input").focus();
}
event.stopPropagation();
event.preventDefault();
}
}
_handleTopLevelKeydown(event) {
let handled = false;
let tgt = event.target;
let isKeyboardClickable = tgt.classList.contains("keyboard-clickable") || tgt.tagName.includes("button");
// Space/Enter will click on a button or keyboard-clickable components
if (["Enter", " "].includes(event.key) && isKeyboardClickable) {
tgt.click();
handled = true;
}
// Unhandled Enter will accept the existing dialog's state
else if (event.key == "Enter" && !tgt.closest("panel")) {
handled = true;
this.doc.dispatchEvent(new CustomEvent("dialog-accepted"));
}
// Unhandled Escape will close the dialog
else if (event.key == "Escape") {
handled = true;
this.doc.dispatchEvent(new CustomEvent("dialog-cancelled"));
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
return handled;
}
_handleKeyboardNavigation(event) {
let handled = false;
let noModifiers = !['ctrlKey', 'metaKey', 'shiftKey', 'altKey'].some(key => event[key]);
let onlyShiftModifierPossible = !['ctrlKey', 'metaKey', 'altKey'].some(key => event[key]);
if (event.key == "Tab") {
handled = this._tabToGroup({ forward: !event.shiftKey });
}
// arrow down from bubble input in library mode will focus the current item, if any
// or navigate into the suggested items group. If the suggested items are empty, focus items table below
else if (!this._id("library-layout").hidden && event.key == "ArrowDown" && this._id("bubble-input").contains(event.target) && noModifiers) {
let group = this.doc.querySelector("#library-layout [data-arrow-nav]");
let current = group.querySelector(".selected.current[tabindex]");
if (current) {
current.focus();
}
else if (group.querySelector(".item")) {
this._navigateGroup({ group, current: null, forward: true, shouldSelect: true, shouldFocus: true, multiSelect: false });
}
else if (this._id("zotero-items-tree").querySelector(".row")) {
this._id("zotero-items-tree").querySelector("[tabindex]").focus();
}
handled = true;
}
// arrow down from suggested items in library mode will focus items table
else if (!this._id("library-layout").hidden && event.key == "ArrowDown" && event.target.closest(".itemsContainer") && noModifiers && this._id("zotero-items-tree").querySelector(".row")) {
this._id("zotero-items-tree").querySelector("[tabindex]").focus();
}
// arrow up/down from bubble-input in list mode will move selection in the items list
else if (!this._id("list-layout").hidden && (event.key == "ArrowDown" || event.key == "ArrowUp") && this._id("bubble-input").contains(event.target) && onlyShiftModifierPossible) {
let group = this.doc.querySelector("#list-layout [data-arrow-nav]");
let current = group.querySelector(".current");
let firstRow = group.querySelector('[data-arrow-nav-enabled="true"][tabindex]');
// on arrowUp from the first row, clear selection
if (current === firstRow && event.key == "ArrowUp" && !event.shiftKey) {
this._selectItems(null);
firstRow.classList.remove("current");
group.scrollTo(0, 0);
this._multiselectStart = null;
}
else if (current || event.key == "ArrowDown") {
// Arrow down from input will just change the selected item without moving focus
// Arrow down from a bubble in the lowest row will move focus
let shouldFocus = event.target.classList.contains("bubble");
let multiSelect = event.shiftKey;
this._navigateGroup({ group, current, forward: event.key == "ArrowDown", shouldSelect: true, shouldFocus, multiSelect });
}
handled = true;
}
// arrowUp from the first item will refocus bubbleInput
else if (event.key == "ArrowUp" && this._shouldRefocusBubbleInputOnArrowUp() && noModifiers) {
this._id("bubble-input").refocusInput();
handled = true;
}
// handle focus and selection movement within bubble-input and item groups
else if (event.key.includes("Arrow") && onlyShiftModifierPossible) {
let arrowDirection = event.target.closest("[data-arrow-nav]")?.getAttribute("data-arrow-nav");
if (!arrowDirection) return false;
let multiSelect = !!event.target.closest("[data-multiselectable]") && event.shiftKey;
let current = this.doc.activeElement;
let group = current.closest("[data-arrow-nav]");
if (arrowDirection == "horizontal") {
if (!(event.key === Zotero.arrowNextKey || event.key === Zotero.arrowPreviousKey)) return false;
// selections only happens with items
let shouldSelect = event.target.closest(".itemsContainer");
handled = this._navigateGroup({ group, current, forward: event.key == Zotero.arrowNextKey, shouldSelect, shouldFocus: true, multiSelect });
}
if (arrowDirection == "vertical") {
if (!(event.key == "ArrowUp" || event.key === "ArrowDown")) return false;
handled = this._navigateGroup({ group, current, forward: event.key === "ArrowDown", shouldSelect: true, shouldFocus: true, multiSelect });
}
}
if (handled) {
event.stopPropagation();
event.preventDefault();
}
return handled;
}
// tab/shift-tab between the main components
_tabToGroup({ forward = true, startingTabIndex = null }) {
let currentTabIndex = startingTabIndex;
if (currentTabIndex === null) {
let active = this.doc.activeElement;
let tabindexNode = active.closest("[data-tabindex]");
if (!tabindexNode) return false;
currentTabIndex = parseInt(tabindexNode.dataset.tabindex);
}
let tabIndexedNodes = [...this.doc.querySelectorAll("[data-tabindex]")];
// filter out invisible, not focusable, or disabled nodes
tabIndexedNodes = tabIndexedNodes.filter(node => (node.getAttribute("tabindex") || node.querySelector("[tabindex]")) && !node.disabled && node.getBoundingClientRect().width);
tabIndexedNodes = tabIndexedNodes.sort((a, b) => {
if (a.dataset.tabindex == b.dataset.tabindex) {
// make sure that if there's a "current" node, it will have priority
let aSelected = a.classList.contains("current") ? -1 : 0;
let bSelected = b.classList.contains("current") ? -1 : 0;
return aSelected - bSelected;
}
return parseInt(a.dataset.tabindex) - parseInt(b.dataset.tabindex);
});
// When going backwards, reverse the array after sorting
if (!forward) {
tabIndexedNodes.reverse();
}
let nodeToFocus;
for (let node of tabIndexedNodes) {
let tabIndex = parseInt(node.dataset.tabindex);
if ((forward && tabIndex > currentTabIndex) || (!forward && tabIndex < currentTabIndex)) {
nodeToFocus = node;
break;
}
}
// If no node was found, wrap around to the first/last node
if (!nodeToFocus && startingTabIndex === null) {
nodeToFocus = tabIndexedNodes[0];
}
// if node to focus is a part of arrow-navigation group (e.g., suggested items)
// and we are not re-focusing a previously selected item,
// navigate into that group to also have the item marked as selected.
if (nodeToFocus.dataset.arrowNavEnabled && !nodeToFocus.classList.contains("current")) {
let group = nodeToFocus.closest("[data-arrow-nav]");
this._navigateGroup({ group, current: null, forward: true, shouldSelect: true, shouldFocus: true, multiSelect: false });
}
else if (nodeToFocus.getAttribute("tabindex")) {
nodeToFocus.focus();
}
else {
nodeToFocus.querySelector("[tabindex]")?.focus();
}
return nodeToFocus;
}
// Navigate the group by moving selection or focus between nodes in a group
_navigateGroup({ group, current, forward, multiSelect, shouldFocus, shouldSelect }) {
// navigable nodes have to be marked with data-arrow-nav-enabled
let allFocusableWithinGroup = [...group.querySelectorAll("[tabindex][data-arrow-nav-enabled]")];
let nextFocusableIndex = 0;
for (let i = 0; i < allFocusableWithinGroup.length; i++) {
if (allFocusableWithinGroup[i] == current) {
nextFocusableIndex = forward ? (i + 1) : (i - 1);
break;
}
}
if (nextFocusableIndex < 0 || nextFocusableIndex >= allFocusableWithinGroup.length) return false;
let nextNode = allFocusableWithinGroup[nextFocusableIndex];
// multiselect only allowed within the same group: no overlap between selected and opened items
// mainly to avoid questionable handling of multi-selected collapsed deck of item cards in library mode
if (multiSelect && current && current.parentNode !== nextNode.parentNode) return current;
if (shouldFocus) {
nextNode.focus();
}
if (!shouldSelect) return nextNode;
current?.classList.remove("current");
nextNode.classList.add("current");
// if the node is not being focused in list mode, make sure we scroll to it so it is visible
if (!shouldFocus) {
let wrapperRect = this._id("list-layout-wrapper").getBoundingClientRect();
let nodeRect = nextNode.getBoundingClientRect();
if (nodeRect.bottom > wrapperRect.bottom || nodeRect.top < wrapperRect.top) {
nextNode.scrollIntoView();
}
}
if (multiSelect) {
// on arrow keypress while holding shift, move focus and also perform multiselect
if (this._multiselectStart === null || !this.doc.contains(this._multiselectStart)) {
this._multiselectStart = current || nextNode;
}
this._selectItems(this._multiselectStart, nextNode);
}
else {
// on arrow keypress without shift, clear multiselect starting point
this._multiselectStart = null;
this._selectItems(nextNode);
}
return nextNode;
}
_shouldRefocusBubbleInputOnArrowUp() {
if (!this._id("library-layout").hidden) {
return this._id("library-other-items").contains(this.doc.activeElement);
}
if (!this._id("list-layout").hidden) {
return this.doc.activeElement == this.doc.querySelector(".item");
}
return false;
}
_selectItems(startNode, endNode) {
this.doc.dispatchEvent(new CustomEvent("select-items", {
bubbles: true,
detail: {
startNode, endNode
}
}));
}
}

View file

@ -0,0 +1,248 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2024 Corporation for Digital Scholarship
Vienna, Virginia, USA
http://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 *****
*/
var { Zotero } = ChromeUtils.importESModule("chrome://zotero/content/zotero.mjs");
// Handle the logic of opening popups and saving/discarding edits to the citation items
export class CitationDialogPopupsHandler {
constructor({ doc }) {
this.doc = doc;
this.item = null;
this.citationItem = null;
this.discardItemDetailsEdits = false;
this.itemDetailsWhenOpened = {};
this.itemDetailsTimeOpened = null;
this.setUpListeners();
}
setUpListeners() {
this.doc.addEventListener("popupshown", (event) => {
// make sure overlay doesn't appear on tooltips and etc.
if (event.target.tagName !== "xul:panel") return;
// if focus is not in the panel tab into it
if (!event.target.contains(this.doc.activeElement)) {
Services.focus.moveFocus(this.doc.defaultView, event.target, Services.focus.MOVEFOCUS_FORWARD, 0);
}
});
// Update bubbles in citation dialog as one makes edits
this._getNode("#itemDetails").addEventListener("input", this.handleItemDetailsChange.bind(this));
this._getNode("#itemDetails").addEventListener("popupshown", this.handleItemDetailsShown.bind(this));
this._getNode("#itemDetails").addEventListener("popuphidden", this.handleItemDetailsClosure.bind(this));
this._getNode("#itemDetails").addEventListener("popuphiding", this.handleItemDetailsClosing.bind(this));
// Item details Remove btn
this._getNode("#itemDetails .remove").addEventListener("click", (_) => {
let event = new CustomEvent("delete-item", {
bubbles: true,
detail: {
dialogReferenceID: this.dialogReferenceID
}
});
this.doc.dispatchEvent(event);
this.discardItemDetailsEdits = true;
this._getNode("#itemDetails").hidePopup();
});
// Item details Show in Library btn
this._getNode("#itemDetails .show").addEventListener("click", (_) => {
this.discardItemDetailsEdits = true;
this._getNode("#itemDetails").hidePopup();
Zotero.Utilities.Internal.showInLibrary(this.item.id);
});
this._getNode("#itemDetails .done").addEventListener("click", (_) => {
this._getNode("#itemDetails").hidePopup();
});
// Capture the keydown on the document to be able to handle Escape
// when a popup is opened to discard edits
this.doc.addEventListener("keydown", (event) => {
if (this._getNode("#itemDetails").state !== "open") return;
this.captureItemDetailsKeyDown(event);
}, true);
// Handle remaining keypress events with a usual bubbling listener
this._getNode("#itemDetails").addEventListener("keypress", this.handleItemDetailsKeypress.bind(this));
}
openItemDetails(dialogReferenceID, item, citationItem, itemDescription) {
this.item = item;
this.citationItem = citationItem;
this.dialogReferenceID = dialogReferenceID;
// record initial properties when popup is opened to be able to discard edits on Escape
this.itemDetailsWhenOpened = {
label: citationItem.label,
locator: citationItem.locator,
prefix: citationItem.prefix,
suffix: citationItem.suffix,
suppressAuthor: citationItem["suppress-author"]
};
let bubble = this._getNode(`[dialogReferenceID='${dialogReferenceID}']`);
let bubbleRect = bubble.getBoundingClientRect();
let popup = this._getNode("#itemDetails");
popup.openPopup(bubble, "after_start", 0, 4, false, false, null);
// popup should be cenetered on the bubble
popup.style.left = `${Math.max(10, bubbleRect.left + (bubbleRect.width / 2) - (popup.offsetWidth / 2))}px`;
popup.style.top = `${bubbleRect.bottom + 10}px`;
// add locator labels if they don't exist yet
if (this._getNode("#label").childElementCount == 0) {
let locators = Zotero.Cite.labels;
for (var locator of locators) {
let locatorLabel = Zotero.Cite.getLocatorString(locator);
var option = this.doc.createElement("option");
option.value = locator;
option.label = locatorLabel;
this._getNode("#label").appendChild(option);
}
}
this._getNode("#itemDetails .show").hidden = !this.item.id;
// Add header and fill inputs with their values
let description = itemDescription;
this._getNode("#itemDetails").querySelector(".description")?.remove();
this._getNode("#itemTitle").textContent = this.item.getDisplayTitle();
this._getNode("#itemTitle").after(description);
let dataTypeLabel = this.item.getItemTypeIconName(true);
this._getNode("#itemDetails").querySelector(".icon").setAttribute("data-item-type", dataTypeLabel);
this._getNode("#label").value = this.citationItem.label || "page";
this._getNode("#locator").value = this.citationItem.locator || "";
this._getNode("#prefix").value = this.citationItem.prefix || "";
this._getNode("#suffix").value = this.citationItem.suffix || "";
this._getNode("#suppress-author").checked = !!this.citationItem["suppress-author"];
bubble.classList.add("showingDetails");
this.itemDetailsTimeOpened = (new Date()).getTime();
}
// do not close the popup within 300ms of opening to account for potential double clicking
// on the bubble to open the popup
handleItemDetailsClosing(event) {
if ((new Date()).getTime() - this.itemDetailsTimeOpened < 300) {
event.preventDefault();
// return focus to the locator
this.doc.defaultView.setTimeout(() => {
this._getNode("#locator").focus();
}, 10);
}
}
handleItemDetailsShown(event) {
event.stopPropagation();
this._getNode("#locator").focus();
}
// When item details popup is closed, sync it's data to citationItems
handleItemDetailsClosure() {
let bubble = this._getNode(`[dialogReferenceID='${this.dialogReferenceID}']`);
if (!bubble) return;
bubble.classList.remove("showingDetails");
// Restore properties to what they were when popup opened
if (this.discardItemDetailsEdits) {
this.discardItemDetailsEdits = false;
this.citationItem.label = this.itemDetailsWhenOpened.label;
this.citationItem.locator = this.itemDetailsWhenOpened.locator;
this.citationItem.prefix = this.itemDetailsWhenOpened.prefix;
this.citationItem.suffix = this.itemDetailsWhenOpened.suffix;
this.citationItem["suppress-author"] = this.itemDetailsWhenOpened.suppressAuthor;
this.itemDetailsWhenOpened = {};
this.notifyCitationDialogOfChange();
}
}
captureItemDetailsKeyDown(event) {
if (event.key == "Escape") {
this.discardItemDetailsEdits = true;
event.stopPropagation();
event.preventDefault();
}
}
handleItemDetailsKeypress(event) {
// Enter on a an input will save changes, hide the popup and refocus last input
if (event.key == "Enter" && ["input"].includes(event.target.tagName) && !event.target.getAttribute("type")) {
this._getNode("#itemDetails").setAttribute("refocus-input", true);
this._getNode("#itemDetails").hidePopup();
}
}
// Update item details and notify citation dialog about changes
handleItemDetailsChange() {
this.citationItem.label = this._getNode("#locator").value ? this._getNode("#label").value : null;
this.citationItem.locator = this._getNode("#locator").value;
this.citationItem.prefix = this._getNode("#prefix").value;
this.citationItem.suffix = this._getNode("#suffix").value;
this.citationItem["suppress-author"] = this._getNode("#suppress-author").checked;
this.notifyCitationDialogOfChange();
}
// Tell citation dialog that the item has been updated to refresh the bubble
notifyCitationDialogOfChange() {
let event = new CustomEvent("item-details-updated", {
bubbles: true,
detail: {
dialogReferenceID: this.dialogReferenceID
}
});
this.doc.dispatchEvent(event);
}
showRetractedWarning(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 disableWarningCheckbox = { 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'), disableWarningCheckbox);
// Cancel
if (result == 1) {
return false;
}
// Show in library
if (result == 2) {
Zotero.Utilities.Internal.showInLibrary(item.id);
return false;
}
// Checked "Do not warn about this item"
if (disableWarningCheckbox.value) {
Zotero.Retractions.disableCitationWarningsForItem(item);
}
return true;
}
_getNode(selector) {
return this.doc.querySelector(selector);
}
}

View file

@ -0,0 +1,341 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2024 Corporation for Digital Scholarship
Vienna, Virginia, USA
http://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 *****
*/
var { Zotero } = ChromeUtils.importESModule("chrome://zotero/content/zotero.mjs");
const MIN_QUERY_LENGTH = 2;
// Contains all search-related logic. Last search results are stored in SearchHandler.results
// as the following object: { found: [], cited: [], open: [], selected: []}.
// Can be refreshed via SearchHandler.refresh or refreshDebounced.
export class CitationDialogSearchHandler {
constructor({ isCitingNotes, io }) {
this.isCitingNotes = isCitingNotes;
this.io = io;
this.searchValue = "";
this.results = {
found: [],
open: [],
cited: [],
selected: [],
};
this.minQueryLengthEnforced = false;
this.searching = false;
this.searchResultIDs = [];
this._nonLibraryItems = {};
}
setSearchValue(str, enforceMinQueryLength) {
this.minQueryLengthEnforced = !!enforceMinQueryLength;
this.searchValue = this.cleanSearchQuery(str);
}
// get item by a given id from the list of search results
getItem(id) {
// if id is in form "cited:" or ".../...", it must be a cited item with cslItemID
if (typeof id == "string" && (id.includes("cited") || id.includes("/"))) {
return this.results.cited.find(item => item.cslItemID === id);
}
// otherwise, it will be a item with an ordinary id from the database (still potentially cited)
for (let key of ['selected', 'open', 'found', 'cited']) {
let item = this.results[key].find(item => item.id === parseInt(id));
if (item) return item;
}
return null;
}
// how many selected items there are without applying the filter
allSelectedItemsCount() {
if (this._nonLibraryItems.selected !== undefined) {
return this._nonLibraryItems.selected.length;
}
return this._getSelectedLibraryItems().length;
}
// Return results in a more helpful formatfor rendering.
// Results are returned as an array of { key, group, isLibrary } objects,
// where key is selected/open/cited/{libraryID}, and group is the respective list of items.
// Groups are sorted in the order they will be rendered:
// Selected, Opened, Cited go first, followed by found library item groups ordered
// by the number of results in each library.
// Items/notes in the libraries group are sorted via _createItemsSort/_createNotesSort comparators.
// Takes citedItems as a parameter to filter them out from Selected, Opened and Cited groups.
getOrderedSearchResultGroups(citedItems = []) {
let removeItemsIncludedInCitation = (items) => {
let citedItemsIDs = new Set(citedItems.map(item => item.cslItemID || item.id));
return items.filter(i => !citedItemsIDs.has(i.cslItemID ? i.cslItemID : i.id));
};
let result = [];
// selected/open/cited go first
for (let groupKey of ["selected", "open", "cited"]) {
let groupItems = this.results[groupKey];
// in selected and opened items, do not display items already in the citation
if (groupKey == "selected" || groupKey == "open") {
groupItems = removeItemsIncludedInCitation(groupItems);
}
if (groupItems.length) {
result.push({ key: groupKey, group: groupItems });
}
}
// library items go after
let libraryItems = Object.values(this.results.found.reduce((acc, item) => {
if (!acc[item.libraryID]) {
acc[item.libraryID] = { key: item.libraryID, group: [], isLibrary: true };
}
acc[item.libraryID].group.push(item);
return acc;
}, {}));
// sort actual items or notes
let itemComparator = this.isCitingNotes ? this._createNotesSort() : this._createItemsSort();
libraryItems.forEach((library) => {
library.group.sort(itemComparator);
});
// sort libraries by the number of items
libraryItems.sort((a, b) => b.group.length - a.group.length);
result.push(...libraryItems);
return result;
}
// Refresh selected/opened/cited items.
// These items are searched for separately from actual library matches
// because it is much faster for large libraries, so we don't have to wait
// for the library search to complete to show these results.
async refreshNonLibraryItems() {
// Use cached selected/cited/open items if available to not
// re-fetch them every time
if (!Object.keys(this._nonLibraryItems).length) {
this._nonLibraryItems = {
open: this._getReaderOpenItems(),
cited: await this._getCitedItems(),
selected: this._getSelectedLibraryItems(),
};
}
let { open, cited, selected } = this._nonLibraryItems;
// apply filtering to item groups
this.results.open = this.searchValue ? this._filterNonMatchingItems(open) : open;
this.results.selected = this.searchValue ? this._filterNonMatchingItems(selected) : selected;
// clear matching library items to make sure items stale results are not showing
this.results.found = [];
// if "ibid" is typed, return all cited items
if (this.searchValue.toLowerCase() === Zotero.getString("integration.ibid").toLowerCase()) {
this.results.cited = cited;
}
else {
this.results.cited = this.searchValue ? this._filterNonMatchingItems(cited) : [];
}
// Ensure duplicates across groups before library items are found
this._deduplicate();
}
// Refresh the list of matching library items for the list mode.
async refreshLibraryItems() {
if (!this.searchValue && !this.isCitingNotes) {
this.results.found = [];
return;
}
this.results.found = await this._getMatchingLibraryItems();
// Ensure duplicates across groups after library items are found
this._deduplicate();
}
// clear selected/open/cited items cache to re-fetch those items
// after they may have changed
clearNonLibraryItemsCache() {
this._nonLibraryItems = {};
}
cleanSearchQuery(str) {
str = str.replace(/ (?:&|and) /g, " ", "g").replace(/^,/, '');
str = this._cleanYear(str);
// If the query is very short, treat it as empty
if (this.minQueryLengthEnforced && str.trim().length < MIN_QUERY_LENGTH) {
str = "";
}
return str;
}
// make sure that each item appears only in one group.
// Items that are selected are removed from opened and cited.
// Items that are opened are removed from cited.
// Items that are selected or opened or cited are removed from library results.
_deduplicate() {
let selectedIDs = new Set(this.results.selected.map(item => item.id));
let openIDs = new Set(this.results.open.map(item => item.id));
let citedIDs = new Set(this.results.cited.filter(item => item.id).map(item => item.id));
this.results.open = this.results.open.filter(item => !selectedIDs.has(item.id));
this.results.cited = this.results.cited.filter(item => !selectedIDs.has(item.id) && !openIDs.has(item.id));
this.results.found = this.results.found.filter(item => !selectedIDs.has(item.id) && !openIDs.has(item.id) && !citedIDs.has(item.id));
}
// Run the actual search query and find all items matching query across all libraries
async _getMatchingLibraryItems() {
var s = new Zotero.Search();
Zotero.Feeds.getAll().forEach(feed => s.addCondition("libraryID", "isNot", feed.libraryID));
if (this.io.filterLibraryIDs) {
this.io.filterLibraryIDs.forEach(id => s.addCondition("libraryID", "is", id));
}
let realInputRegex = /[\w\u007F-\uFFFF]/;
if (this.isCitingNotes) {
s.addCondition("quicksearch-titleCreatorYearNote", "contains", this.searchValue);
}
else if (realInputRegex.test(this.searchValue)) {
s.addCondition("quicksearch-titleCreatorYear", "contains", this.searchValue);
s.addCondition("itemType", "isNot", "attachment");
}
let searchResultIDs = await s.search();
// Search results might be in an unloaded library, so get items asynchronously and load necessary data
var items = await Zotero.Items.getAsync(searchResultIDs);
await Zotero.Items.loadDataTypes(items);
return items;
}
async _getCitedItems() {
if (this.isCitingNotes) return [];
// Fetch all cited items in the document, not just items currently in the dialog
let citedItems = await this.io.getItems();
return citedItems;
}
_getReaderOpenItems() {
if (this.isCitingNotes) return [];
let win = Zotero.getMainWindow();
let tabs = win.Zotero_Tabs.getState();
let itemIDs = tabs.filter(t => t.type === 'reader').sort((a, b) => {
// Sort selected tab first
if (a.selected) return -1;
else if (b.selected) return 1;
// Then in reverse chronological select order
else if (a.timeUnselected && b.timeUnselected) return b.timeUnselected - a.timeUnselected;
// Then in reverse order for tabs that never got loaded in this session
else if (a.timeUnselected) return -1;
return 1;
}).map(t => t.data.itemID);
if (!itemIDs.length) return [];
let items = itemIDs.map((itemID) => {
let item = Zotero.Items.get(itemID);
if (item && item.parentItemID) {
itemID = item.parentItemID;
}
return Zotero.Cite.getItem(itemID);
});
return items;
}
_getSelectedLibraryItems() {
if (this.isCitingNotes) {
return Zotero.getActiveZoteroPane()?.getSelectedItems().filter(i => i.isNote()) || [];
}
return Zotero.getActiveZoteroPane()?.getSelectedItems().filter(i => i.isRegularItem()) || [];
}
_filterNonMatchingItems(items) {
let matchedItems = new Set();
let splits = Zotero.Fulltext.semanticSplitter(this.searchValue);
for (let item of items) {
// Generate a string to search for each item
let itemStr = item.getCreators()
.map(creator => creator.firstName + " " + creator.lastName)
.concat([item.getField("title"), item.getField("date", true, true).substr(0, 4)])
.join(" ");
// See if words match
for (let split of splits) {
if (itemStr.toLowerCase().includes(split)) matchedItems.add(item);
}
}
return Array.from(matchedItems);
}
// Generate sort function for items
_createItemsSort() {
let searchString = (this.searchValue).toLowerCase();
let searchParts = Zotero.SearchConditions.parseSearchString(searchString);
var collation = Zotero.getLocaleCollation();
return ((a, b) => {
var firstCreatorA = a.firstCreator, firstCreatorB = b.firstCreator;
// Favor left-bound name matches (e.g., "Baum" < "Appelbaum"),
// using last name of first author
if (firstCreatorA && firstCreatorB) {
for (let part of searchParts) {
let caStartsWith = firstCreatorA.toLowerCase().startsWith(part.text);
let cbStartsWith = firstCreatorB.toLowerCase().startsWith(part.text);
if (caStartsWith && !cbStartsWith) {
return -1;
}
else if (!caStartsWith && cbStartsWith) {
return 1;
}
}
}
// Sort by last name of first author
if (firstCreatorA !== "" && firstCreatorB === "") {
return -1;
}
else if (firstCreatorA === "" && firstCreatorB !== "") {
return 1;
}
else if (firstCreatorA) {
return collation.compareString(1, firstCreatorA, firstCreatorB);
}
// Sort by date
var yearA = a.getField("date", true, true).substr(0, 4),
yearB = b.getField("date", true, true).substr(0, 4);
return yearA - yearB;
});
}
// Generate sort function for notes
_createNotesSort() {
var collation = Zotero.getLocaleCollation();
return (a, b) => {
return collation.compareString(
1, b.getField('dateModified'), a.getField('dateModified')
);
};
}
_cleanYear(string) {
let yearRegex = /,? *([0-9]+(?: *[-] *[0-9]+)?) *(B[. ]*C[. ]*(?:E[. ]*)?|A[. ]*D[. ]*|C[. ]*E[. ]*)?$/i;
let maybeYear = yearRegex.exec(string);
if (!maybeYear) return string;
let year = parseInt(maybeYear[1]);
let stringNoYear = string.substr(0, maybeYear.index) + string.substring(maybeYear.index + maybeYear[0].length);
if (!year) return stringNoYear;
return stringNoYear + " " + year;
}
}

View file

@ -23,25 +23,19 @@
***** END LICENSE BLOCK *****
*/
Services.scriptloader.loadSubScript("chrome://zotero/content/titlebar.js", this);
var Zotero_ProgressBar = new function () {
var initialized, io;
/**
* Pre-initialization, when the dialog has loaded but has not yet appeared
*/
this.onDOMContentLoaded = function(event) {
if(event.target === document) {
initialized = true;
io = window.arguments[0].wrappedJSObject;
let io = window.arguments[0].wrappedJSObject;
if (io.onLoad) {
io.onLoad(_onProgress);
}
if (io.isNote) {
document.documentElement.classList.add('note-dialog');
}
// Same height that citation dialog would occupy while loading
window.resizeTo(800, 42);
}
};
@ -64,51 +58,13 @@ var Zotero_ProgressBar = new function () {
* Called when progress changes
*/
function _onProgress(percent) {
var meter = document.querySelector(".citation-dialog.progress-meter");
var meter = document.getElementById("progress");
if(percent === null) {
meter.removeAttribute('value');
} else {
meter.value = Math.round(percent);
}
}
/**
* Resizes windows
* @constructor
*/
var Resizer = function(panel, targetWidth, targetHeight, pixelsPerStep, stepsPerSecond) {
this.panel = panel;
this.curWidth = panel.clientWidth;
this.curHeight = panel.clientHeight;
this.difX = (targetWidth ? targetWidth - this.curWidth : 0);
this.difY = (targetHeight ? targetHeight - this.curHeight : 0);
this.step = 0;
this.steps = Math.ceil(Math.max(Math.abs(this.difX), Math.abs(this.difY))/pixelsPerStep);
this.timeout = (1000/stepsPerSecond);
var me = this;
this._animateCallback = function() { me.animate() };
};
/**
* Performs a step of the animation
*/
Resizer.prototype.animate = function() {
if(this.stopped) return;
this.step++;
this.panel.sizeTo(this.curWidth+Math.round(this.step*this.difX/this.steps),
this.curHeight+Math.round(this.step*this.difY/this.steps));
if(this.step !== this.steps) {
window.setTimeout(this._animateCallback, this.timeout);
}
};
/**
* Halts resizing
*/
Resizer.prototype.stop = function() {
this.stopped = true;
};
}
window.addEventListener("DOMContentLoaded", Zotero_ProgressBar.onDOMContentLoaded, false);

View file

@ -23,27 +23,30 @@
***** END LICENSE BLOCK *****
-->
<?xml-stylesheet href="chrome://global/skin/" type="text/css"?>
<?xml-stylesheet href="chrome://zotero/skin/zotero.css" type="text/css"?>
<?xml-stylesheet href="chrome://zotero-platform/content/zotero.css" type="text/css"?>
<?xml-stylesheet href="chrome://zotero/skin/integration.css" type="text/css"?>
<?xml-stylesheet href="chrome://zotero-platform/content/integration.css" type="text/css"?>
<!DOCTYPE window SYSTEM "chrome://zotero/locale/zotero.dtd">
<window
<html
id="progress-bar"
class="citation-dialog progress-bar"
orient="vertical"
title="&zotero.progress.title;"
no-titlebar-icon="true"
xmlns:html="http://www.w3.org/1999/xhtml"
xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
persist="screenX screenY">
<head>
<link rel="stylesheet" href="chrome://zotero-platform/content/zotero.css" />
<script src="../include.js"/>
<script src="progressBar.js" type="text/javascript"/>
<script src="../titlebar.js" type="text/javascript"/>
</head>
<script src="../include.js"/>
<script src="progressBar.js" type="text/javascript"/>
<box orient="horizontal" class="citation-dialog entry">
<html:progress class="citation-dialog progress-meter downloadProgress" max="100"/>
</box>
</window>
<body class="vbox flex">
<div id="search-row" class="hbox">
<div id="z-icon-container">
<div id="z-icon"></div>
</div>
<progress id="progress" max="100"></progress>
</div>
</body>
</html>

View file

@ -107,7 +107,7 @@ var ItemTree = class ItemTree extends LibraryTree {
onContextMenu: noop,
onActivate: noop,
emptyMessage: '',
multiSelect: true
getExtraField: noop
};
static propTypes = {
@ -124,7 +124,7 @@ var ItemTree = class ItemTree extends LibraryTree {
onContextMenu: PropTypes.func,
onActivate: PropTypes.func,
emptyMessage: PropTypes.string,
multiSelect: PropTypes.bool,
getExtraField: PropTypes.func,
};
constructor(props) {
@ -1043,7 +1043,7 @@ var ItemTree = class ItemTree extends LibraryTree {
* Select the first row when the tree is focused by the keyboard.
*/
handleKeyUp = (event) => {
if (!Zotero.locked && event.code === 'Tab' && this.selection.count == 0) {
if (!Zotero.locked && (event.code === 'Tab' || event.key.includes("Arrow")) && this.selection.count == 0) {
this.selection.select(this.selection.focused);
}
};
@ -1447,6 +1447,8 @@ var ItemTree = class ItemTree extends LibraryTree {
return (row.ref.isFeedItem && Zotero.Feeds.get(row.ref.libraryID).name) || "";
default:
let extraField = this.props.getExtraField(row.ref, field);
if (extraField !== undefined) return extraField;
// Get from row.getField() to allow for custom fields
return row.getField(field, false, true);
}
@ -3107,6 +3109,11 @@ var ItemTree = class ItemTree extends LibraryTree {
div.classList.toggle('context-row', !!rowData.contextRow);
div.classList.toggle('unread', !!rowData.unread);
div.classList.toggle('highlighted', this._highlightedRows.has(rowData.id));
let nextRowID = this.getRow(index + 1)?.id;
let prevRowID = this.getRow(index - 1)?.id;
div.classList.toggle('first-highlighted', this._highlightedRows.has(rowData.id) && !this._highlightedRows.has(prevRowID));
div.classList.toggle('last-highlighted', this._highlightedRows.has(rowData.id) && !this._highlightedRows.has(nextRowID));
if (this._dropRow == index) {
let span;
if (Zotero.DragDrop.currentOrientation != 0) {
@ -3302,7 +3309,13 @@ var ItemTree = class ItemTree extends LibraryTree {
let key = col.dataKey;
let val = row[key];
if (val === undefined) {
val = treeRow.getField(key);
let customRowValue = this.props.getExtraField(treeRow.ref, key);
if (customRowValue !== undefined) {
val = customRowValue;
}
else {
val = treeRow.getField(key);
}
}
switch (key) {

View file

@ -46,6 +46,20 @@
</hbox>
</groupbox>
<groupbox aria-labelledby="citation-dialog-title">
<label><html:h2 id="citation-dialog-title" data-l10n-id="preferences-citation-dialog"></html:h2></label>
<groupbox aria-labelledby="citation-dialog-mode-label">
<hbox align="center">
<label id="citation-dialog-mode-label" data-l10n-id="preferences-citation-dialog-mode">:</label>
<radiogroup orient="horizontal" id="citation-dialog-mode" preference="extensions.zotero.integration.citationDialogMode">
<radio data-l10n-id="preferences-citation-dialog-mode-last-closed" value="last-closed"/>
<radio data-l10n-id="preferences-citation-dialog-mode-library" value="library"/>
<radio data-l10n-id="preferences-citation-dialog-mode-list" value="list"/>
</radiogroup>
</hbox>
</groupbox>
</groupbox>
<groupbox aria-label="&zotero.preferences.citationOptions.caption;">
<label><html:h2>&zotero.preferences.citationOptions.caption;</html:h2></label>
@ -73,7 +87,5 @@
<html:h1>&zotero.preferences.cite.wordProcessors;</html:h1>
<vbox id="wordProcessorInstallers"/>
<checkbox label="&zotero.preferences.cite.wordProcessors.useClassicAddCitationDialog;" preference="extensions.zotero.integration.useClassicAddCitationDialog" native="true"/>
</vbox>
</vbox>

View file

@ -1259,12 +1259,17 @@ class EditorInstance {
if (Zotero.isLinux) allOptions += ',dialog=no';
// if(options) allOptions += ','+options;
var mode = (!Zotero.isMac && Zotero.Prefs.get('integration.keepAddCitationDialogRaised')
? 'popup' : 'alwaysRaised') + ',resizable=false,centerscreen';
var mode = 'chrome,centerscreen,resizable=true';
if (!Zotero.isMac && Zotero.Prefs.get('integration.keepAddCitationDialogRaised')) {
mode += ",popup";
}
else {
mode += ",alwaysRaised";
}
win = that._quickFormatWindow = Components.classes['@mozilla.org/embedcomp/window-watcher;1']
.getService(Components.interfaces.nsIWindowWatcher)
.openWindow(null, 'chrome://zotero/content/integration/quickFormat.xhtml', '', mode, {
.openWindow(null, 'chrome://zotero/content/integration/citationDialog.xhtml', '', mode, {
wrappedJSObject: io
});
}

View file

@ -1560,24 +1560,20 @@ Zotero.Integration.Session.prototype.cite = async function (field, addNote=false
citation, this.style.opt.sort_citations,
fieldIndexPromise, citationsByItemIDPromise, previewFn
);
io.isCitingNotes = addNote;
Zotero.debug(`Editing citation:`);
Zotero.debug(JSON.stringify(citation.toJSON()));
var mode = (!Zotero.isMac && Zotero.Prefs.get('integration.keepAddCitationDialogRaised')
? 'popup' : 'alwaysRaised')+',resizable=false';
if (addNote) {
Zotero.Integration.displayDialog('chrome://zotero/content/integration/insertNoteDialog.xhtml',
mode, io, "citation");
}
else if (Zotero.Prefs.get("integration.useClassicAddCitationDialog")) {
Zotero.Integration.displayDialog('chrome://zotero/content/integration/addCitationDialog.xhtml',
'alwaysRaised,resizable', io, "citation");
var mode = "chrome,centerscreen,resizable=true";
if (!Zotero.isMac && Zotero.Prefs.get('integration.keepAddCitationDialogRaised')) {
mode += ",popup";
}
else {
Zotero.Integration.displayDialog('chrome://zotero/content/integration/quickFormat.xhtml',
mode, io, "citation");
mode += ",alwaysRaised";
}
Zotero.Integration.displayDialog('chrome://zotero/content/integration/citationDialog.xhtml', mode, io, "citation");
// -------------------
// io.promise resolves when the citation dialog is closed
this.progressCallback = await io.promise;
@ -3621,10 +3617,10 @@ Zotero.Integration.Progress = class {
this.segmentIdx = 0;
}
show() {
var options = 'chrome,centerscreen';
if (this.dontDisplay) return;
var options = 'chrome,centerscreen,resizable=false';
// without this, Firefox gets raised with our windows under Compiz
if (Zotero.isLinux) options += ',dialog=no';
if (Zotero.isMac) options += ',resizable=false';
var io = {onLoad: function(onProgress) {
this.onProgress = onProgress;

View file

@ -74,6 +74,14 @@ preferences-styleManager-add-button =
.tooltiptext = Add a style from a file
.aria-label = { general-add }
.label = { $label }
preferences-citation-dialog = Citation Dialog
preferences-citation-dialog-mode = Citation Dialog Mode:
preferences-citation-dialog-mode-last-closed =
.label = Last Used
preferences-citation-dialog-mode-list =
.label = List Mode
preferences-citation-dialog-mode-library =
.label = Library Mode
preferences-advanced-enable-local-api =
.label = Allow other applications on this computer to communicate with { -app-name }

View file

@ -8,11 +8,21 @@ option-or-alt =
[macos] { general-key-option }
*[other] { general-key-alt }
}
command-or-control =
{ PLATFORM() ->
[macos] { general-key-command }
*[other] { general-key-control }
}
return-or-enter =
{ PLATFORM() ->
[macos] Return
*[other] Enter
}
backsace-or-delete =
{ PLATFORM() ->
[macos] Delete
*[other] Backspace
}
general-print = Print
general-remove = Remove
@ -26,6 +36,9 @@ general-tag = Tag
general-done = Done
general-view-troubleshooting-instructions = View Troubleshooting Instructions
general-go-back = Go Back
general-accept = Accept
general-cancel = Cancel
general-show-in-library = Show in Library
citation-style-label = Citation Style:
language-label = Language:
@ -351,6 +364,73 @@ integration-editBibliography-wrapper =
{ -integration-editBibliography-edit-reference }
integration-quickFormatDialog-window =
.title = { -app-name } - Quick Format Citation
integration-citationDialog = Citation Dialog
integration-citationDialog-section-open = Open Documents ({ $count })
integration-citationDialog-section-selected = Selected Items ({ $count }/{ $total })
integration-citationDialog-section-cited = Cited Items ({ $count })
integration-citationDialog-details-suffix = Suffix
integration-citationDialog-details-prefix = Prefix
integration-citationDialog-details-suppressAuthor = Omit Author
integration-citationDialog-details-remove = { general-remove }
integration-citationDialog-details-done =
.label = { general-done }
integration-citationDialog-details-showInLibrary = { general-show-in-library }
integration-citationDialog-settings-title = Citation Settings
integration-citationDialog-lib-no-items = { $search ->
[true] No selected, open, or cited items match the current search
*[other] No selected or open items
}
integration-citationDialog-settings-keepSorted = Keep sources sorted
integration-citationDialog-btn-settings =
.title = { general-open-settings }
integration-citationDialog-btn-mode =
.title = {
$mode ->
[library] Switch to List Mode
[list] Switch to Library Mode
*[other] Switch Mode
}
.aria-label = {
$mode ->
[library] The dialog is in Library mode. Click to switch to List Mode.
[list] The dialog is in List mode. Click to switch to Library Mode.
*[other] Switch Mode
}
integration-citationDialog-btn-accept =
.title = { general-accept }
integration-citationDialog-btn-cancel =
.title = { general-cancel }
integration-citationDialog-general-instructions = Use Left/Right Arrow to navigate the items of this citation. Press Tab to select items to add into this citation.
Press { command-or-control } - { return-or-enter } to save edits to this citation. Press Escape to discard the changes and close the dialog.
integration-citationDialog-enter-to-add-item = Press { return-or-enter } to add this item to the citation.
integradion-citationDialog-search-for-items = Search for items to add to the citation
integration-citationDialog-aria-bubble =
.aria-description = This item is included in the citation. Press space bar to customize the item. { integration-citationDialog-general-instructions }
integration-citationDialog-single-input =
.placeholder = { integradion-citationDialog-search-for-items }
.aria-description = Press Tab to select items to add into this citation. Press Escape to discard the changes and close the dialog.
integration-citationDialog-input =
.placeholder = { integradion-citationDialog-search-for-items }
.aria-description = { integration-citationDialog-general-instructions }
integration-citationDialog-aria-item-list =
.aria-description = Use Up/Down Arrow to change item selection. { integration-citationDialog-enter-to-add-item }
integration-citationDialog-aria-item-library =
.aria-description = Use Right/Left Arrow to change item selection. { integration-citationDialog-enter-to-add-item }
integration-citationDialog-collections-table =
.aria-label = Collections.
.aria-description = Select a collection and press Tab to navigate its items.
integration-citationDialog-items-table =
.aria-label = { integration-citationDialog-enter-to-add-item }
integration-citationDialog-items-table-added =
.aria-label = This item has been added into the citation. Press { return-or-enter } to add it again or { backsace-or-delete } to remove it.
integration-citationDialog-add-all = Add all
integration-citationDialog-collapse-section =
.title = Collapse section
integration-citationDialog-bubble-empty = (no title)
integration-citationDialog-duplicates-warning-title = Potential Duplicate Item
integration-citationDialog-duplicates-warning-message = This citation already has an item with this title and creator. Add anyway?
styleEditor-locatorType =
.aria-label = Locator type

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.29291 8.00004L7.29289 11.9999L8 11.2928L4.70712 8.00004L8 4.70711L7.29289 4L3.29291 8.00004ZM7.29291 8.00004L11.2929 11.9999L12 11.2928L8.70712 8.00004L12 4.70711L11.2929 4L7.29291 8.00004Z" fill="context-fill"/>
</svg>

After

Width:  |  Height:  |  Size: 368 B

View file

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 2.75C11.4142 2.75 11.75 2.41421 11.75 2C11.75 1.58579 11.4142 1.25 11 1.25C10.5858 1.25 10.25 1.58579 10.25 2C10.25 2.41421 10.5858 2.75 11 2.75ZM12.9004 2.625C12.638 3.42348 11.8863 4 11 4C10.1137 4 9.36204 3.42348 9.0996 2.625H0V1.375H9.0996C9.36204 0.576517 10.1137 0 11 0C11.8863 0 12.638 0.576517 12.9004 1.375L16 1.375V2.625L12.9004 2.625ZM5 8.75C5.41421 8.75 5.75 8.41421 5.75 8C5.75 7.58579 5.41421 7.25 5 7.25C4.58579 7.25 4.25 7.58579 4.25 8C4.25 8.41421 4.58579 8.75 5 8.75ZM5 10C5.8863 10 6.63796 9.42348 6.90041 8.625L16 8.625V7.375L6.90041 7.375C6.63796 6.57652 5.8863 6 5 6C4.1137 6 3.36204 6.57652 3.09959 7.375H0V8.625H3.09959C3.36204 9.42348 4.1137 10 5 10ZM11.75 14C11.75 14.4142 11.4142 14.75 11 14.75C10.5858 14.75 10.25 14.4142 10.25 14C10.25 13.5858 10.5858 13.25 11 13.25C11.4142 13.25 11.75 13.5858 11.75 14ZM12.9004 14.625C12.638 15.4235 11.8863 16 11 16C10.1137 16 9.36204 15.4235 9.0996 14.625H0V13.375H9.0996C9.36204 12.5765 10.1137 12 11 12C11.8863 12 12.638 12.5765 12.9004 13.375H16V14.625H12.9004Z" fill="context-fill"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.75 0C15.4404 0 16 0.559645 16 1.25V9.67137C15.6534 9.24299 15.2297 8.87952 14.75 8.60205V7L7.00002 7V14.75H8.60201C8.87948 15.2297 9.24294 15.6534 9.67131 16H1.25002C0.559661 16 1.52588e-05 15.4404 1.52588e-05 14.75V1.25C1.52588e-05 0.559644 0.559662 0 1.25002 0H14.75ZM1.25002 14.75H5.75002V7L1.25002 7L1.25002 14.75ZM14.75 5.75V1.25L1.25002 1.25L1.25002 5.75H14.75ZM7.00002 4.125H3.00002V2.875H7.00002V4.125ZM18 17.1161L17.1162 18L14.4933 15.3773C13.9277 15.7699 13.2407 16 12.5 16C10.567 16 9 14.433 9 12.5C9 10.567 10.567 9.00002 12.5 9.00002C14.433 9.00002 16 10.567 16 12.5C16 13.2408 15.7699 13.9278 15.3772 14.4934L18 17.1161ZM14.75 12.5C14.75 13.7427 13.7426 14.75 12.5 14.75C11.2574 14.75 10.25 13.7427 10.25 12.5C10.25 11.2574 11.2574 10.25 12.5 10.25C13.7426 10.25 14.75 11.2574 14.75 12.5Z" fill="context-fill"/>
</svg>

After

Width:  |  Height:  |  Size: 984 B

View file

@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.75 1.25V5.75H1.25V1.25H14.75ZM16 1.25C16 0.559644 15.4404 0 14.75 0H1.25C0.559644 0 0 0.559644 0 1.25V5.75C0 6.44036 0.559644 7 1.25 7H14.75C15.4404 7 16 6.44036 16 5.75V1.25ZM7 4.125H3V2.875H7V4.125ZM8.60201 14.75C8.87947 15.2297 9.24293 15.6534 9.67131 16H2V14.75H8.60201ZM8.06222 11.75C8.0213 11.9939 8 12.2445 8 12.5C8 12.669 8.00932 12.8358 8.02746 13H2V11.75H8.06222ZM8.75779 10C9.08901 9.50518 9.51578 9.07972 10.0117 8.75H2V10H8.75779ZM18 17.1161L17.1162 18L14.4933 15.3773C13.9277 15.7699 13.2407 16 12.5 16C10.567 16 9 14.433 9 12.5C9 10.567 10.567 9.00002 12.5 9.00002C14.433 9.00002 16 10.567 16 12.5C16 13.2408 15.7699 13.9278 15.3772 14.4934L18 17.1161ZM14.75 12.5C14.75 13.7427 13.7426 14.75 12.5 14.75C11.2574 14.75 10.25 13.7427 10.25 12.5C10.25 11.2574 11.2574 10.25 12.5 10.25C13.7426 10.25 14.75 11.2574 14.75 12.5Z" fill="context-fill"/>
</svg>

After

Width:  |  Height:  |  Size: 1,015 B

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.11607 9.99999L2.99999 3.88388L3.88387 3L9.99995 9.11611L16.1161 3L17 3.88388L10.8838 9.99999L17 16.1162L16.1161 17L9.99995 10.8839L3.88382 17L2.99994 16.1161L9.11607 9.99999Z" fill="context-fill"/>
</svg>

After

Width:  |  Height:  |  Size: 353 B

View file

@ -142,6 +142,7 @@ pref("extensions.zotero.integration.useClassicAddCitationDialog", false);
pref("extensions.zotero.integration.keepAddCitationDialogRaised", false);
pref("extensions.zotero.integration.upgradeTemplateDelayedOn", 0);
pref("extensions.zotero.integration.dontPromptMendeleyImport", false);
pref("extensions.zotero.integration.citationDialogMode", "last-closed");
// Connector settings
pref("extensions.zotero.httpServer.enabled", true);

View file

@ -1,7 +1,686 @@
#citation-dialog {
#citation-dialog, #progress-bar {
min-width: 800px;
height: 100%;
body {
height: inherit;
margin: 0;
color: var(--fill-primary);
background: var(--material-background);
overflow-y: hidden;
-moz-window-dragging: drag;
}
[hidden] {
display: none !important;
}
.layout {
display: contents;
}
}
.btn-icon {
@include focus-ring;
border: none;
width: 28px;
height: 28px;
max-height: 28px;
padding: 0;
color: var(--fill-secondary);
background-size: 60% !important;
background-repeat: no-repeat;
border-radius: 5px;
margin: 0;
&:hover:not([disabled]) {
cursor: pointer;
background-color: var(--fill-quinary) !important;
}
&:active:not([disabled]) {
background-color: var(--fill-quarternary) !important;
}
&:disabled {
opacity: 0.5;
}
}
#accept-button {
@include svgicon("arrow-right", "universal", "20");
}
#cancel-button {
@include svgicon("x", "universal", "20");
}
#search-row {
margin: 9px 8px 9px 0;
#z-icon-container {
width: 36px; // same as left margin on .library-other-items
#z-icon {
background-image: url(chrome://zotero/skin/z.svg);
width: 16px;
height: 16px;
margin-top: 7px;
margin-left: 12px;
}
}
#progress {
flex: 1;
}
#top-level-btn-group {
display: flex;
width: 73px;
justify-content: end;
align-items: center;
gap: 8px;
-moz-window-dragging: no-drag;
// same height as a single row of bubbles so that this group is not stretched
// when there are multiple rows of bubbles and buttons do not shift
height: 30px;
#loading-spinner {
width: 28px;
height: 28px;
background-size: 60%;
background-repeat: no-repeat;
display: none;
&[status="animate"] {
display: inline-block;
}
}
.vertical-separator {
border-inline-end: 1px solid var(--fill-quarternary);
height: 20px;
}
}
}
.divider {
border-bottom: 1px solid var(--color-panedivider);
margin: 0;
}
.add-all {
@include focus-ring(true);
border-radius: 5px;
font-weight: 400;
text-decoration: underline;
margin-left: auto;
-moz-window-dragging: no-drag;
&:hover {
cursor: pointer;
}
}
// alternative to var(--accent-blue10) without opacity
// which causes issues with stacked item cards in library mode
--selected-item-background: #EBF0FC;
@media (prefers-color-scheme: dark) {
--selected-item-background: #28375A;
}
#library-layout {
#library-other-items {
height: 82px;
flex-shrink: 0; // make sure suggested items do not get shrunk when window is resized
--item-width: 210px;
--item-padding-horizontal: 4px;
--item-margin: 4px;
--item-horizontal-size: calc(var(--item-width) + 2*var(--item-padding-horizontal) + 2px);
position: relative;
overflow-x: auto;
overflow-y: hidden;
// no vertical scrollbar for selected/opened/cited items
scrollbar-width: none;
// add fade effect on the edges of
mask-image: linear-gradient(to right, transparent, black 10px, black calc(100% - 10px), transparent);
// padding and margin needed to allow space for fade effect
// without it being visible at initial scroll position
padding-inline: 8px;
margin-inline: 4px;
// wrapper for horizontal scrollable suggested items
.search-items {
display: flex;
padding: 8px 0;
.section {
&:not(:last-of-type) {
margin-inline-end: 16px;
}
// hide vertical divider
.divider {
display: none;
}
.header {
height: 20px;
color: var(--fill-secondary);
// keep header stuck to the top left corner as the user scrolls
position: sticky;
top: 0;
left: 0;
width: var(--item-horizontal-size);
-moz-user-select: none;
// ensure long headers in non-english locales do not break the layout
text-wrap: nowrap;
text-overflow: ellipsis;
overflow: hidden;
display: block;
}
.itemsContainer {
flex-direction: row;
display: flex;
margin-top: 2px;
gap: var(--item-margin);
transition: gap 0.2s ease-in-out;
border-radius: 5px;
.item {
@include focus-ring(true);
height: 42px;
flex-shrink: 0;
width: var(--item-width);
overflow: hidden;
white-space: nowrap;
border: 1px solid var(--fill-quarternary);
border-radius: 5px;
display: flex;
flex-direction: column;
justify-content: center;
cursor: default;
padding: 0 var(--item-padding-horizontal);
line-height: 18px;
-moz-user-select: none;
transform: translateX(0);
transition: transform 0.2s ease-in-out;//, margin-inline 0.3s ease-in-out;
// color has to be without opacity to the selected items deck
background-color: var(--color-background);
-moz-window-dragging: no-drag;
.title {
overflow: hidden;
text-overflow: ellipsis;
}
.description {
color: var(--fill-secondary);
font-size: 12px;
line-height: 16px;
overflow: hidden;
text-overflow: ellipsis;
}
&.selected {
background-color: var(--selected-item-background) !important;
}
}
}
// handle sections that can be expanded or collapsed
&.expandable {
transition: width 0.2s ease-in-out;
width: calc((var(--item-horizontal-size) * var(--deck-length)) + (var(--item-margin) * (var(--deck-length) - 1)));
.header {
display: flex;
justify-content: space-between;
.header-label {
// make sure header text does not overlap with buttons
max-width: 160px;
text-overflow: ellipsis;
overflow: hidden;
}
.header-btn-group {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
flex: 1;
margin-inline-start: 2px;
.collapse-section-btn {
@include focus-ring(true);
@include svgicon("chevron-12-double", "universal", "16");
color: var(--fill-primary);
width: 16px;
height: 16px;
margin-top: 1px; // nicer alignment with the section header
-moz-window-dragging: no-drag;
}
}
}
.item {
z-index: calc(var(--deck-length) - var(--deck-index));
}
// collapsed sections move all items under the top first item
&:not(.expanded) {
// collapsed deck's width = width of an item + 5px for each item peaking behind the top one (max of 2)
width: calc(var(--item-horizontal-size) + 5px * min(2, var(--deck-length)));
.itemsContainer {
@include focus-ring(true);
gap: 0;
}
.item:nth-child(1) {
box-shadow: 2px 0px 4px 0px rgba(0, 0, 0, 0.10)
}
// 2nd and 3rd children are moved behind the first item, shrunk slightly and moved a bit to the right
// (by 16px and 32px) so their edge peaks out behind the top item
.item:nth-child(2) {
transform: translateX(calc(-1 * var(--deck-index) * (var(--item-horizontal-size)) + 16px)) scale(0.9);
box-shadow: 2px 0px 4px 0px rgba(0, 0, 0, 0.10)
}
.item:nth-child(3) {
transform: translateX(calc(-1 * var(--deck-index) * (var(--item-horizontal-size)) + 32px)) scale(0.8);
box-shadow: 2px 0px 4px 0px rgba(0, 0, 0, 0.10)
}
// remaining item cards are just hidden behind the top item
.item:nth-child(n + 4) {
transform: translateX(calc(-1 * var(--deck-index) * (var(--item-horizontal-size))));
}
// no button to collapse the dection when it is already collapsed
.collapse-section-btn {
display: none;
}
}
}
// separate items get a hover effect, unless they are in
// a collapsed expandable group, in which case the whole group is hovered
&:not(.expandable), &.expandable.expanded {
.item:hover {
background-color: var(--color-quinary-on-background);
}
}
&.expandable:not(.expanded) {
.itemsContainer:hover {
.item {
background-color: var(--color-quinary-on-background);
}
}
}
}
}
#library-no-suggested-items-message {
align-self: center;
width: 100%;
text-align: center;
font-size: 13px;
color: var(--fill-secondary);
-moz-user-select: none;
}
}
#library-trees {
min-height: 200px;
flex: 1;
-moz-user-select: none;
-moz-window-dragging: no-drag;
#collections-tree-container {
min-width: 200px;
min-height: 100%;
border-inline-end: var(--material-panedivider);
& > #zotero-collections-tree {
background: var(--material-sidepane) !important;
}
.virtualized-table-body {
padding-top: 8px;
}
}
#item-tree-container {
min-height: 100%;
flex: 1; /* expand all the way to the right */
// the column with the + button
.clickable {
// make sure the button is centered
display: flex;
justify-content: center;
padding: 0;
// the actual clickable button
.icon-action {
display: flex;
justify-content: center;
width: 20px;
height: 20px;
align-items: center;
border-radius: 6px;
.icon {
width: 16px;
height: 16px;
}
// class to handle hover and active effect, since :hover is applied to the entire row
&.hover:not([disabled]) {
background-color: var(--fill-quinary);
}
&.active:not([disabled]) {
background-color: var(--fill-quarternary);
}
&[disabled] {
color: var(--color-gray-50);
}
}
}
// lighter hover and active effects on + buttons in selected rows
.row.selected {
.icon-action {
&.hover:not([disabled]) {
background-color: #ffffff1a;
}
&.active:not([disabled]) {
background-color: #ffffff33;
}
}
}
.row.highlighted {
border-radius: 0;
}
.row.first-highlighted {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
.row.last-highlighted {
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
}
}
}
}
#list-layout {
#list-layout-wrapper {
-moz-user-select: none;
overflow-y: auto;
scrollbar-color: var(--color-scrollbar) var(--color-scrollbar-background);
height: 100%;
padding-top: 4px;
padding-bottom: 8px;
.section {
// show dividers except for on the last section
.divider {
margin: 4px 16px;
border-bottom: 1px solid var(--fill-quinary);
}
&:last-child {
.divider {
display: none;
}
}
.header {
font-weight: 700;
font-size: 13px;
color: var(--fill-secondary);
padding: 4px 8px 4px 16px;
}
.item {
@include focus-ring(true);
margin: 0 8px;
padding: 4px 8px;
border-radius: 5px;
color: var(--fill-primary);
cursor: default;
-moz-window-dragging: no-drag;
&:hover {
background-color: var(--fill-quinary);
}
&.selected {
background-color: var(--selected-item-background);
border-radius: 0;
}
&.selected-first {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
&.selected-last {
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
}
.icon {
margin-top: -4px;
margin-inline-end: 4px;
flex-shrink: 0;
&.retracted {
width: 12px;
height: 12px;
color: var(--accent-red);
@include svgicon("cross", "universal", "16");
}
}
.description {
// Needed to have empty item row description to still occupy height
white-space: pre;
margin-inline-start: 20px;
overflow: hidden;
text-overflow: ellipsis;
}
.title {
overflow: hidden;
text-wrap: nowrap;
text-overflow: ellipsis;
}
}
&.expandable {
// styling for expandable header with a twisty
.header {
display: flex;
justify-content: space-between;
.header-label {
@include focus-ring;
border-radius: 5px;
&:hover {
cursor: pointer;
text-decoration: underline;
}
&::before {
@include svgicon("chevron-8", "universal", "8");
width: 8px;
height: 8px;
display: inline-block;
content: "";
white-space: pre;
margin-inline-end: 3px;
transform: rotate(0deg);
transform-origin: center;
transition: transform 0.2s ease-in-out;
vertical-align: middle;
margin-bottom: 3px;
}
}
}
&.expanded {
.header-label::before {
transform: rotate(180deg);
}
}
// item container has no height when it is collapsed
.itemsContainer {
transition: height 0.3s ease;
overflow: hidden;
// add small padding at the top and bottom to make sure the focus-ring (if it appear) is not
// cutoff by the overflow:hidden. Negative margin is to preserve spacing set by other components.
padding: 1px 0;
margin: -1px 0;
}
&:not(.expanded) {
.itemsContainer {
height: 0 !important; // important to override inline height with items displayed
}
}
}
}
}
&.empty {
#list-layout-wrapper {
padding: 0;
}
}
}
#bottom-area-wrapper {
border-top: var(--material-panedivider);
padding: 4px 8px 4px 12px;
#bottom-btn-group {
gap: 8px;
-moz-window-dragging: no-drag;
#mode-button {
&[mode="library"] {
@include svgicon("dialog-search-list", "universal", "16");
}
&[mode="list"] {
@include svgicon("dialog-search-library", "universal", "16");
}
}
#settings-button {
@include svgicon("dialog-options", "universal", "16");
}
}
}
#popups {
panel {
padding: 0;
.popup {
padding: 16px;
}
}
.overlay {
position: absolute;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.1);
z-index: 1000;
}
#settings-popup {
top: 35px;
right: 5px;
.title {
font-size: 13px;
font-weight: 700;
padding-bottom: 8px;
}
}
#itemDetails {
width: 400px;
.popup {
.details-header {
.icon {
margin-inline-end: 5px;
margin-top: 2px;
flex-shrink: 0;
}
}
.details {
margin: 16px 0;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 8px;
.row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: end;
}
.details-data {
@media not (-moz-platform: windows) {
@include focus-ring;
}
margin-inline-end: 0;
padding-inline-start: 5px;
border-radius: 5px;
border: none;
box-shadow: 0px 0.5px 2.5px 0px rgba(0, 0, 0, 0.3);
width: 250px;
@media (-moz-platform: linux) {
width: 240px;
}
}
#suppress-author-row {
justify-content: start;
padding-inline-start: 100px;
}
#label {
flex: 1;
@media (-moz-platform: macos) {
height: 22px;
}
@media (-moz-platform: windows) {
border: 1px solid var(--color-border);
border-radius: 3px;
height: 25px;
}
}
}
button.done {
float: right;
}
button.remove {
color: var(--accent-red);
}
}
}
}
.description {
color: var(--fill-secondary);
font-size: 12px;
// add comma between description <span>s
span:not(:last-child):not([no-comma])::after {
content: ", ";
}
}
.aria-hidden {
position: absolute;
top: -1000px;
left: -1000px;
visibility: hidden;
}
input[type="checkbox"], select, button {
// other platforms already get a standard focusring
@media (-moz-platform: linux) {
@include focus-ring;
}
}
.drag-image-wrapper {
position: absolute;
top: -1000px;
height: 200px;
width: 100%;
}
}
#progress-bar {
body {
justify-content: center;
}
#search-row {
margin-top: 9px;
#z-icon-container {
display: flex;
justify-content: center;
#z-icon {
margin: 0 !important;
}
}
}
}

View file

@ -202,7 +202,12 @@
}
}
}
item-tree-menu-bar[inactive] {
max-height: 0 !important;
overflow: hidden;
}
$-itemTypesIcons: (
artwork,
attachment-epub,
@ -284,7 +289,8 @@ $-trashableObjectIcons: (
$-coloredIcons: (
cross: --accent-red,
tick: --accent-green,
refresh: --fill-secondary
refresh: --fill-secondary,
plus-circle: --fill-secondary
);
.icon-item-type {

View file

@ -132,7 +132,7 @@
bottom: -1px;
}
&.selected:not(.highlighted) {
&.selected {
background-color: var(--color-accent);
color: var(--color-accent-text);
@ -142,8 +142,8 @@
}
}
&.highlighted {
background: var(--accent-highlight);
&.highlighted:not(.selected) {
background: var(--highlight-color, var(--accent-highlight)) !important;
}
&.unread {

View file

@ -1,78 +1,96 @@
bubble-input {
min-width: 200px;
-moz-window-dragging: no-drag;
--bubble-height: 28px;
--bubble-horizontal-margin: 2px;
--bubble-rows-gap: 5px;
.body {
cursor: text;
display: flex;
flex-flow: row wrap;
align-items: center;
//margin: 0;
font: -moz-field;
outline: none;
//line-height: 2em;
//width: 700px; /* initial editor width - adjusted after dom load */
align-content: start;
row-gap: var(--bubble-rows-gap);
min-height: 28px;
font-size: 13px;
background: white;
color: black;
width: 100%;
padding-inline: 6px;
padding-inline-end: 6px;
// height is limited to 5.5. rows of bubbles
max-height: calc(5.5 * (var(--bubble-height) + var(--bubble-rows-gap)));
overflow-y: auto;
scrollbar-color: var(--color-scrollbar) var(--color-scrollbar-background);
// to get proper width by getContentWidth in bubbleInput.js
span {
white-space: pre;
}
cursor: text;
font-size: 15px;
-moz-window-dragging: no-drag;
// increase the clickable area before each bubble a bit
padding-inline-start: 5px;
margin-inline-start: -5px;
// a bit of padding so that focusring of bubbles is not cutoff
padding-top: 1px;
padding-bottom: 1px;
}
input {
outline: none;
width: 2px;
border: none !important;
box-sizing: border-box;
height: 15px;
height: var(--bubble-height);
/* Keep the background and text color unchanged in dark mode */
background-color: transparent !important;
color: black;
//background-image: none !important;
padding: 0;
margin-inline: -1px;
margin: 0;
// input occupying the entire width for when there are not bubbles at all
&.full-width {
width: 100% !important;
}
// inputs initially occupy no width so they don't misalign bubbles at the
// start of each line. On focus, set their width for the cursor to appear
// and offset it by negative margin to avoid bubbles shifting
&.empty:not(.full-width):focus {
min-width: 1px;
margin-inline-start: -1px;
}
}
.bubble {
--margin-horizontal: 2px;
border-radius: 8px;
background-color: #dee7f8;
border-style: solid;
border-width: 1px;
border-color: #a8c0ec;
padding: 0 4px;
margin: 0 var(--margin-horizontal);
@media not (-moz-platform: windows) {
@include focus-ring(true);
}
height: var(--bubble-height);
border-radius: 5px;
padding: 0 8px;
margin: 0 var(--bubble-horizontal-margin);
display: flex;
flex-direction: row;
align-items: center;
white-space: nowrap;
line-height: normal;
color: #000;
-moz-user-select: none;
cursor: pointer;
height: 16px;
max-width: -moz-available;
position: relative;
cursor: pointer;
background-color: var(--fill-quinary);
&:hover {
background-color: #bbcef1;
border-color: #6d95e0;
&:hover:not(.showingDetails) {
background-color: var(--fill-quarternary);
.cross {
display: block;
.delete-btn {
display: flex;
}
.text {
margin-right: -16px;
mask-image: linear-gradient(to left, transparent 12px, var(--fill-primary) 24px);
mask-image: linear-gradient(to left, transparent 14px, var(--fill-primary) 24px);
overflow: hidden;
text-overflow: ellipsis;
}
}
&.showingDetails {
background-color: var(--fill-quarternary) !important;
}
&.drop-before::before, &.drop-after::after {
content: "";
@ -83,24 +101,32 @@ bubble-input {
position: absolute;
}
&.drop-before::before {
left: calc(-1 * var(--margin-horizontal));
left: calc(-1 * var(--bubble-horizontal-margin));
}
&.drop-after::after {
right: calc(-2 * var(--margin-horizontal));
right: calc(-2 * var(--bubble-horizontal-margin));
}
&[selected="true"] {
border-radius: 8px !important;
background-color: #598bec;
color: #fff;
// highlight bubbles whose item is currently selected by the user
&.has-item-selected {
background-color: var(--accent-blue10);
}
.cross {
@include svgicon("x-8", "universal", "16");
.delete-btn {
display: none;
width: 16px;
height: 18px;
align-self: start;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
border-radius: 5px;
position: absolute;
right: 4px;
color: var(--fill-secondary);
&:hover {
background-color: var(--fill-quarternary);
}
&:active {
background-color: var(--fill-tertiary);
}
}
}
}
}

View file

@ -298,7 +298,7 @@ describe("Zotero.Integration", function () {
var dialogResults = {
addCitationDialog: {},
quickFormat: {},
citationDialog: {},
integrationDocPrefs: {},
selectItemsDialog: {},
editBibliographyDialog: {}
@ -331,7 +331,7 @@ describe("Zotero.Integration", function () {
function setAddEditItems(items) {
if (items.length == undefined) items = [items];
dialogResults.quickFormat = async function(dialogName, io) {
dialogResults.citationDialog = async function(dialogName, io) {
io.citation.citationItems = items.map(function(item) {
item = Zotero.Cite.getItem(item.id);
return {id: item.id, uris: item.cslURIs, itemData: item.cslItemData};
@ -526,19 +526,19 @@ describe("Zotero.Integration", function () {
it('should return false if an integration dialog is open but is pristine', async function () {
await insertMultipleCitations.call(this);
let docID = this.test.fullTitle();
let quickFormatOpenedDeferred = Zotero.Promise.defer();
let quickFormatCancelledDeferred = Zotero.Promise.defer();
dialogResults.quickFormat = async function(dialogName, io) {
Zotero.Integration.currentWindow = { isPristine: true, focus: () => 0, cancel: quickFormatCancelledDeferred.resolve };
quickFormatOpenedDeferred.resolve();
await quickFormatCancelledDeferred.promise;
let citationDialogOpenedDeferred = Zotero.Promise.defer();
let citationDialogCancelledDeferred = Zotero.Promise.defer();
dialogResults.citationDialog = async function(dialogName, io) {
Zotero.Integration.currentWindow = { isPristine: true, focus: () => 0, cancel: citationDialogCancelledDeferred.resolve };
citationDialogOpenedDeferred.resolve();
await citationDialogCancelledDeferred.promise;
io._acceptDeferred.resolve(() => {});
Zotero.Integration.currentWindow = null;
};
let firstCommandPromise = execCommand('addEditCitation', docID);
await quickFormatOpenedDeferred.promise;
await citationDialogOpenedDeferred.promise;
assert.isFalse(await Zotero.Integration.shouldAbortCommand());
await quickFormatCancelledDeferred.promise;
await citationDialogCancelledDeferred.promise;
await firstCommandPromise;
});
@ -549,18 +549,18 @@ describe("Zotero.Integration", function () {
try {
await insertMultipleCitations.call(this);
let docID = this.test.fullTitle();
let quickFormatOpenedDeferred = Zotero.Promise.defer();
let quickFormatDeferred = Zotero.Promise.defer();
dialogResults.quickFormat = async function (dialogName, io) {
quickFormatOpenedDeferred.resolve();
await quickFormatDeferred.promise;
let citationDialogOpenedDeferred = Zotero.Promise.defer();
let citationDialogDeferred = Zotero.Promise.defer();
dialogResults.citationDialog = async function (dialogName, io) {
citationDialogOpenedDeferred.resolve();
await citationDialogDeferred.promise;
io._acceptDeferred.resolve(() => {});
};
let firstCommandPromise = execCommand('addEditCitation', docID);
await quickFormatOpenedDeferred.promise;
await citationDialogOpenedDeferred.promise;
assert.isTrue(await Zotero.Integration.shouldAbortCommand());
assert.isTrue(stub.called);
quickFormatDeferred.resolve({});
citationDialogDeferred.resolve({});
await firstCommandPromise;
}
finally {
@ -575,21 +575,21 @@ describe("Zotero.Integration", function () {
try {
await insertMultipleCitations.call(this);
let docID = this.test.fullTitle();
let quickFormatOpenedDeferred = Zotero.Promise.defer();
let quickFormatCancelledDeferred = Zotero.Promise.defer();
dialogResults.quickFormat = async function(dialogName, io, windowType) {
Zotero.Integration.currentWindow = { isPristine: false, focus: () => 0, cancel: quickFormatCancelledDeferred.resolve };
let citationDialogOpenedDeferred = Zotero.Promise.defer();
let citationDialogCancelledDeferred = Zotero.Promise.defer();
dialogResults.citationDialog = async function(dialogName, io, windowType) {
Zotero.Integration.currentWindow = { isPristine: false, focus: () => 0, cancel: citationDialogCancelledDeferred.resolve };
Zotero.Integration.currentWindowType = windowType;
quickFormatOpenedDeferred.resolve();
await quickFormatCancelledDeferred.promise;
citationDialogOpenedDeferred.resolve();
await citationDialogCancelledDeferred.promise;
io._acceptDeferred.resolve(() => {});
Zotero.Integration.currentWindow = null;
};
let firstCommandPromise = execCommand('addEditCitation', docID);
await quickFormatOpenedDeferred.promise;
await citationDialogOpenedDeferred.promise;
assert.isFalse(await Zotero.Integration.shouldAbortCommand());
assert.isTrue(stub.called);
await quickFormatCancelledDeferred.promise;
await citationDialogCancelledDeferred.promise;
await firstCommandPromise;
}
finally {