mirror of
https://github.com/zotero/zotero.git
synced 2026-08-28 05:25:31 +00:00
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:
parent
27f1103d88
commit
4a00304925
29 changed files with 4310 additions and 547 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
270
chrome/content/zotero/integration/citationDialog/helpers.mjs
Normal file
270
chrome/content/zotero/integration/citationDialog/helpers.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
3
chrome/skin/default/zotero/20/universal/x.svg
Normal file
3
chrome/skin/default/zotero/20/universal/x.svg
Normal 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 |
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue