diff --git a/chrome/content/zotero-platform/unix/overlay.css b/chrome/content/zotero-platform/unix/overlay.css
index 49bc6bd38e..b3a2e95af7 100644
--- a/chrome/content/zotero-platform/unix/overlay.css
+++ b/chrome/content/zotero-platform/unix/overlay.css
@@ -10,6 +10,11 @@ tab {
border-right: 1px solid hsla(0, 0%, 0%, 0.2);
border-left: 1px solid hsla(0, 0%, 0%, 0.2);
}
+
+tab:where([visuallyselected="true"]) {
+ background: Background;
+}
+
/* Fixes misc Ubuntu 16.10 rendering issue fixes. */
#zotero-prefs menulist dropmarker{
width: 30px;
diff --git a/chrome/content/zotero/advancedSearch.js b/chrome/content/zotero/advancedSearch.js
index b3719a9351..89e6e10ec4 100644
--- a/chrome/content/zotero/advancedSearch.js
+++ b/chrome/content/zotero/advancedSearch.js
@@ -69,9 +69,7 @@ var ZoteroAdvancedSearch = new function() {
columns,
});
- // A minimal implementation of Zotero.CollectionTreeRow
- var collectionTreeRow = {
- view: {},
+ this.itemsView.changeCollectionTreeRow({
ref: _searchBox.search,
visibilityGroup: 'default',
isSearchMode: () => true,
@@ -85,8 +83,9 @@ var ZoteroAdvancedSearch = new function() {
isFeeds: () => false,
isFeedsOrFeed: () => false,
isShare: () => false,
- isTrash: () => false
- };
+ isTrash: () => false,
+ isSearch: () => true
+ });
this.itemsView.changeCollectionTreeRow(collectionTreeRow);
// Focus the first field in the window
@@ -101,12 +100,11 @@ var ZoteroAdvancedSearch = new function() {
_searchBox.updateSearch();
_searchBox.active = true;
- // A minimal implementation of Zotero.CollectionTreeRow
- var collectionTreeRow = {
- view: {},
+ return this.itemsView.changeCollectionTreeRow({
ref: _searchBox.search,
visibilityGroup: 'default',
isSearchMode: () => true,
+ isSearch: () => true,
getItems: async function () {
await Zotero.Libraries.get(_libraryID).waitForDataLoad('item');
@@ -114,20 +112,8 @@ var ZoteroAdvancedSearch = new function() {
search.libraryID = _libraryID;
var ids = await search.search();
return Zotero.Items.get(ids);
- },
- isLibrary: () => false,
- isCollection: () => false,
- isSearch: () => true,
- isPublications: () => false,
- isDuplicates: () => false,
- isFeed: () => false,
- isFeeds: () => false,
- isFeedsOrFeed: () => false,
- isShare: () => false,
- isTrash: () => false
- };
-
- return this.itemsView.changeCollectionTreeRow(collectionTreeRow);
+ }
+ });
}
diff --git a/chrome/content/zotero/collectionTree.jsx b/chrome/content/zotero/collectionTree.jsx
index 70ab141024..197557cbee 100644
--- a/chrome/content/zotero/collectionTree.jsx
+++ b/chrome/content/zotero/collectionTree.jsx
@@ -710,6 +710,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
await this.waitForLoad();
+ // Check if items from multiple libraries were specified
// Check if items from multiple libraries were specified
if (items.length > 1 && new Set(items.map(item => item.libraryID)).size > 1) {
Zotero.debug("Can't select items in multiple libraries", 2);
diff --git a/chrome/content/zotero/components/virtualized-table.jsx b/chrome/content/zotero/components/virtualized-table.jsx
index b4931be286..88b74e9423 100644
--- a/chrome/content/zotero/components/virtualized-table.jsx
+++ b/chrome/content/zotero/components/virtualized-table.jsx
@@ -394,7 +394,7 @@ class VirtualizedTable extends React.Component {
getRowCount: PropTypes.func.isRequired,
- renderItem: PropTypes.func,
+ renderItem: PropTypes.func.isRequired,
// Row height specified as lines of text per row. Defaults to 1
linesPerRow: PropTypes.number,
// Do not adjust for Zotero-defined font scaling
@@ -672,7 +672,7 @@ class VirtualizedTable extends React.Component {
for (let i = this.selection.focused + 1, checked = 0; checked < rowCount; i++, checked++) {
i %= rowCount;
let rowString = this.props.getRowString(i);
- if (rowString.toLowerCase().indexOf(char) == 0) {
+ if (rowString && rowString.toLowerCase().indexOf(char) == 0) {
if (i != this.selection.focused) {
this.scrollToRow(i);
this.onSelection(i);
@@ -854,8 +854,10 @@ class VirtualizedTable extends React.Component {
offset += resizingRect.width;
}
const widthSum = aRect.width + bRect.width;
- const aSpacingOffset = (aColumn.minWidth ? aColumn.minWidth : COLUMN_MIN_WIDTH) + (aColumn.noPadding ? 0 : COLUMN_PADDING);
- const bSpacingOffset = (bColumn.minWidth ? bColumn.minWidth : COLUMN_MIN_WIDTH) + (bColumn.noPadding ? 0 : COLUMN_PADDING);
+ const aColumnPadding = aColumn.iconLabel ? 0 : COLUMN_PADDING;
+ const bColumnPadding = bColumn.iconLabel ? 0 : COLUMN_PADDING;
+ const aSpacingOffset = (aColumn.minWidth ? aColumn.minWidth : COLUMN_MIN_WIDTH) + aColumnPadding;
+ const bSpacingOffset = (bColumn.minWidth ? bColumn.minWidth : COLUMN_MIN_WIDTH) + bColumnPadding;
const aColumnWidth = Math.min(widthSum - bSpacingOffset, Math.max(aSpacingOffset, event.clientX - (RESIZER_WIDTH / 2) - offset));
const bColumnWidth = widthSum - aColumnWidth;
let onResizeData = {};
@@ -1003,7 +1005,7 @@ class VirtualizedTable extends React.Component {
}
_handleColumnDragStart = (index, event) => {
- if (event.button !== 0) return false;
+ if (event.button !== 0 || this.props.staticColumns) return false;
// Remember for sorting
this._headerMouseDownIndex = index;
this.setState({ draggingColumn: index });
@@ -1223,7 +1225,8 @@ class VirtualizedTable extends React.Component {
+ {...(column.iconLabel ? { title: columnName } : {})}
+ >
{label}
{sortIndicator}
@@ -1630,6 +1633,7 @@ var Columns = class {
}
const column = this._columns.find(column => column.dataKey == dataKey);
const styleIndex = this._columnStyleMap[dataKey];
+ const columnPadding = column.iconLabel ? 0 : COLUMN_PADDING;
if (storePrefs && !column.fixedWidth) {
column.width = width;
prefs[dataKey] = this._getColumnPrefsToPersist(column);
@@ -1642,7 +1646,7 @@ var Columns = class {
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('max-width', `${width}px`, 'important');
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('min-width', `${width}px`, 'important');
} else {
- width = (width - COLUMN_PADDING);
+ width = (width - columnPadding);
Zotero.debug(`Columns ${dataKey} width ${width}`);
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('flex-basis', `${width}px`);
}
@@ -1740,10 +1744,18 @@ var Columns = class {
};
function renderCell(index, data, column, dir = null) {
- column = column || { columnName: "" };
+ column = column || { dataKey: "" };
+ if (column.renderer) {
+ return column.renderer(index, data, column, dir);
+ }
let span = document.createElement('span');
span.className = `cell ${column.className}`;
- span.textContent = data;
+ if (column.type == 'html') {
+ span.innerHTML = data.replaceAll('&', '&');
+ }
+ else {
+ span.textContent = data;
+ }
if (dir) span.dir = dir;
return span;
}
@@ -1777,6 +1789,7 @@ function makeRowRenderer(getRowData) {
div.classList.toggle('selected', selection.isSelected(index));
div.classList.toggle('focused', selection.focused == index);
const rowData = getRowData(index);
+ div.classList.toggle('highlighted', !!rowData.highlighted);
let ariaLabel = "";
if (columns.length) {
diff --git a/chrome/content/zotero/elements/bubbleInput.js b/chrome/content/zotero/elements/bubbleInput.js
new file mode 100644
index 0000000000..ed3b4aecc6
--- /dev/null
+++ b/chrome/content/zotero/elements/bubbleInput.js
@@ -0,0 +1,462 @@
+/*
+ ***** 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 .
+
+ ***** END LICENSE BLOCK *****
+*/
+
+"use strict";
+
+{
+ class BubbleInput extends XULElementBase {
+ content = MozXULElement.parseXULToFragment(`
+
+
+ `);
+
+ 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');
+
+ // // 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;
+ }
+
+ _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();
+ }
+
+ _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", '');
+ 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") {
+ 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;
+ }
+
+ getCurrentInput() {
+ if (this._isInput(document.activeElement)) {
+ return document.activeElement;
+ }
+ 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);
+ }
+
+ // 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;
+ }
+ }
+
+ _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;
+ }
+
+ _createInputElem() {
+ let input = document.createElement('input');
+ input.setAttribute("aria-describedby", "input-description");
+ 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';
+ });
+ // input.addEventListener("keypress", onInputPress);
+ input.addEventListener("keypress", e => this._onInputKeypress(input, e));
+ // input.addEventListener("paste", _onPaste, false);
+ 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;
+ });
+ // // 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();
+ }
+ });
+ return input;
+ }
+
+ _onInputKeypress(input, event) {
+ if (event.target === input && 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" && 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();
+ }
+ }
+ else 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();
+ }
+ }
+ }
+
+ _moveFocusForward(node) {
+ if (node.nextElementSibling?.focus) {
+ node.nextElementSibling.focus();
+ return true;
+ }
+ return false;
+ }
+
+ _moveFocusBack(node) {
+ // Skip line break if it's before the node
+ if (node.previousElementSibling?.tagName == "br") {
+ node = node.previousElementSibling;
+ }
+ 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();
+ }
+ node.nextElementSibling.remove();
+ }
+ node = node.nextElementSibling;
+ }
+ }
+
+ _getBubbleIndex(bubble) {
+ return this.body.querySelectorAll('.bubble').indexOf(bubble);
+ }
+
+ /**
+ * 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
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):
+ * NULL #1 #2 #3
+ * ↓ ↓ ↓ ↓
+ * [ bubble_1 bubble_2 bubble_3
+ * bubble_4, bubble_5 ]
+ * ↑ ↑ ↑ ↑
+ * #3 #4 #5 #5
+ * @param {Int} x - X coordinate
+ * @param {Int} y - Y coordinate
+ * @returns {lastBubble: Node, startOfTheLine: Bool}
+ */
+ _getLastBubbleBeforePoint(x, y) {
+ let bubbles = this._body.querySelectorAll('.bubble');
+ let lastBubble = null;
+ let startOfTheLine = false;
+ 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 the click is to the right of a bubble, it becomes a candidate
+ if (x > rect.right) {
+ lastBubble = i;
+ }
+ // Otherwise, stop and return the last bubble we saw if any
+ else {
+ if (i == 0) {
+ lastBubble = null;
+ }
+ else {
+ // Indicate there is no bubble before this one
+ startOfTheLine = lastBubble === null;
+ lastBubble = Math.max(i - 1, 0);
+ }
+ break;
+ }
+ }
+ }
+ if (lastBubble !== null) {
+ lastBubble = bubbles[lastBubble];
+ }
+ return { lastBubble: lastBubble, startOfTheLine: startOfTheLine };
+ }
+ }
+
+ customElements.define('bubble-input', BubbleInput);
+}
\ No newline at end of file
diff --git a/chrome/content/zotero/integration/citationDialog.js b/chrome/content/zotero/integration/citationDialog.js
new file mode 100644
index 0000000000..d0c58e6e92
--- /dev/null
+++ b/chrome/content/zotero/integration/citationDialog.js
@@ -0,0 +1,24 @@
+/*
+ ***** 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 .
+
+ ***** END LICENSE BLOCK *****
+*/
diff --git a/chrome/content/zotero/integration/citationDialog.xhtml b/chrome/content/zotero/integration/citationDialog.xhtml
new file mode 100644
index 0000000000..29cfd20366
--- /dev/null
+++ b/chrome/content/zotero/integration/citationDialog.xhtml
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+ &zotero.integration.quickFormatDialog.title;
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js
new file mode 100644
index 0000000000..ab9dac1499
--- /dev/null
+++ b/chrome/content/zotero/integration/citationExplorer.js
@@ -0,0 +1,510 @@
+/*
+ ***** BEGIN LICENSE BLOCK *****
+
+ Copyright © 2023 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 .
+
+ ***** END LICENSE BLOCK *****
+*/
+
+const React = require('react');
+const ReactDOM = require('react-dom');
+const diff = require('diff');
+const VirtualizedTable = require('components/virtualized-table');
+const { getCSSIcon, IconAttachSmall } = require('components/icons');
+const ItemTree = require('zotero/itemTree');
+const { getColumnDefinitionsByDataKey } = require('zotero/itemTreeColumns');
+const { makeRowRenderer } = VirtualizedTable;
+
+let io, citations, items, uncitedItems, citationList, itemList;
+let citationRows = [];
+let itemRows = [];
+let uncitedItemRows = [];
+let _addToTarget;
+let disableCitationActivate;
+let selectedTab = 0;
+
+const citationColumns = [
+ {
+ dataKey: 'isLinked',
+ label: 'Is Linked',
+ iconLabel: ,
+ width: 26,
+ staticWidth: true,
+ fixedWidth: true,
+ renderer: (index, data, column) => {
+ let icon = getCSSIcon('IconCross');
+ if (data) {
+ icon = getCSSIcon('IconTick');
+ }
+ icon.className += ` cell ${column.className}`;
+ return icon;
+ }
+ },
+ {
+ dataKey: 'title',
+ label: "Citation",
+ type: 'html'
+ },
+];
+
+let itemColumns = getColumnDefinitionsByDataKey(['title', 'firstCreator', 'date']);
+itemColumns.push({
+ dataKey: 'isLinked',
+ label: 'Is Linked',
+ iconLabel: ,
+ width: 26,
+ staticWidth: true,
+ fixedWidth: true,
+ renderer: (index, data, column) => {
+ let icon = getCSSIcon('IconCross');
+ if (data) {
+ icon = getCSSIcon('IconTick');
+ }
+ icon.className += ` cell ${column.className}`;
+ return icon;
+ }
+});
+itemColumns[1].sortDirection = 1;
+
+window.ZoteroDocumentCitations = {
+ init: async function () {
+ this._highlightedCitations = new Set();
+ this._filteredCitations = new Set();
+ this._filteredItems = new Set();
+
+ document.querySelector('#button-show-in-document').addEventListener('click', this.onCitationActivate.bind(this));
+ document.querySelector('#button-edit-citation').addEventListener('click', this.onCitationEdit.bind(this));
+
+ document.querySelector('#button-show-in-zotero').addEventListener('click', this.onItemActivate.bind(this));
+ document.querySelector('#button-relink-item').addEventListener('click', this.onItemRelink.bind(this));
+
+ let lastTranslationTarget = Zotero.Prefs.get('documentCitations.lastAddToTarget');
+ if (lastTranslationTarget) {
+ let id = parseInt(lastTranslationTarget.substr(1));
+ if (lastTranslationTarget[0] == "L") {
+ _addToTarget = Zotero.Libraries.get(id);
+ }
+ else if (lastTranslationTarget[0] == "C") {
+ _addToTarget = Zotero.Collections.get(id);
+ }
+ }
+ if (!_addToTarget) {
+ _addToTarget = Zotero.Libraries.userLibrary;
+ }
+ this.setAddToButton();
+
+ io = window.arguments[0].wrappedJSObject;
+ citations = Object.values(io.citations);
+ items = io.items;
+ uncitedItems = io.uncitedItems;
+ await this._initMappings();
+ await this.refreshCitationList();
+ await this.refreshItemList();
+ },
+
+ refreshCitationList: async function () {
+ this._renderedCitationRows = citationRows.filter(row => !this._filteredCitations.has(row.ref.citationID));
+ this._renderedCitationRows.forEach((row) => {
+ row.highlighted = this._highlightedCitations.has(row.ref.citationID);
+ });
+
+ // init VirtualizedTable
+ if (!citationList) {
+ await new Promise((resolve) => {
+ ReactDOM.createRoot(document.querySelector('#citation-list')).render( {
+ citationList = ref;
+ resolve();
+ }}
+ multiSelect={true}
+ getRowCount={() => this._renderedCitationRows.length}
+ showHeader={true}
+ staticColumns={true}
+ columns={citationColumns}
+ renderItem={makeRowRenderer(index => this._renderedCitationRows[index])}
+ onActivate={this.onCitationActivate.bind(this)}
+ onSelectionChange={this.onCitationSelectionChange.bind(this)}
+ getRowString={index => this._renderedCitationRows[index].title}
+ />);
+ });
+ }
+ citationList.invalidate();
+ },
+
+ refreshItemList: async function () {
+ let rows = selectedTab === 0 ? itemRows : uncitedItemRows;
+ rows.forEach((item) => {
+ item.isLinked = !item.cslItemID;
+ });
+
+ let filteredItems = rows.filter(item => !this._filteredItems.has(item.id));
+ if (!itemList) {
+ let domElem = document.querySelector('#item-list');
+ itemList = await ItemTree.init(domElem, {
+ id: "document-collections",
+ regularOnly: true,
+ columns: itemColumns,
+ shouldListenForNotifications: false,
+ onSelectionChange: this.onItemSelectionChange.bind(this),
+ onActivate: this.onItemActivate.bind(this),
+ emptyMessage: Zotero.getString('pane.items.loading')
+ });
+ await itemList.waitForLoad();
+ }
+ await itemList.changeCollectionTreeRow({
+ getItems: async () => filteredItems,
+ isSearch: () => true,
+ isSearchMode: () => true,
+ });
+ },
+
+ onCitationFilter: async function () {
+ let searchString = this._normalizeSearch(document.querySelector('#citation-search').value);
+ let citationStrings = await Promise.all(citations.map(citation => citation.field.getText()));
+ this._filteredCitations = new Set();
+ citationStrings.forEach((str, index) => {
+ if (!this._normalizeSearch(str).includes(searchString)) {
+ this._filteredCitations.add(citations[index].citationID);
+ }
+ });
+ await this.refreshCitationList();
+ },
+
+ onItemFilter: async function () {
+ let searchString = this._normalizeSearch(document.querySelector('#item-search').value);
+ let itemStrings = itemRows.map((item) => {
+ return [item.getField('title'), item.getField('firstCreator'), item.getField('date')].join(' ');
+ });
+ this._filteredItems = new Set();
+ itemStrings.forEach((str, index) => {
+ if (!this._normalizeSearch(str).includes(searchString)) {
+ this._filteredItems.add(itemRows[index].id);
+ }
+ });
+ await this.refreshItemList();
+ },
+
+ onSelectTab: async function (selectedIndex) {
+ if (selectedIndex === selectedTab) return;
+ selectedTab = selectedIndex;
+ if (selectedTab) {
+ this._highlightedCitations = new Set();
+ document.querySelector('#button-show-in-zotero').hidden = true;
+ document.querySelector('#button-relink-item').hidden = false;
+ document.querySelector('#button-addTo-library').style.display = 'none';
+ }
+ await this.refreshCitationList();
+ await this.refreshItemList();
+ },
+
+ _initMappings: async function () {
+ itemRows = items.map((item) => {
+ let citedIn = [];
+ return new Proxy(item, {
+ get(target, prop) {
+ if (prop == 'id' && !target.id) {
+ return target.cslItemID;
+ }
+ if (prop == 'citedIn') {
+ return citedIn;
+ }
+ return Reflect.get(...arguments);
+ }
+ });
+ });
+ uncitedItemRows = uncitedItems.map((item) => {
+ return new Proxy(item, {
+ get(target, prop) {
+ if (prop == 'citedIn') {
+ return [];
+ }
+ return Reflect.get(...arguments);
+ }
+ });
+ });
+ citationRows = await Promise.all(citations
+ .map(async (citation, citationIndex) => {
+ let isLinked = true;
+ let citedItems = [];
+ // check if all citation items are linked
+ for (let citationItem of citation.citationItems) {
+ itemRows.forEach((itemRow, itemIndex) => {
+ if ([itemRow.id, itemRow.cslItemID].includes(citationItem.id)) {
+ citedItems.push(itemIndex);
+ itemRow.citedIn.push(citationIndex);
+ }
+ });
+ if (typeof citationItem.id != 'number') {
+ isLinked = false;
+ break;
+ }
+ }
+ let title = await citation.field.getText();
+ if (citation.properties.plainCitation != title) {
+ let d = diff(citation.properties.plainCitation, title);
+ title = d.map(([type, text]) => {
+ if (type == 0) return text;
+ if (type == -1) return `${text}`;
+ if (type == 1) return `${text}`;
+ }).join('');
+ }
+ return {
+ title: title,
+ isLinked: isLinked,
+ citedItems,
+ highlighted: this._highlightedCitations.has(citation.citationID),
+ ref: citation
+ };
+ }));
+ },
+
+ /**
+ * Select citation in text
+ * @returns {Promise}
+ * @private
+ */
+ onCitationActivate: async function () {
+ if (disableCitationActivate) return;
+ const citation = citations[citationList.selection.focused];
+ try {
+ await io.selectCitation(citation);
+ const isCitationActivated = await io.cursorInCitation(citation);
+ if (isCitationActivated) {
+ await io.activateDocument();
+ return;
+ }
+ }
+ catch (e) { }
+ // An error got thrown or wrong citation got activated, which means that some citations got deleted
+ // and now the citation explorer dialog is not showing correct citations and citation
+ // activation is not going to work right.
+
+ var ps = Services.prompt;
+ var title = Zotero.getString('general.warning');
+ var message = Zotero.getString('integration.citationExplorer.citationsModified', [Zotero.appName]);
+ ps.alert(window, title, message);
+ disableCitationActivate = true;
+ document.querySelector('#button-show-in-document').disabled = true;
+ document.querySelector('#button-edit-citation').disabled = true;
+ },
+
+ /**
+ * Highlight items that are cited in citation
+ * @returns {Promise}
+ * @private
+ */
+ onCitationSelectionChange: async function () {
+ let highlightedItems = [];
+ for (let index of citationList.selection.selected) {
+ for (let item of citations[index].citationItems) {
+ if (item.cslItemID) {
+ highlightedItems.push(item.cslItemID);
+ }
+ else {
+ highlightedItems.push(item.id);
+ }
+ }
+ }
+ itemList.setHighlightedRows(highlightedItems);
+ },
+
+ onCitationEdit: async function () {
+ let citation = citations[citationList.selection.focused];
+ io.openCitationDialog = citation._field;
+ window.close();
+ },
+
+ /**
+ * Highlight citations that contain item
+ * @returns {Promise}
+ * @private
+ */
+ onItemSelectionChange: async function () {
+ if (selectedTab === 1) return;
+ this._highlightedCitations = new Set();
+ for (let selectedItemIndex of itemList.selection.selected) {
+ for (let citationIndex of itemRows[selectedItemIndex].citedIn) {
+ this._highlightedCitations.add(citations[citationIndex].citationID);
+ }
+ }
+ const item = itemList.getRow(itemList.selection.focused).ref;
+ const isUnlinked = typeof item.id != 'number';
+ const isMultiple = itemList.selection.selected.size > 1;
+ document.querySelector('#button-show-in-zotero').hidden = isMultiple || isUnlinked;
+ document.querySelector('#button-relink-item').hidden = isMultiple || !isUnlinked;
+ document.querySelector('#button-addTo-library').style.display = (isMultiple || !isUnlinked) ? 'none' : 'inherit';
+
+ await this.refreshCitationList();
+ },
+
+ onItemActivate: async function () {
+ if (itemList.selection.selected.size > 1) return;
+ const item = itemList.getRow(itemList.selection.focused).ref;
+ if (typeof item.id != 'number') {
+ this.onItemRelink();
+ }
+ else {
+ await Zotero.Utilities.Internal.showInLibrary(item.id);
+ }
+ },
+
+ onItemRelink: async function () {
+ let io = { dataIn: null, dataOut: null, multiSelect: false, deferred: Zotero.Promise.defer() };
+ window.openDialog('chrome://zotero/content/selectItemsDialog.xhtml', '',
+ 'chrome,dialog=no,centerscreen,resizable=yes', io);
+
+ await io.deferred.promise;
+ if (!io.dataOut || !io.dataOut.length) {
+ return;
+ }
+
+ let items = await Zotero.Items.getAsync(io.dataOut);
+ if (!items.length) {
+ return;
+ }
+ let treeRow = itemList.getRow(itemList.selection.focused);
+ const oldItemID = treeRow.id;
+ const itemIdx = itemRows.findIndex(row => row.id === treeRow.id);
+ this._linkItem(items[0], oldItemID, itemIdx);
+
+ await this._initMappings();
+ await this.refreshCitationList();
+ await this.refreshItemList();
+ },
+
+ async addToLibraryAndLink() {
+ var collectionID = _addToTarget.objectType == 'collection' ? _addToTarget.id : undefined;
+ for (let index of itemList.selection.selected) {
+ let treeRow = itemList.getRow(index);
+ const oldItemID = treeRow.id;
+ const itemIdx = itemRows.findIndex(row => row.id === treeRow.id);
+
+ // Save item
+ let item = treeRow.ref.clone(_addToTarget.libraryID);
+ if (collectionID) {
+ item.addToCollection(collectionID);
+ }
+ await item.saveTx();
+ this._linkItem(item, oldItemID, itemIdx);
+ }
+ await this._initMappings();
+ await this.refreshCitationList();
+ await this.refreshItemList();
+ },
+
+ _linkItem(item, oldItemID, itemIdx) {
+ // For all citations where the item is cited
+ for (let citationIndex of itemRows[itemIdx].citedIn) {
+ let citation = citations[citationIndex];
+ let citationItemIdx = citation.citationItems.findIndex(i => i.id == oldItemID);
+ let citationItem = citation.citationItems[citationItemIdx];
+ // Update the citation with the new item
+ citationItem.id = item.id;
+ citationItem.uris = Zotero.Integration.currentSession.uriMap.getURIsForItemID(citationItem.id);
+ // Mark citation for an update with citeproc and write changes to doc
+ io.updateIndex(citationIndex);
+ }
+ items[itemIdx] = item;
+ },
+
+ buildAddToLibraryContextMenu(event) {
+ var menu = document.querySelector('#item-addTo-menu');
+ // Don't trigger rebuilding on nested popupmenu open/close
+ if (event.target != menu) {
+ return;
+ }
+ // Clear previous items
+ while (menu.firstChild) {
+ menu.removeChild(menu.firstChild);
+ }
+
+ let target = Zotero.Prefs.get('documentCitations.lastAddToTarget');
+ if (!target) {
+ target = "L" + Zotero.Libraries.userLibraryID;
+ }
+
+ var libraries = Zotero.Libraries.getAll();
+ for (let library of libraries) {
+ if (!library.editable || library.libraryType == 'publications') {
+ continue;
+ }
+ Zotero.Utilities.Internal.createMenuForTarget(
+ library,
+ menu,
+ target,
+ function(event, libraryOrCollection) {
+ if (event.target.tagName == 'menu') {
+ Zotero.Promise.coroutine(function* () {
+ // Simulate menuitem flash on OS X
+ if (Zotero.isMac) {
+ event.target.setAttribute('_moz-menuactive', false);
+ yield Zotero.Promise.delay(50);
+ event.target.setAttribute('_moz-menuactive', true);
+ yield Zotero.Promise.delay(50);
+ event.target.setAttribute('_moz-menuactive', false);
+ yield Zotero.Promise.delay(50);
+ event.target.setAttribute('_moz-menuactive', true);
+ }
+ menu.hidePopup();
+
+ ZoteroDocumentCitations.setAddToTarget(libraryOrCollection);
+ event.stopPropagation();
+ })();
+ }
+ else {
+ ZoteroDocumentCitations.setAddToTarget(libraryOrCollection);
+ event.stopPropagation();
+ }
+ }
+ );
+ }
+ },
+
+ setAddToTarget(translationTarget) {
+ _addToTarget = translationTarget;
+ Zotero.Prefs.set('documentCitations.lastAddToTarget', translationTarget.treeViewID);
+ this.setAddToButton();
+ },
+
+ setAddToButton() {
+ var label = Zotero.getString('pane.item.addTo', _addToTarget.name);
+ var elem = document.querySelector('#button-addTo-library');
+ elem.label = label;
+ elem.title = label;
+ elem.image = _addToTarget.treeViewImage;
+ },
+
+
+ /**
+ * @param {String} s
+ * @return {String}
+ */
+ _normalizeSearch(s) {
+ return Zotero.Utilities.removeDiacritics(
+ Zotero.Utilities.trimInternal(s).toLowerCase(),
+ true);
+ },
+
+};
+
+window.addEventListener('DOMContentLoaded', function () {
+ ZoteroDocumentCitations.init();
+});
\ No newline at end of file
diff --git a/chrome/content/zotero/integration/citationExplorer.xhtml b/chrome/content/zotero/integration/citationExplorer.xhtml
new file mode 100644
index 0000000000..62ddb9c227
--- /dev/null
+++ b/chrome/content/zotero/integration/citationExplorer.xhtml
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/chrome/content/zotero/integration/quickFormat.js b/chrome/content/zotero/integration/quickFormat.js
index 88a4a83faf..7ad3e09b2f 100644
--- a/chrome/content/zotero/integration/quickFormat.js
+++ b/chrome/content/zotero/integration/quickFormat.js
@@ -2336,23 +2336,7 @@ var Zotero_QuickFormat = new function () {
this.showInLibrary = async function (itemID) {
let citationItem = JSON.parse(panelRefersToBubble?.dataset.citationItem || "{}");
var id = itemID || citationItem.id;
- var pane = Zotero.getActiveZoteroPane();
- // Open main window if it's not open (Mac)
- if (!pane) {
- let win = Zotero.openMainWindow();
- await new Zotero.Promise((resolve) => {
- let onOpen = function () {
- win.removeEventListener('load', onOpen);
- resolve();
- };
- win.addEventListener('load', onOpen);
- });
- pane = win.ZoteroPane;
- }
- pane.selectItem(id);
-
- // Pull window to foreground
- Zotero.Utilities.Internal.activate(pane.document.defaultView);
+ Zotero.Utilities.Internal.showInLibrary(id);
}
/**
diff --git a/chrome/content/zotero/itemTree.jsx b/chrome/content/zotero/itemTree.jsx
index a4f3bd7d5e..28e2350275 100644
--- a/chrome/content/zotero/itemTree.jsx
+++ b/chrome/content/zotero/itemTree.jsx
@@ -53,6 +53,23 @@ const CHILD_INDENT = 16;
const COLORED_TAGS_RE = new RegExp("^(?:Numpad|Digit)([0-" + Zotero.Tags.MAX_COLORED_TAGS + "]{1})$");
const COLUMN_PREFS_FILEPATH = OS.Path.join(Zotero.Profile.dir, "treePrefs.json");
const ATTACHMENT_STATE_LOAD_DELAY = 150; //ms
+const STUB_COLLECTION_TREE_ROW = {
+ view: {},
+ ref: {},
+ visibilityGroup: "",
+ isSearchMode: () => false,
+ getItems: async () => [],
+ isLibrary: () => false,
+ isCollection: () => false,
+ isSearch: () => false,
+ isPublications: () => false,
+ isDuplicates: () => false,
+ isFeed: () => false,
+ isFeeds: () => false,
+ isFeedsOrFeed: () => false,
+ isShare: () => false,
+ isTrash: () => false
+};
var ItemTree = class ItemTree extends LibraryTree {
static async init(domEl, opts={}) {
@@ -83,6 +100,9 @@ var ItemTree = class ItemTree extends LibraryTree {
dragAndDrop: false,
persistColumns: false,
columnPicker: false,
+ regularOnly: false,
+ multiSelect: true,
+ shouldListenForNotifications: true,
columns: COLUMNS,
onContextMenu: noop,
onActivate: noop,
@@ -96,6 +116,9 @@ var ItemTree = class ItemTree extends LibraryTree {
dragAndDrop: PropTypes.bool,
persistColumns: PropTypes.bool,
columnPicker: PropTypes.bool,
+ regularOnly: PropTypes.bool,
+ multiSelect: PropTypes.bool,
+ shouldListenForNotifications: PropTypes.bool,
columns: PropTypes.array,
onSelectionChange: PropTypes.func,
onContextMenu: PropTypes.func,
@@ -117,18 +140,21 @@ var ItemTree = class ItemTree extends LibraryTree {
this._introText = null;
this._rowCache = {};
+ this._highlightedRows = new Set();
this._modificationLock = Zotero.Promise.resolve();
this._refreshPromise = Zotero.Promise.resolve();
this._dropRow = null;
- this._unregisterID = Zotero.Notifier.registerObserver(
- this,
- ['item', 'collection-item', 'item-tag', 'share-items', 'bucket', 'feedItem', 'search', 'itemtree', 'collection'],
- 'itemTreeView',
- 50
- );
+ if (props.shouldListenForNotifications) {
+ this._unregisterID = Zotero.Notifier.registerObserver(
+ this,
+ ['item', 'collection-item', 'item-tag', 'share-items', 'bucket', 'feedItem', 'search', 'itemtree', 'collection'],
+ 'itemTreeView',
+ 50
+ );
+ }
this._itemsPaneMessage = null;
@@ -245,7 +271,7 @@ var ItemTree = class ItemTree extends LibraryTree {
// TEMP: Hide annotations
newSearchItems = newSearchItems.filter(item => !item.isAnnotation());
// Remove notes and attachments if necessary
- if (this.regularOnly) {
+ if (this.props.regularOnly) {
newSearchItems = newSearchItems.filter((item) => {
return item instanceof Zotero.Collection
|| item instanceof Zotero.Search
@@ -257,12 +283,11 @@ var ItemTree = class ItemTree extends LibraryTree {
let itemsToAdd = newSearchItems.filter(item => this._rowMap[item.treeViewID] === undefined);
// Find the parents of search matches
let newSearchParentIDs = new Set(
- this.regularOnly
+ this.props.regularOnly
? []
: newSearchItems.filter(item => !!item.parentItemID).map(item => item.parentItemID)
);
this._searchParentIDs = newSearchParentIDs;
- newSearchItems = new Set(newSearchItems);
var newCellTextCache = {};
var newSearchMode = this.collectionTreeRow.isSearchMode();
@@ -286,7 +311,7 @@ var ItemTree = class ItemTree extends LibraryTree {
}
let isSearchParent = newSearchParentIDs.has(row.ref.treeViewID);
// If not showing children or no children match the search, close
- if (this.regularOnly || !isSearchParent) {
+ if (this.props.regularOnly || !isSearchParent) {
row.isOpen = false;
skipChildren = true;
}
@@ -294,7 +319,7 @@ var ItemTree = class ItemTree extends LibraryTree {
skipChildren = false;
}
// Skip items that don't match the search and don't have children that do
- if (!newSearchItems.has(row.ref) && !isSearchParent) {
+ if (!newSearchItemIDs.has(row.ref.id) && !isSearchParent) {
continue;
}
}
@@ -907,13 +932,10 @@ var ItemTree = class ItemTree extends LibraryTree {
// view (e.g., as triggered by itemsView.runListeners('select') in ZoteroPane::itemSelected())
// before returning. This guarantees that changes are reflected in the middle and right-hand panes
// before returning from the save transaction.
- //
- // If no onselect handler is set on the tree element, as is the case in the Advanced Search window,
- // the select listeners never get called, so don't wait.
- if (reselect && this.props.onSelectionChange) {
+ if (reselect) {
var selectPromise = this.waitForSelect();
+ // Triggers reselect on the item tree and fires a select event
this.selection.selectEventsSuppressed = false;
- Zotero.debug("Yielding for select promise"); // TEMP
return selectPromise;
}
else {
@@ -1104,6 +1126,16 @@ var ItemTree = class ItemTree extends LibraryTree {
}
async changeCollectionTreeRow(collectionTreeRow) {
+ // When used outside the Zotero Pane, "collectionTreeRow" is not an actual
+ // tree row being passed in, but rather a simple object that defines necessary properties.
+ // So we need to supplement other CollectionTreeRow properties so that itemTree code
+ // does not complain.
+ // Obviously this is not ideal and would be best refactored so that collection
+ // tree row dependencies are specified separately
+ // and there's a separate method to refresh the items.
+ if (collectionTreeRow.constructor.name == "Object") {
+ collectionTreeRow = Object.assign({}, STUB_COLLECTION_TREE_ROW, collectionTreeRow);
+ }
if (this._locked) return;
if (!collectionTreeRow) {
this.tree = null;
@@ -1113,7 +1145,10 @@ var ItemTree = class ItemTree extends LibraryTree {
Zotero.debug(`itemTree.changeCollectionTreeRow(): ${collectionTreeRow.id}`);
this._itemTreeLoadingDeferred = Zotero.Promise.defer();
this.setItemsPaneMessage(Zotero.getString('pane.items.loading'));
- let newId = "item-tree-" + this.props.id + "-" + collectionTreeRow.visibilityGroup;
+ let newId = "item-tree-" + this.props.id;
+ if (collectionTreeRow.visibilityGroup) {
+ newId += "-" + collectionTreeRow.visibilityGroup;
+ }
if (this.id != newId && this.props.persistColumns) {
await this._writeColumnPrefsToFile(true);
this.id = newId;
@@ -1491,7 +1526,10 @@ var ItemTree = class ItemTree extends LibraryTree {
fieldB = creatorSortCache[bItemID];
var prop = sortCreatorAsString ? 'firstCreator' : 'sortCreator';
var sortStringA = itemA[prop];
+ // Unsaved items like those embedded in documents
+ if (!sortStringA) sortStringA = itemA.getField('firstCreator');
var sortStringB = itemB[prop];
+ if (!sortStringB) sortStringB = itemB.getField('firstCreator');
if (fieldA === undefined) {
let firstCreator = Zotero.Items.getSortTitle(sortStringA);
fieldA = firstCreator;
@@ -1633,6 +1671,14 @@ var ItemTree = class ItemTree extends LibraryTree {
this.ensureRowIsVisible(indices[0] + maxBuffer);
}
+ async setHighlightedRows(ids) {
+ if (!Array.isArray(ids)) {
+ return;
+ }
+ this._highlightedRows = new Set(ids);
+ this.tree.invalidate();
+ }
+
toggleOpenState = async (index, skipRowMapRefresh=false) => {
// Shouldn't happen but does if an item is dragged over a closed
// container until it opens and then released, since the container
@@ -1800,7 +1846,11 @@ var ItemTree = class ItemTree extends LibraryTree {
}
getRowString(index) {
- return this.getCellText(index, this.getSortField())
+ let row = this.getRow(index);
+ if (row.ref.isFeedItem) {
+ return this.getCellText(index, 'title');
+ }
+ return this.getCellText(index, this.getSortField());
}
async deleteSelection(force) {
@@ -2014,7 +2064,7 @@ var ItemTree = class ItemTree extends LibraryTree {
};
isContainerEmpty = (index) => {
- if (this.regularOnly) {
+ if (this.props.regularOnly) {
return true;
}
@@ -3056,6 +3106,7 @@ var ItemTree = class ItemTree extends LibraryTree {
const rowData = this._getRowData(index);
div.classList.toggle('context-row', !!rowData.contextRow);
div.classList.toggle('unread', !!rowData.unread);
+ div.classList.toggle('highlighted', this._highlightedRows.has(rowData.id));
if (this._dropRow == index) {
let span;
if (Zotero.DragDrop.currentOrientation != 0) {
@@ -3204,6 +3255,7 @@ var ItemTree = class ItemTree extends LibraryTree {
}
let row = {
+ id: itemID,
// Not a collection or search in the trash
isItem: treeRow.ref instanceof Zotero.Item
};
@@ -3406,7 +3458,7 @@ var ItemTree = class ItemTree extends LibraryTree {
let hasDefaultIn = columns.some(column => 'defaultIn' in column);
for (let column of columns) {
if (this.props.persistColumns) {
- if (column.disabledIn && column.disabledIn.includes(visibilityGroup)) continue;;
+ if (column.disabledIn && column.disabledIn.includes(visibilityGroup)) continue;
const columnSettings = columnsSettings[column.dataKey];
if (!columnSettings && this.id === 'main') {
column = this._setLegacyColumnSettings(column);
@@ -3414,10 +3466,6 @@ var ItemTree = class ItemTree extends LibraryTree {
// Also includes a `hidden` pref and overrides the above if available
column = Object.assign({}, column, columnSettings || {});
-
- if (column.sortDirection) {
- this._sortedColumn = column;
- }
// If column does not have an "ordinal" field it means it
// is newly added
if (!("ordinal" in column)) {
@@ -3429,13 +3477,16 @@ var ItemTree = class ItemTree extends LibraryTree {
}
// Initial hidden value
if (!("hidden" in column)) {
- if (hasDefaultIn) {
+ if (hasDefaultIn && visibilityGroup) {
column.hidden = !(column.defaultIn && column.defaultIn.includes(visibilityGroup));
}
else {
column.hidden = false;
}
}
+ if (column.sortDirection) {
+ this._sortedColumn = column;
+ }
this._columns.push(column);
}
@@ -3919,7 +3970,10 @@ var ItemTreeRow = function(ref, level, isOpen)
ItemTreeRow.prototype.getField = function(field, unformatted)
{
- if (!Zotero.ItemTreeManager.isCustomColumn(field)) {
+ if (this.ref.hasOwnProperty(field) && this.ref[field] != null){
+ return this.ref[field];
+ }
+ else if (!Zotero.ItemTreeManager.isCustomColumn(field)) {
return this.ref.getField(field, unformatted, true);
}
return Zotero.ItemTreeManager.getCustomCellData(this.ref, field);
diff --git a/chrome/content/zotero/itemTreeColumns.jsx b/chrome/content/zotero/itemTreeColumns.jsx
index bf30f1b033..20c185eece 100644
--- a/chrome/content/zotero/itemTreeColumns.jsx
+++ b/chrome/content/zotero/itemTreeColumns.jsx
@@ -40,7 +40,6 @@ const Icons = require('components/icons');
* @property {string} [width] - A column width instead of flex ratio. See above.
* @property {boolean} [fixedWidth] - Default: false. Set to true to disable column resizing
* @property {boolean} [staticWidth] - Default: false. Set to true to prevent columns from changing width when the width of the tree increases or decreases
- * @property {boolean} [noPadding] - Set to true for columns with padding disabled in stylesheet
* @property {number} [minWidth] - Override the default [20px] column min-width for resizing
* @property {React.Component} [iconLabel] - Set an Icon label instead of a text-based one
* @property {string} [iconPath] - Set an Icon path, overrides {iconLabel}
@@ -338,7 +337,6 @@ const COLUMNS = [
iconLabel: ,
fixedWidth: true,
width: "32",
- noPadding: true,
zoteroPersist: ["hidden", "sortDirection"]
},
{
@@ -350,7 +348,6 @@ const COLUMNS = [
width: "26",
minWidth: 26,
staticWidth: true,
- noPadding: true,
zoteroPersist: ["width", "hidden", "sortDirection"]
},
{
diff --git a/chrome/content/zotero/libraryTree.js b/chrome/content/zotero/libraryTree.js
index a60cbb6538..a749c0dc81 100644
--- a/chrome/content/zotero/libraryTree.js
+++ b/chrome/content/zotero/libraryTree.js
@@ -194,9 +194,12 @@ var LibraryTree = class LibraryTree extends React.Component {
this._rowMap = rowMap;
}
- _onSelectionChange = () => {
- if (!this._uninitialized) {
- this.props.onSelectionChange && this.props.onSelectionChange(this.selection);
+ _onSelectionChange = async () => {
+ if (!this._uninitialized && this.props.onSelectionChange) {
+ try {
+ await Zotero.Promise.resolve(this.props.onSelectionChange(this.selection));
+ } catch (e) {}
+ this.runListeners('select');
}
}
diff --git a/chrome/content/zotero/selectItemsDialog.js b/chrome/content/zotero/selectItemsDialog.js
index ce72d66476..b2f2069f3b 100644
--- a/chrome/content/zotero/selectItemsDialog.js
+++ b/chrome/content/zotero/selectItemsDialog.js
@@ -69,12 +69,8 @@ var doLoad = async function () {
Zotero_Bibliography_Dialog.treeItemSelected();
}
else if (isAddEditItemsDialog) {
- onItemSelected();
Zotero_Citation_Dialog.treeItemSelected();
}
- else {
- onItemSelected();
- }
},
onActivate: () => {
document.querySelector('dialog').acceptDialog();
@@ -83,15 +79,15 @@ var doLoad = async function () {
dragAndDrop: false,
persistColumns: true,
columnPicker: true,
- emptyMessage: Zotero.getString('pane.items.loading'),
- multiSelect: !io.singleSelection
+ multiSelect: io.multiSelect,
+ emptyMessage: Zotero.getString('pane.items.loading')
});
itemsView.setItemsPaneMessage(Zotero.getString('pane.items.loading'));
const filterLibraryIDs = false || io.filterLibraryIDs;
const hideSources = io.hideCollections || ['duplicates', 'trash', 'feeds'];
collectionsView = await CollectionTree.init(document.getElementById('zotero-collections-tree'), {
- onSelectionChange: Zotero.Utilities.debounce(() => onCollectionSelected(), 100),
+ onSelectionChange: () => onCollectionSelected(),
filterLibraryIDs,
hideSources
});
@@ -161,8 +157,6 @@ var onCollectionSelected = async function () {
await itemsView.changeCollectionTreeRow(collectionTreeRow);
itemsView.clearItemsPaneMessage();
-
- collectionsView.runListeners('select');
};
function onSearch()
@@ -174,25 +168,6 @@ function onSearch()
}
}
-function onItemSelected()
-{
- itemsView.runListeners('select');
- if (io.onlyRegularItems) {
- // Disable "accept" button if a top-level item isn't selected
- let selected = itemsView.getSelectedItems();
- let disableAccept = (selected && !selected.every(item => item.isRegularItem()));
- // TEMP: Disable the button directly only as long as we move the button box in doLoad().
- // Then, we should set buttondisabledaccept attribute on the dialog
- if (disableAccept) {
- document.querySelector("dialog button[dlgtype='accept']").setAttribute("disabled", true);
- }
- else {
- // Remove disabled attribute since the stylesheet looks at disabled attribute
- document.querySelector("dialog button[dlgtype='accept']").removeAttribute("disabled");
- }
- }
-}
-
function doAccept() {
io.dataOut = itemsView.getSelectedItems(true);
}
diff --git a/chrome/content/zotero/xpcom/connector/server_connector.js b/chrome/content/zotero/xpcom/connector/server_connector.js
index 68d7a968f5..b3d5f7db29 100644
--- a/chrome/content/zotero/xpcom/connector/server_connector.js
+++ b/chrome/content/zotero/xpcom/connector/server_connector.js
@@ -1700,6 +1700,7 @@ Zotero.Server.Connector.Ping.prototype = {
prefs: {
automaticSnapshots: Zotero.Prefs.get('automaticSnapshots'),
googleDocsAddNoteEnabled: true,
+ googleDocsCitationExplorerEnabled: true,
translatorsHash,
sortedTranslatorHash
}
diff --git a/chrome/content/zotero/xpcom/integration.js b/chrome/content/zotero/xpcom/integration.js
index 995514f7fd..64d80fb569 100644
--- a/chrome/content/zotero/xpcom/integration.js
+++ b/chrome/content/zotero/xpcom/integration.js
@@ -432,7 +432,7 @@ Zotero.Integration = new function() {
}
}
- Zotero.Utilities.Internal.activate();
+ Zotero.Utilities.Internal.activate(Zotero.Integration.currentWindow);
let ps = Services.prompt;
if (e instanceof Zotero.Exception.Alert) {
ps.alert(null, Zotero.getString('integration.error.title'), displayError);
@@ -569,7 +569,7 @@ Zotero.Integration = new function() {
// and display wrong field types in doc preferences.
if (!session || session.agent != agent) {
session = new Zotero.Integration.Session(doc, app);
- session.reload = true;
+ session.rebuildCiteprocState = true;
}
session.agent = agent;
session._doc = doc;
@@ -581,6 +581,7 @@ Zotero.Integration = new function() {
session._deleteFields = {};
session._bibliographyFields = [];
session._shouldMerge = false;
+ session._transactionUpToDate = false;
if (dataString == EXPORTED_DOCUMENT_MARKER) {
Zotero.Integration.currentSession = session;
@@ -708,7 +709,7 @@ Zotero.Integration.Interface.prototype.addCitation = async function () {
let citations = await this._session.cite(null);
if (this._session.data.prefs.delayCitationUpdates) {
for (let citation of citations) {
- await this._session.writeDelayedCitation(citation._field, citation);
+ await this._session.writeDelayedCitation(citation.field, citation);
}
}
else {
@@ -741,7 +742,7 @@ Zotero.Integration.Interface.prototype.addEditCitation = async function (docFiel
let citations = await this._session.cite(docField);
if (this._session.data.prefs.delayCitationUpdates) {
for (let citation of citations) {
- await this._session.writeDelayedCitation(citation._field, citation);
+ await this._session.writeDelayedCitation(citation.field, citation);
}
} else {
return this._session.updateDocument(FORCE_CITATIONS_FALSE, false, false);
@@ -763,7 +764,7 @@ Zotero.Integration.Interface.prototype.addNote = async function () {
let citations = await this._session.cite(null, true);
if (this._session.data.prefs.delayCitationUpdates) {
for (let citation of citations) {
- await this._session.writeDelayedCitation(citation._field, citation);
+ await this._session.writeDelayedCitation(citation.field, citation);
}
}
else {
@@ -789,7 +790,7 @@ Zotero.Integration.Interface.prototype.addBibliography = Zotero.Promise.coroutin
yield field.clearCode();
if(this._session.data.prefs.delayCitationUpdates) {
// Refreshes citeproc state before proceeding
- this._session.reload = true;
+ this._session.rebuildCiteprocState = true;
citationsMode = FORCE_CITATIONS_REGENERATE;
}
yield this._session.updateFromDocument(citationsMode);
@@ -822,7 +823,7 @@ Zotero.Integration.Interface.prototype.editBibliography = Zotero.Promise.corouti
var citationsMode = FORCE_CITATIONS_FALSE;
if(this._session.data.prefs.delayCitationUpdates) {
// Refreshes citeproc state before proceeding
- this._session.reload = true;
+ this._session.rebuildCiteprocState = true;
citationsMode = FORCE_CITATIONS_REGENERATE;
}
yield this._session.updateFromDocument(citationsMode);
@@ -861,14 +862,31 @@ Zotero.Integration.Interface.prototype.addEditBibliography = Zotero.Promise.coro
var citationsMode = FORCE_CITATIONS_FALSE;
if(this._session.data.prefs.delayCitationUpdates) {
// Refreshes citeproc state before proceeding
- this._session.reload = true;
+ this._session.rebuildCiteprocState = true;
citationsMode = FORCE_CITATIONS_REGENERATE;
}
yield this._session.updateFromDocument(citationsMode);
- if (!newBibliography) yield this._session.editBibliography(bibliography);
+ if (!newBibliography) {
+ yield this._session.editBibliography(bibliography);
+ }
yield this._session.updateDocument(citationsMode, true, false);
});
+Zotero.Integration.Interface.prototype.citationExplorer = async function () {
+ await this._session.init(true, false);
+
+ var citationsMode = FORCE_CITATIONS_FALSE;
+ if(this._session.data.prefs.delayCitationUpdates) {
+ // Refreshes citeproc state before proceeding
+ this._session.rebuildCiteprocState = true;
+ citationsMode = FORCE_CITATIONS_REGENERATE;
+ }
+ await this._session.updateFromDocument(citationsMode);
+ await this._session.openCitationExplorer();
+ return this._session.updateDocument(citationsMode, true, false);
+}
+
+
/**
* Updates the citation data for all citations and bibliography entries.
* @return {Promise}
@@ -877,7 +895,7 @@ Zotero.Integration.Interface.prototype.refresh = async function() {
await this._session.init(true, false);
this._session._shouldMerge = true;
- this._session.reload = this._session.reload || this._session.data.prefs.delayCitationUpdates;
+ this._session.rebuildCiteprocState = this._session.rebuildCiteprocState || this._session.data.prefs.delayCitationUpdates;
await this._session.updateFromDocument(FORCE_CITATIONS_REGENERATE);
await this._session.updateDocument(FORCE_CITATIONS_REGENERATE, true, false);
}
@@ -1003,7 +1021,17 @@ Zotero.Integration.Session = function(doc, app) {
this.secondaryFieldType = app.secondaryFieldType;
this.outputFormat = app.outputFormat || 'rtf';
this._dontActivateDocument = false;
+
+ // Set to true upon updateFromDocument()
+ // Changes to false after delayed citation insert
+ // updateFromDocument() not called for most operations in delayed update mode
this._sessionUpToDate = false;
+ // Set to true upon updateFromDocument()
+ // Changes to false on new transaction
+ this._transactionUpToDate = false;
+ // Controls whether adjacent fields should be merged
+ // Generally only enabled for Interface.refresh() calls
+ this._shouldMerge = false;
this._app = app;
this._fields = null;
@@ -1103,7 +1131,10 @@ Zotero.Integration.Session.prototype.getFields = new function() {
/**
* Updates Zotero.Integration.Session citations from the session document
*/
-Zotero.Integration.Session.prototype.updateFromDocument = Zotero.Promise.coroutine(function* (forceCitations) {
+Zotero.Integration.Session.prototype.updateFromDocument = Zotero.Promise.coroutine(function* (forceUpdateAllCitations) {
+ if (this._transactionUpToDate) {
+ return;
+ }
yield this.getFields();
this.resetRequest(this._doc);
@@ -1114,11 +1145,11 @@ Zotero.Integration.Session.prototype.updateFromDocument = Zotero.Promise.corouti
var timer = new Zotero.Integration.Timer();
timer.start();
this.progressBar.start();
- if (forceCitations) {
- this.regenAll = true;
+ if (forceUpdateAllCitations) {
+ this.forceUpdateAllCitations = true;
// See Session.restoreProcessorState() for a comment
if (!Zotero.Prefs.get('cite.useCiteprocRs')) {
- this.reload = true;
+ this.rebuildCiteprocState = true;
}
}
yield this._processFields();
@@ -1129,18 +1160,18 @@ Zotero.Integration.Session.prototype.updateFromDocument = Zotero.Promise.corouti
Zotero.debug('Retracted item handling failed', 2);
Zotero.logError(e);
}
- this.regenAll = false;
+ this.forceUpdateAllCitations = false;
var updateTime = timer.stop();
this.progressBar.finishSegment();
Zotero.debug("Integration: Updated session data for " + this._fields.length + " fields in "
+ updateTime + "; " + this._fields.length/updateTime + " fields/second");
- if (this.reload) {
+ if (this.rebuildCiteprocState) {
this.restoreProcessorState();
- delete this.reload;
+ this.rebuildCiteprocState = false;
}
- this._sessionUpToDate = true;
+ this._sessionUpToDate = this._transactionUpToDate = true;
});
/**
@@ -1158,6 +1189,7 @@ Zotero.Integration.Session.prototype._processFields = async function () {
var noteIndex = await field.getNoteIndex(),
data = await field.unserialize(),
citation = new Zotero.Integration.Citation(field, data, noteIndex);
+ citation.fieldIndex = i;
if (this._shouldMerge && typeof field.isAdjacentToNextField === 'function' && await field.isAdjacentToNextField()) {
adjacentCitations.push(citation);
@@ -1307,7 +1339,7 @@ Zotero.Integration.Session.prototype._updateDocument = async function(forceCitat
var citation = this.citationsByIndex[i];
if (citation) {
- let citationField = citation._field;
+ let citationField = citation.field;
var isRich = false;
if (!citation.properties.dontUpdate) {
@@ -1476,9 +1508,12 @@ Zotero.Integration.Session.prototype.cite = async function (field, addNote=false
// Preparing data to pass into CitationEditInterface
var fieldIndexPromise, citationsByItemIDPromise;
- if (!this.data.prefs.delayCitationUpdates
- || !Object.keys(this.citationsByItemID).length
- || this._sessionUpToDate) {
+ const citationDataLoadedFromDocument = Object.keys(this.citationsByItemID).length;
+ if (this.data.prefs.delayCitationUpdates && citationDataLoadedFromDocument) {
+ fieldIndexPromise = Zotero.Promise.resolve(-1);
+ citationsByItemIDPromise = Zotero.Promise.resolve(this.citationsByItemID);
+ }
+ else {
fieldIndexPromise = this.getFields().then(async function (fields) {
for (var i = 0, n = fields.length; i < n; i++) {
if (await fields[i].equals(field._field)) {
@@ -1490,13 +1525,8 @@ Zotero.Integration.Session.prototype.cite = async function (field, addNote=false
}
return -1;
});
- citationsByItemIDPromise = this.updateFromDocument(FORCE_CITATIONS_FALSE).then(function() {
- return this.citationsByItemID;
- }.bind(this));
- }
- else {
- fieldIndexPromise = Zotero.Promise.resolve(-1);
- citationsByItemIDPromise = Zotero.Promise.resolve(this.citationsByItemID);
+ citationsByItemIDPromise =
+ this.updateFromDocument(FORCE_CITATIONS_FALSE).then(() => this.citationsByItemID);
}
var previewFn = async function (citation) {
@@ -1579,9 +1609,9 @@ Zotero.Integration.Session.prototype.cite = async function (field, addNote=false
}
for (let citation of citations) {
if (fields) {
- citation._field = new Zotero.Integration.CitationField(fields[citation._fieldIndex]);
+ citation.field = new Zotero.Integration.CitationField(fields[citation.fieldIndex]);
}
- await this.addCitation(citation._fieldIndex, await citation._field.getNoteIndex(), citation);
+ await this.addCitation(citation.fieldIndex, await citation.field.getNoteIndex(), citation);
}
return citations;
};
@@ -1677,7 +1707,7 @@ Zotero.Integration.Session.prototype._insertNoteIntoDocument = async function (f
let insertedCitations = await Promise.all(fields.map(async (field, index) => {
let citation = new Zotero.Integration.Citation(new Zotero.Integration.CitationField(field, 'TEMP'),
citations[index]);
- citation._fieldIndex = fieldIndex + fields.length - 1 - index;
+ citation.fieldIndex = fieldIndex + fields.length - 1 - index;
return citation;
}));
return insertedCitations;
@@ -1687,8 +1717,8 @@ Zotero.Integration.Session.prototype._insertItemsIntoDocument = async function (
if (!field) {
field = new Zotero.Integration.CitationField(await this.addField(true, fieldIndex));
}
- citation._field = field;
- citation._fieldIndex = fieldIndex;
+ citation.field = field;
+ citation.fieldIndex = fieldIndex;
return citation;
};
@@ -1807,8 +1837,11 @@ Zotero.Integration.Session.prototype.resetRequest = function(doc) {
// Citations that are not new to the session but where the item metadata
// has changed will be marked in updateIndices
this.updateIndices = {};
- // Citations that require updating in the document will be marked in
- // processIndices
+ // Forces all citations to be added to updateIndices, which runs them through
+ // citeproc and updates in document if necessary
+ this.forceUpdateAllCitations = false;
+ // Reloads citeproc with all citation data.
+ this.rebuildCiteprocState = false;
this.processIndices = {};
this.citationsByItemID = {};
@@ -1896,7 +1929,7 @@ Zotero.Integration.Session.prototype.setData = async function (data, resetStyle)
this.style = getStyle.getCiteProc(data.style.locale, this.outputFormat, data.prefs.automaticJournalAbbreviations);
this.styleClass = getStyle.class;
// We're changing the citeproc instance, so we'll have to reinsert all citations into the registry
- this.reload = true;
+ this.rebuildCiteprocState = true;
this.styleID = data.style.styleID;
} catch (e) {
Zotero.logError(e);
@@ -1976,9 +2009,8 @@ Zotero.Integration.Session.prototype.setDocPrefs = async function (showImportExp
|| oldData.prefs.fieldType != data.prefs.fieldType
|| (!data.prefs.delayCitationUpdates && oldData.prefs.delayCitationUpdates != data.prefs.delayCitationUpdates)
|| oldData.prefs.automaticJournalAbbreviations != data.prefs.automaticJournalAbbreviations) {
- // This will cause us to regenerate all citations
- this.regenAll = true;
- this.reload = true;
+ this.forceUpdateAllCitations = true;
+ this.rebuildCiteprocState = true;
}
return oldData || null;
@@ -2128,7 +2160,7 @@ Zotero.Integration.Session.prototype.addCitation = async function (index, noteIn
if (!this.oldCitations.has(citation.citationID)) {
this.newIndices[index] = true;
}
- if (this.regenAll && !this.newIndices[index]) {
+ if (this.forceUpdateAllCitations && !this.newIndices[index]) {
this.updateIndices[index] = true;
}
Zotero.debug("Integration: Adding citationID "+citation.citationID);
@@ -2332,8 +2364,9 @@ Zotero.Integration.Session.prototype.writeDelayedCitation = Zotero.Promise.corou
});
-Zotero.Integration.Session.prototype.getItems = function() {
- return Zotero.Cite.getItem(Object.keys(this.citationsByItemID));
+Zotero.Integration.Session.prototype.getItems = function(itemIDs) {
+ itemIDs = itemIDs || Object.keys(this.citationsByItemID);
+ return Zotero.Cite.getItem(itemIDs);
}
Zotero.Integration.Session.prototype.handleRetractedItems = async function () {
@@ -2403,6 +2436,44 @@ Zotero.Integration.Session.prototype.promptForRetraction = function (citedItem,
return checkbox.value;
}
+/**
+ * Opens the citation explorer
+ */
+Zotero.Integration.Session.prototype.openCitationExplorer = async function () {
+ if (!Object.keys(this.citationsByIndex).length) {
+ throw new Error('Integration.Session.openCitationExplorer: called without loaded citations');
+ }
+
+ let io = {
+ citations: this.citationsByIndex,
+ uncitedItems: this.bibliography ? Array.from(await this.getItems(Array.from(this.bibliography.uncitedItemIDs))) : [],
+ items: await this.getItems(),
+ activateDocument: async () => this._doc.activate(),
+ selectCitation: async citation => citation.field.select(),
+ cursorInCitation: async (citation) => {
+ const field = await this._doc.cursorInField(this.data.prefs['fieldType']);
+ if (!field) return false;
+ const citationField = await Zotero.Integration.Field.loadExisting(field);
+ const data = await citationField.unserialize();
+ return data.citationID === citation.citationID;
+ },
+ updateIndex: index => this.updateIndices[index] = true
+ };
+
+ await Zotero.Integration.displayDialog('chrome://zotero/content/integration/citationExplorer.xhtml', 'resizable', io);
+
+ if (io.openCitationDialog) {
+ let citations = await this.cite(io.openCitationDialog);
+ if (this.data.prefs.delayCitationUpdates) {
+ for (let citation of citations) {
+ await this.writeDelayedCitation(citation.field, citation);
+ }
+ } else {
+ return this.updateDocument(FORCE_CITATIONS_FALSE, false, false);
+ }
+ }
+};
+
/**
* Edits integration bibliography
@@ -3088,7 +3159,15 @@ Zotero.Integration.Citation = class {
this.properties = data.properties;
this.properties.noteIndex = noteIndex;
- this._field = citationField;
+ this.field = citationField;
+ }
+
+ /**
+ * @deprecated
+ */
+ get _field() {
+ Zotero.debug('Citation._field is deprecated. Use Citation.field');
+ return this.field;
}
/**
@@ -3225,7 +3304,7 @@ Zotero.Integration.Citation = class {
var msg = Zotero.getString("integration.missingItem.multiple", (idx).toString());
}
msg += '\n\n'+Zotero.getString('integration.missingItem.description');
- await this._field.select();
+ await this.field.select();
await Zotero.Integration.currentDoc.activate();
var result = await Zotero.Integration.currentSession.displayAlert(msg,
DIALOG_ICON_WARNING, DIALOG_BUTTONS_YES_NO_CANCEL);
@@ -3253,9 +3332,9 @@ Zotero.Integration.Citation = class {
// Check for modified field text or dontUpdate flag
if (this.properties.dontUpdate
|| (this.properties.plainCitation
- && await this._field.getText() !== this.properties.plainCitation)) {
+ && await this.field.getText() !== this.properties.plainCitation)) {
await Zotero.Integration.currentDoc.activate();
- var fieldText = await this._field.getText();
+ var fieldText = await this.field.getText();
Zotero.debug("[addEditCitation] Attempting to update manually modified citation.\n"
+ "citaion.properties.dontUpdate: " + this.properties.dontUpdate + "\n"
+ "Original: " + this.properties.plainCitation + "\n"
@@ -3336,7 +3415,7 @@ Zotero.Integration.Citation = class {
Zotero.Integration.Bibliography = class {
constructor(bibliographyField, data) {
- this._field = bibliographyField;
+ this.field = bibliographyField;
this.data = data;
this.uncitedItemIDs = new Set();
diff --git a/chrome/content/zotero/xpcom/utilities_internal.js b/chrome/content/zotero/xpcom/utilities_internal.js
index 9cd5432b51..03248f8db8 100644
--- a/chrome/content/zotero/xpcom/utilities_internal.js
+++ b/chrome/content/zotero/xpcom/utilities_internal.js
@@ -1737,6 +1737,28 @@ Zotero.Utilities.Internal = {
},
+ showInLibrary: async function (itemID) {
+ var pane = Zotero.getActiveZoteroPane();
+ // Open main window if it's not open (Mac)
+ if (!pane) {
+ let win = Zotero.openMainWindow();
+ await new Zotero.Promise((resolve) => {
+ let onOpen = function () {
+ win.removeEventListener('load', onOpen);
+ resolve();
+ };
+ win.addEventListener('load', onOpen);
+ });
+ pane = win.ZoteroPane;
+ }
+ pane.selectItem(itemID);
+
+ // Pull window to foreground
+ Zotero.Utilities.Internal.activate(pane.document.defaultView);
+ pane.document.ownerGlobal.focus();
+ },
+
+
filterStack: function (stack) {
return stack.split(/\n/)
.filter(line => !line.includes('resource://zotero/bluebird'))
@@ -2534,7 +2556,19 @@ Zotero.Utilities.Internal.activate = new function () {
* Bring a window to the foreground by interfacing directly with X11
*/
function _X11BringToForeground(win, intervalID) {
- var windowTitle = win.getInterface(Ci.nsIWebNavigation).title;
+ try {
+ var windowTitle = win.getInterface(Ci.nsIWebNavigation).title;
+ if (!windowTitle) {
+ windowTitle = win.document.title
+ }
+ if (!windowTitle) {
+ throw new Error(`Could not find window title for ${win.location.href}`);
+ }
+ } catch (e) {
+ Zotero.debug(`Could not find window title for ${win.location.href}`, 1);
+ Zotero.logError(e);
+ win.clearInterval(intervalID);
+ }
var x11Window = _X11FindWindow(_x11RootWindow, windowTitle);
if (!x11Window) return;
diff --git a/chrome/content/zotero/zoteroPane.js b/chrome/content/zotero/zoteroPane.js
index 9e00bc7c2f..733b415af3 100644
--- a/chrome/content/zotero/zoteroPane.js
+++ b/chrome/content/zotero/zoteroPane.js
@@ -1780,66 +1780,62 @@ var ZoteroPane = new function()
this.onCollectionSelected = Zotero.serial(async function () {
- try {
- var collectionTreeRow = this.getCollectionTreeRow();
- if (!collectionTreeRow) {
- Zotero.debug('ZoteroPane.onCollectionSelected: No selected collection found');
- return;
- }
-
- if (this.itemsView && this.itemsView.collectionTreeRow && this.itemsView.collectionTreeRow.id == collectionTreeRow.id) {
- Zotero.debug("ZoteroPane.onCollectionSelected: Collection selection hasn't changed");
-
- // Update enabled actions, in case editability has changed
- this._updateEnabledActionsForRow(collectionTreeRow);
- return;
- }
-
- // Rename tab
- Zotero_Tabs.rename('zotero-pane', collectionTreeRow.getName());
-
- let type = Zotero.Libraries.get(collectionTreeRow.ref.libraryID).libraryType;
-
- // Clear quick search and tag selector when switching views
- document.getElementById('zotero-tb-search-textbox').value = "";
- if (ZoteroPane.tagSelector) {
- ZoteroPane.tagSelector.clearTagSelection();
- }
-
- collectionTreeRow.setSearch('');
- if (ZoteroPane.tagSelector) {
- collectionTreeRow.setTags(ZoteroPane.tagSelector.getTagSelection());
- }
-
- this._updateEnabledActionsForRow(collectionTreeRow);
-
- // If item data not yet loaded for library, load it now.
- // Other data types are loaded at startup
- if (collectionTreeRow.isFeeds()) {
- var feedsToLoad = Zotero.Feeds.getAll().filter(feed => !feed.getDataLoaded('item'));
- if (feedsToLoad.length) {
- Zotero.debug("Waiting for items to load for feeds " + feedsToLoad.map(feed => feed.libraryID));
- ZoteroPane_Local.setItemsPaneMessage(Zotero.getString('pane.items.loading'));
- for (let feed of feedsToLoad) {
- await feed.waitForDataLoad('item');
- }
- }
- }
- else {
- var library = Zotero.Libraries.get(collectionTreeRow.ref.libraryID);
- if (!library.getDataLoaded('item')) {
- Zotero.debug("Waiting for items to load for library " + library.libraryID);
- ZoteroPane_Local.setItemsPaneMessage(Zotero.getString('pane.items.loading'));
- await library.waitForDataLoad('item');
- }
- }
-
- this.itemsView.changeCollectionTreeRow(collectionTreeRow);
-
- Zotero.Prefs.set('lastViewedFolder', collectionTreeRow.id);
- } finally {
- this.collectionsView.runListeners('select');
+ var collectionTreeRow = this.getCollectionTreeRow();
+ if (!collectionTreeRow) {
+ Zotero.debug('ZoteroPane.onCollectionSelected: No selected collection found');
+ return;
}
+
+ if (this.itemsView && this.itemsView.collectionTreeRow && this.itemsView.collectionTreeRow.id == collectionTreeRow.id) {
+ Zotero.debug("ZoteroPane.onCollectionSelected: Collection selection hasn't changed");
+
+ // Update enabled actions, in case editability has changed
+ this._updateEnabledActionsForRow(collectionTreeRow);
+ return;
+ }
+
+ // Rename tab
+ Zotero_Tabs.rename('zotero-pane', collectionTreeRow.getName());
+
+ let type = Zotero.Libraries.get(collectionTreeRow.ref.libraryID).libraryType;
+
+ // Clear quick search and tag selector when switching views
+ document.getElementById('zotero-tb-search-textbox').value = "";
+ if (ZoteroPane.tagSelector) {
+ ZoteroPane.tagSelector.clearTagSelection();
+ }
+
+ collectionTreeRow.setSearch('');
+ if (ZoteroPane.tagSelector) {
+ collectionTreeRow.setTags(ZoteroPane.tagSelector.getTagSelection());
+ }
+
+ this._updateEnabledActionsForRow(collectionTreeRow);
+
+ // If item data not yet loaded for library, load it now.
+ // Other data types are loaded at startup
+ if (collectionTreeRow.isFeeds()) {
+ var feedsToLoad = Zotero.Feeds.getAll().filter(feed => !feed.getDataLoaded('item'));
+ if (feedsToLoad.length) {
+ Zotero.debug("Waiting for items to load for feeds " + feedsToLoad.map(feed => feed.libraryID));
+ ZoteroPane_Local.setItemsPaneMessage(Zotero.getString('pane.items.loading'));
+ for (let feed of feedsToLoad) {
+ await feed.waitForDataLoad('item');
+ }
+ }
+ }
+ else {
+ var library = Zotero.Libraries.get(collectionTreeRow.ref.libraryID);
+ if (!library.getDataLoaded('item')) {
+ Zotero.debug("Waiting for items to load for library " + library.libraryID);
+ ZoteroPane_Local.setItemsPaneMessage(Zotero.getString('pane.items.loading'));
+ await library.waitForDataLoad('item');
+ }
+ }
+
+ this.itemsView.changeCollectionTreeRow(collectionTreeRow);
+
+ Zotero.Prefs.set('lastViewedFolder', collectionTreeRow.id);
});
@@ -1951,15 +1947,12 @@ var ZoteroPane = new function()
return this.itemPane.render();
}.bind(this))()
- .catch(function (e) {
+ .catch((e) => {
Zotero.logError(e);
Zotero.crash();
throw e;
- }.bind(this))
- .finally(function () {
- return this.itemsView.runListeners('select');
- }.bind(this));
- };
+ });
+ }
this.updateAddAttachmentMenu = function (popup) {
if (!this.canEdit()) {
diff --git a/chrome/locale/en-US/zotero/zotero.properties b/chrome/locale/en-US/zotero/zotero.properties
index 8a34dbb7c1..a5e44cc602 100644
--- a/chrome/locale/en-US/zotero/zotero.properties
+++ b/chrome/locale/en-US/zotero/zotero.properties
@@ -974,6 +974,7 @@ integration.upgradeTemplate = The %S plugin for %S is outdated. Reinstall the pl
integration.mendeleyImport.title = Missing Mendeley Data
integration.mendeleyImport.description = %1$S detected that the document you are citing with contains Mendeley citations. %1$S will be able to manage these citations if you import your Mendeley database.
integration.mendeleyImport.openImporter = Open Mendeley Importer...
+integration.citationExplorer.citationsModified = Citations in your document have been modified since Citation Explorer has been opened and %S will not be able to activate them until you reopen this window.
styles.install.title = Install Style
styles.install.unexpectedError = An unexpected error occurred while installing "%1$S"
diff --git a/chrome/skin/default/zotero/16/universal/citation-dialog-library.svg b/chrome/skin/default/zotero/16/universal/citation-dialog-library.svg
new file mode 100644
index 0000000000..9c05383f0e
--- /dev/null
+++ b/chrome/skin/default/zotero/16/universal/citation-dialog-library.svg
@@ -0,0 +1,3 @@
+
diff --git a/chrome/skin/default/zotero/16/universal/citation-dialog-list.svg b/chrome/skin/default/zotero/16/universal/citation-dialog-list.svg
new file mode 100644
index 0000000000..8b1cd61fb9
--- /dev/null
+++ b/chrome/skin/default/zotero/16/universal/citation-dialog-list.svg
@@ -0,0 +1,3 @@
+
diff --git a/chrome/skin/default/zotero/16/universal/citation-dialog-settings.svg b/chrome/skin/default/zotero/16/universal/citation-dialog-settings.svg
new file mode 100644
index 0000000000..c3fdf8253d
--- /dev/null
+++ b/chrome/skin/default/zotero/16/universal/citation-dialog-settings.svg
@@ -0,0 +1,3 @@
+
diff --git a/resource/diff.js b/resource/diff.js
new file mode 100644
index 0000000000..187d17689b
--- /dev/null
+++ b/resource/diff.js
@@ -0,0 +1,774 @@
+/**
+ * This library modifies the diff-patch-match library by Neil Fraser
+ * by removing the patch and match functionality and certain advanced
+ * options in the diff function. The original license is as follows:
+ *
+ * ===
+ *
+ * Diff Match and Patch
+ *
+ * Copyright 2006 Google Inc.
+ * http://code.google.com/p/google-diff-match-patch/
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+/**
+ * The data structure representing a diff is an array of tuples:
+ * [[DIFF_DELETE, 'Hello'], [DIFF_INSERT, 'Goodbye'], [DIFF_EQUAL, ' world.']]
+ * which means: delete 'Hello', add 'Goodbye' and keep ' world.'
+ */
+var DIFF_DELETE = -1;
+var DIFF_INSERT = 1;
+var DIFF_EQUAL = 0;
+
+
+/**
+ * Find the differences between two texts. Simplifies the problem by stripping
+ * any common prefix or suffix off the texts before diffing.
+ * @param {string} text1 Old string to be diffed.
+ * @param {string} text2 New string to be diffed.
+ * @param {Int|Object} [cursor_pos] Edit position in text1 or object with more info
+ * @return {Array} Array of diff tuples.
+ */
+function diff_main(text1, text2, cursor_pos, _fix_unicode) {
+ // Check for equality
+ if (text1 === text2) {
+ if (text1) {
+ return [[DIFF_EQUAL, text1]];
+ }
+ return [];
+ }
+
+ if (cursor_pos != null) {
+ var editdiff = find_cursor_edit_diff(text1, text2, cursor_pos);
+ if (editdiff) {
+ return editdiff;
+ }
+ }
+
+ // Trim off common prefix (speedup).
+ var commonlength = diff_commonPrefix(text1, text2);
+ var commonprefix = text1.substring(0, commonlength);
+ text1 = text1.substring(commonlength);
+ text2 = text2.substring(commonlength);
+
+ // Trim off common suffix (speedup).
+ commonlength = diff_commonSuffix(text1, text2);
+ var commonsuffix = text1.substring(text1.length - commonlength);
+ text1 = text1.substring(0, text1.length - commonlength);
+ text2 = text2.substring(0, text2.length - commonlength);
+
+ // Compute the diff on the middle block.
+ var diffs = diff_compute_(text1, text2);
+
+ // Restore the prefix and suffix.
+ if (commonprefix) {
+ diffs.unshift([DIFF_EQUAL, commonprefix]);
+ }
+ if (commonsuffix) {
+ diffs.push([DIFF_EQUAL, commonsuffix]);
+ }
+ diff_cleanupMerge(diffs, _fix_unicode);
+ return diffs;
+};
+
+
+/**
+ * Find the differences between two texts. Assumes that the texts do not
+ * have any common prefix or suffix.
+ * @param {string} text1 Old string to be diffed.
+ * @param {string} text2 New string to be diffed.
+ * @return {Array} Array of diff tuples.
+ */
+function diff_compute_(text1, text2) {
+ var diffs;
+
+ if (!text1) {
+ // Just add some text (speedup).
+ return [[DIFF_INSERT, text2]];
+ }
+
+ if (!text2) {
+ // Just delete some text (speedup).
+ return [[DIFF_DELETE, text1]];
+ }
+
+ var longtext = text1.length > text2.length ? text1 : text2;
+ var shorttext = text1.length > text2.length ? text2 : text1;
+ var i = longtext.indexOf(shorttext);
+ if (i !== -1) {
+ // Shorter text is inside the longer text (speedup).
+ diffs = [
+ [DIFF_INSERT, longtext.substring(0, i)],
+ [DIFF_EQUAL, shorttext],
+ [DIFF_INSERT, longtext.substring(i + shorttext.length)]
+ ];
+ // Swap insertions for deletions if diff is reversed.
+ if (text1.length > text2.length) {
+ diffs[0][0] = diffs[2][0] = DIFF_DELETE;
+ }
+ return diffs;
+ }
+
+ if (shorttext.length === 1) {
+ // Single character string.
+ // After the previous speedup, the character can't be an equality.
+ return [[DIFF_DELETE, text1], [DIFF_INSERT, text2]];
+ }
+
+ // Check to see if the problem can be split in two.
+ var hm = diff_halfMatch_(text1, text2);
+ if (hm) {
+ // A half-match was found, sort out the return data.
+ var text1_a = hm[0];
+ var text1_b = hm[1];
+ var text2_a = hm[2];
+ var text2_b = hm[3];
+ var mid_common = hm[4];
+ // Send both pairs off for separate processing.
+ var diffs_a = diff_main(text1_a, text2_a);
+ var diffs_b = diff_main(text1_b, text2_b);
+ // Merge the results.
+ return diffs_a.concat([[DIFF_EQUAL, mid_common]], diffs_b);
+ }
+
+ return diff_bisect_(text1, text2);
+};
+
+
+/**
+ * Find the 'middle snake' of a diff, split the problem in two
+ * and return the recursively constructed diff.
+ * See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations.
+ * @param {string} text1 Old string to be diffed.
+ * @param {string} text2 New string to be diffed.
+ * @return {Array} Array of diff tuples.
+ * @private
+ */
+function diff_bisect_(text1, text2) {
+ // Cache the text lengths to prevent multiple calls.
+ var text1_length = text1.length;
+ var text2_length = text2.length;
+ var max_d = Math.ceil((text1_length + text2_length) / 2);
+ var v_offset = max_d;
+ var v_length = 2 * max_d;
+ var v1 = new Array(v_length);
+ var v2 = new Array(v_length);
+ // Setting all elements to -1 is faster in Chrome & Firefox than mixing
+ // integers and undefined.
+ for (var x = 0; x < v_length; x++) {
+ v1[x] = -1;
+ v2[x] = -1;
+ }
+ v1[v_offset + 1] = 0;
+ v2[v_offset + 1] = 0;
+ var delta = text1_length - text2_length;
+ // If the total number of characters is odd, then the front path will collide
+ // with the reverse path.
+ var front = (delta % 2 !== 0);
+ // Offsets for start and end of k loop.
+ // Prevents mapping of space beyond the grid.
+ var k1start = 0;
+ var k1end = 0;
+ var k2start = 0;
+ var k2end = 0;
+ for (var d = 0; d < max_d; d++) {
+ // Walk the front path one step.
+ for (var k1 = -d + k1start; k1 <= d - k1end; k1 += 2) {
+ var k1_offset = v_offset + k1;
+ var x1;
+ if (k1 === -d || (k1 !== d && v1[k1_offset - 1] < v1[k1_offset + 1])) {
+ x1 = v1[k1_offset + 1];
+ } else {
+ x1 = v1[k1_offset - 1] + 1;
+ }
+ var y1 = x1 - k1;
+ while (
+ x1 < text1_length && y1 < text2_length &&
+ text1.charAt(x1) === text2.charAt(y1)
+ ) {
+ x1++;
+ y1++;
+ }
+ v1[k1_offset] = x1;
+ if (x1 > text1_length) {
+ // Ran off the right of the graph.
+ k1end += 2;
+ } else if (y1 > text2_length) {
+ // Ran off the bottom of the graph.
+ k1start += 2;
+ } else if (front) {
+ var k2_offset = v_offset + delta - k1;
+ if (k2_offset >= 0 && k2_offset < v_length && v2[k2_offset] !== -1) {
+ // Mirror x2 onto top-left coordinate system.
+ var x2 = text1_length - v2[k2_offset];
+ if (x1 >= x2) {
+ // Overlap detected.
+ return diff_bisectSplit_(text1, text2, x1, y1);
+ }
+ }
+ }
+ }
+
+ // Walk the reverse path one step.
+ for (var k2 = -d + k2start; k2 <= d - k2end; k2 += 2) {
+ var k2_offset = v_offset + k2;
+ var x2;
+ if (k2 === -d || (k2 !== d && v2[k2_offset - 1] < v2[k2_offset + 1])) {
+ x2 = v2[k2_offset + 1];
+ } else {
+ x2 = v2[k2_offset - 1] + 1;
+ }
+ var y2 = x2 - k2;
+ while (
+ x2 < text1_length && y2 < text2_length &&
+ text1.charAt(text1_length - x2 - 1) === text2.charAt(text2_length - y2 - 1)
+ ) {
+ x2++;
+ y2++;
+ }
+ v2[k2_offset] = x2;
+ if (x2 > text1_length) {
+ // Ran off the left of the graph.
+ k2end += 2;
+ } else if (y2 > text2_length) {
+ // Ran off the top of the graph.
+ k2start += 2;
+ } else if (!front) {
+ var k1_offset = v_offset + delta - k2;
+ if (k1_offset >= 0 && k1_offset < v_length && v1[k1_offset] !== -1) {
+ var x1 = v1[k1_offset];
+ var y1 = v_offset + x1 - k1_offset;
+ // Mirror x2 onto top-left coordinate system.
+ x2 = text1_length - x2;
+ if (x1 >= x2) {
+ // Overlap detected.
+ return diff_bisectSplit_(text1, text2, x1, y1);
+ }
+ }
+ }
+ }
+ }
+ // Diff took too long and hit the deadline or
+ // number of diffs equals number of characters, no commonality at all.
+ return [[DIFF_DELETE, text1], [DIFF_INSERT, text2]];
+};
+
+
+/**
+ * Given the location of the 'middle snake', split the diff in two parts
+ * and recurse.
+ * @param {string} text1 Old string to be diffed.
+ * @param {string} text2 New string to be diffed.
+ * @param {number} x Index of split point in text1.
+ * @param {number} y Index of split point in text2.
+ * @return {Array} Array of diff tuples.
+ */
+function diff_bisectSplit_(text1, text2, x, y) {
+ var text1a = text1.substring(0, x);
+ var text2a = text2.substring(0, y);
+ var text1b = text1.substring(x);
+ var text2b = text2.substring(y);
+
+ // Compute both diffs serially.
+ var diffs = diff_main(text1a, text2a);
+ var diffsb = diff_main(text1b, text2b);
+
+ return diffs.concat(diffsb);
+};
+
+
+/**
+ * Determine the common prefix of two strings.
+ * @param {string} text1 First string.
+ * @param {string} text2 Second string.
+ * @return {number} The number of characters common to the start of each
+ * string.
+ */
+function diff_commonPrefix(text1, text2) {
+ // Quick check for common null cases.
+ if (!text1 || !text2 || text1.charAt(0) !== text2.charAt(0)) {
+ return 0;
+ }
+ // Binary search.
+ // Performance analysis: http://neil.fraser.name/news/2007/10/09/
+ var pointermin = 0;
+ var pointermax = Math.min(text1.length, text2.length);
+ var pointermid = pointermax;
+ var pointerstart = 0;
+ while (pointermin < pointermid) {
+ if (
+ text1.substring(pointerstart, pointermid) ==
+ text2.substring(pointerstart, pointermid)
+ ) {
+ pointermin = pointermid;
+ pointerstart = pointermin;
+ } else {
+ pointermax = pointermid;
+ }
+ pointermid = Math.floor((pointermax - pointermin) / 2 + pointermin);
+ }
+
+ if (is_surrogate_pair_start(text1.charCodeAt(pointermid - 1))) {
+ pointermid--;
+ }
+
+ return pointermid;
+};
+
+
+/**
+ * Determine the common suffix of two strings.
+ * @param {string} text1 First string.
+ * @param {string} text2 Second string.
+ * @return {number} The number of characters common to the end of each string.
+ */
+function diff_commonSuffix(text1, text2) {
+ // Quick check for common null cases.
+ if (!text1 || !text2 || text1.slice(-1) !== text2.slice(-1)) {
+ return 0;
+ }
+ // Binary search.
+ // Performance analysis: http://neil.fraser.name/news/2007/10/09/
+ var pointermin = 0;
+ var pointermax = Math.min(text1.length, text2.length);
+ var pointermid = pointermax;
+ var pointerend = 0;
+ while (pointermin < pointermid) {
+ if (
+ text1.substring(text1.length - pointermid, text1.length - pointerend) ==
+ text2.substring(text2.length - pointermid, text2.length - pointerend)
+ ) {
+ pointermin = pointermid;
+ pointerend = pointermin;
+ } else {
+ pointermax = pointermid;
+ }
+ pointermid = Math.floor((pointermax - pointermin) / 2 + pointermin);
+ }
+
+ if (is_surrogate_pair_end(text1.charCodeAt(text1.length - pointermid))) {
+ pointermid--;
+ }
+
+ return pointermid;
+};
+
+
+/**
+ * Do the two texts share a substring which is at least half the length of the
+ * longer text?
+ * This speedup can produce non-minimal diffs.
+ * @param {string} text1 First string.
+ * @param {string} text2 Second string.
+ * @return {Array.} Five element Array, containing the prefix of
+ * text1, the suffix of text1, the prefix of text2, the suffix of
+ * text2 and the common middle. Or null if there was no match.
+ */
+function diff_halfMatch_(text1, text2) {
+ var longtext = text1.length > text2.length ? text1 : text2;
+ var shorttext = text1.length > text2.length ? text2 : text1;
+ if (longtext.length < 4 || shorttext.length * 2 < longtext.length) {
+ return null; // Pointless.
+ }
+
+ /**
+ * Does a substring of shorttext exist within longtext such that the substring
+ * is at least half the length of longtext?
+ * Closure, but does not reference any external variables.
+ * @param {string} longtext Longer string.
+ * @param {string} shorttext Shorter string.
+ * @param {number} i Start index of quarter length substring within longtext.
+ * @return {Array.} Five element Array, containing the prefix of
+ * longtext, the suffix of longtext, the prefix of shorttext, the suffix
+ * of shorttext and the common middle. Or null if there was no match.
+ * @private
+ */
+ function diff_halfMatchI_(longtext, shorttext, i) {
+ // Start with a 1/4 length substring at position i as a seed.
+ var seed = longtext.substring(i, i + Math.floor(longtext.length / 4));
+ var j = -1;
+ var best_common = '';
+ var best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b;
+ while ((j = shorttext.indexOf(seed, j + 1)) !== -1) {
+ var prefixLength = diff_commonPrefix(
+ longtext.substring(i), shorttext.substring(j));
+ var suffixLength = diff_commonSuffix(
+ longtext.substring(0, i), shorttext.substring(0, j));
+ if (best_common.length < suffixLength + prefixLength) {
+ best_common = shorttext.substring(
+ j - suffixLength, j) + shorttext.substring(j, j + prefixLength);
+ best_longtext_a = longtext.substring(0, i - suffixLength);
+ best_longtext_b = longtext.substring(i + prefixLength);
+ best_shorttext_a = shorttext.substring(0, j - suffixLength);
+ best_shorttext_b = shorttext.substring(j + prefixLength);
+ }
+ }
+ if (best_common.length * 2 >= longtext.length) {
+ return [
+ best_longtext_a, best_longtext_b,
+ best_shorttext_a, best_shorttext_b, best_common
+ ];
+ } else {
+ return null;
+ }
+ }
+
+ // First check if the second quarter is the seed for a half-match.
+ var hm1 = diff_halfMatchI_(longtext, shorttext, Math.ceil(longtext.length / 4));
+ // Check again based on the third quarter.
+ var hm2 = diff_halfMatchI_(longtext, shorttext, Math.ceil(longtext.length / 2));
+ var hm;
+ if (!hm1 && !hm2) {
+ return null;
+ } else if (!hm2) {
+ hm = hm1;
+ } else if (!hm1) {
+ hm = hm2;
+ } else {
+ // Both matched. Select the longest.
+ hm = hm1[4].length > hm2[4].length ? hm1 : hm2;
+ }
+
+ // A half-match was found, sort out the return data.
+ var text1_a, text1_b, text2_a, text2_b;
+ if (text1.length > text2.length) {
+ text1_a = hm[0];
+ text1_b = hm[1];
+ text2_a = hm[2];
+ text2_b = hm[3];
+ } else {
+ text2_a = hm[0];
+ text2_b = hm[1];
+ text1_a = hm[2];
+ text1_b = hm[3];
+ }
+ var mid_common = hm[4];
+ return [text1_a, text1_b, text2_a, text2_b, mid_common];
+};
+
+
+/**
+ * Reorder and merge like edit sections. Merge equalities.
+ * Any edit section can move as long as it doesn't cross an equality.
+ * @param {Array} diffs Array of diff tuples.
+ * @param {boolean} fix_unicode Whether to normalize to a unicode-correct diff
+ */
+function diff_cleanupMerge(diffs, fix_unicode) {
+ diffs.push([DIFF_EQUAL, '']); // Add a dummy entry at the end.
+ var pointer = 0;
+ var count_delete = 0;
+ var count_insert = 0;
+ var text_delete = '';
+ var text_insert = '';
+ var commonlength;
+ while (pointer < diffs.length) {
+ if (pointer < diffs.length - 1 && !diffs[pointer][1]) {
+ diffs.splice(pointer, 1);
+ continue;
+ }
+ switch (diffs[pointer][0]) {
+ case DIFF_INSERT:
+
+ count_insert++;
+ text_insert += diffs[pointer][1];
+ pointer++;
+ break;
+ case DIFF_DELETE:
+ count_delete++;
+ text_delete += diffs[pointer][1];
+ pointer++;
+ break;
+ case DIFF_EQUAL:
+ var previous_equality = pointer - count_insert - count_delete - 1;
+ if (fix_unicode) {
+ // prevent splitting of unicode surrogate pairs. when fix_unicode is true,
+ // we assume that the old and new text in the diff are complete and correct
+ // unicode-encoded JS strings, but the tuple boundaries may fall between
+ // surrogate pairs. we fix this by shaving off stray surrogates from the end
+ // of the previous equality and the beginning of this equality. this may create
+ // empty equalities or a common prefix or suffix. for example, if AB and AC are
+ // emojis, `[[0, 'A'], [-1, 'BA'], [0, 'C']]` would turn into deleting 'ABAC' and
+ // inserting 'AC', and then the common suffix 'AC' will be eliminated. in this
+ // particular case, both equalities go away, we absorb any previous inequalities,
+ // and we keep scanning for the next equality before rewriting the tuples.
+ if (previous_equality >= 0 && ends_with_pair_start(diffs[previous_equality][1])) {
+ var stray = diffs[previous_equality][1].slice(-1);
+ diffs[previous_equality][1] = diffs[previous_equality][1].slice(0, -1);
+ text_delete = stray + text_delete;
+ text_insert = stray + text_insert;
+ if (!diffs[previous_equality][1]) {
+ // emptied out previous equality, so delete it and include previous delete/insert
+ diffs.splice(previous_equality, 1);
+ pointer--;
+ var k = previous_equality - 1;
+ if (diffs[k] && diffs[k][0] === DIFF_INSERT) {
+ count_insert++;
+ text_insert = diffs[k][1] + text_insert;
+ k--;
+ }
+ if (diffs[k] && diffs[k][0] === DIFF_DELETE) {
+ count_delete++;
+ text_delete = diffs[k][1] + text_delete;
+ k--;
+ }
+ previous_equality = k;
+ }
+ }
+ if (starts_with_pair_end(diffs[pointer][1])) {
+ var stray = diffs[pointer][1].charAt(0);
+ diffs[pointer][1] = diffs[pointer][1].slice(1);
+ text_delete += stray;
+ text_insert += stray;
+ }
+ }
+ if (pointer < diffs.length - 1 && !diffs[pointer][1]) {
+ // for empty equality not at end, wait for next equality
+ diffs.splice(pointer, 1);
+ break;
+ }
+ if (text_delete.length > 0 || text_insert.length > 0) {
+ // note that diff_commonPrefix and diff_commonSuffix are unicode-aware
+ if (text_delete.length > 0 && text_insert.length > 0) {
+ // Factor out any common prefixes.
+ commonlength = diff_commonPrefix(text_insert, text_delete);
+ if (commonlength !== 0) {
+ if (previous_equality >= 0) {
+ diffs[previous_equality][1] += text_insert.substring(0, commonlength);
+ } else {
+ diffs.splice(0, 0, [DIFF_EQUAL, text_insert.substring(0, commonlength)]);
+ pointer++;
+ }
+ text_insert = text_insert.substring(commonlength);
+ text_delete = text_delete.substring(commonlength);
+ }
+ // Factor out any common suffixes.
+ commonlength = diff_commonSuffix(text_insert, text_delete);
+ if (commonlength !== 0) {
+ diffs[pointer][1] =
+ text_insert.substring(text_insert.length - commonlength) + diffs[pointer][1];
+ text_insert = text_insert.substring(0, text_insert.length - commonlength);
+ text_delete = text_delete.substring(0, text_delete.length - commonlength);
+ }
+ }
+ // Delete the offending records and add the merged ones.
+ var n = count_insert + count_delete;
+ if (text_delete.length === 0 && text_insert.length === 0) {
+ diffs.splice(pointer - n, n);
+ pointer = pointer - n;
+ } else if (text_delete.length === 0) {
+ diffs.splice(pointer - n, n, [DIFF_INSERT, text_insert]);
+ pointer = pointer - n + 1;
+ } else if (text_insert.length === 0) {
+ diffs.splice(pointer - n, n, [DIFF_DELETE, text_delete]);
+ pointer = pointer - n + 1;
+ } else {
+ diffs.splice(pointer - n, n, [DIFF_DELETE, text_delete], [DIFF_INSERT, text_insert]);
+ pointer = pointer - n + 2;
+ }
+ }
+ if (pointer !== 0 && diffs[pointer - 1][0] === DIFF_EQUAL) {
+ // Merge this equality with the previous one.
+ diffs[pointer - 1][1] += diffs[pointer][1];
+ diffs.splice(pointer, 1);
+ } else {
+ pointer++;
+ }
+ count_insert = 0;
+ count_delete = 0;
+ text_delete = '';
+ text_insert = '';
+ break;
+ }
+ }
+ if (diffs[diffs.length - 1][1] === '') {
+ diffs.pop(); // Remove the dummy entry at the end.
+ }
+
+ // Second pass: look for single edits surrounded on both sides by equalities
+ // which can be shifted sideways to eliminate an equality.
+ // e.g: ABAC -> ABAC
+ var changes = false;
+ pointer = 1;
+ // Intentionally ignore the first and last element (don't need checking).
+ while (pointer < diffs.length - 1) {
+ if (diffs[pointer - 1][0] === DIFF_EQUAL &&
+ diffs[pointer + 1][0] === DIFF_EQUAL) {
+ // This is a single edit surrounded by equalities.
+ if (diffs[pointer][1].substring(diffs[pointer][1].length -
+ diffs[pointer - 1][1].length) === diffs[pointer - 1][1]) {
+ // Shift the edit over the previous equality.
+ diffs[pointer][1] = diffs[pointer - 1][1] +
+ diffs[pointer][1].substring(0, diffs[pointer][1].length -
+ diffs[pointer - 1][1].length);
+ diffs[pointer + 1][1] = diffs[pointer - 1][1] + diffs[pointer + 1][1];
+ diffs.splice(pointer - 1, 1);
+ changes = true;
+ } else if (diffs[pointer][1].substring(0, diffs[pointer + 1][1].length) ==
+ diffs[pointer + 1][1]) {
+ // Shift the edit over the next equality.
+ diffs[pointer - 1][1] += diffs[pointer + 1][1];
+ diffs[pointer][1] =
+ diffs[pointer][1].substring(diffs[pointer + 1][1].length) +
+ diffs[pointer + 1][1];
+ diffs.splice(pointer + 1, 1);
+ changes = true;
+ }
+ }
+ pointer++;
+ }
+ // If shifts were made, the diff needs reordering and another shift sweep.
+ if (changes) {
+ diff_cleanupMerge(diffs, fix_unicode);
+ }
+};
+
+function is_surrogate_pair_start(charCode) {
+ return charCode >= 0xD800 && charCode <= 0xDBFF;
+}
+
+function is_surrogate_pair_end(charCode) {
+ return charCode >= 0xDC00 && charCode <= 0xDFFF;
+}
+
+function starts_with_pair_end(str) {
+ return is_surrogate_pair_end(str.charCodeAt(0));
+}
+
+function ends_with_pair_start(str) {
+ return is_surrogate_pair_start(str.charCodeAt(str.length - 1));
+}
+
+function remove_empty_tuples(tuples) {
+ var ret = [];
+ for (var i = 0; i < tuples.length; i++) {
+ if (tuples[i][1].length > 0) {
+ ret.push(tuples[i]);
+ }
+ }
+ return ret;
+}
+
+function make_edit_splice(before, oldMiddle, newMiddle, after) {
+ if (ends_with_pair_start(before) || starts_with_pair_end(after)) {
+ return null;
+ }
+ return remove_empty_tuples([
+ [DIFF_EQUAL, before],
+ [DIFF_DELETE, oldMiddle],
+ [DIFF_INSERT, newMiddle],
+ [DIFF_EQUAL, after]
+ ]);
+}
+
+function find_cursor_edit_diff(oldText, newText, cursor_pos) {
+ // note: this runs after equality check has ruled out exact equality
+ var oldRange = typeof cursor_pos === 'number' ?
+ { index: cursor_pos, length: 0 } : cursor_pos.oldRange;
+ var newRange = typeof cursor_pos === 'number' ?
+ null : cursor_pos.newRange;
+ // take into account the old and new selection to generate the best diff
+ // possible for a text edit. for example, a text change from "xxx" to "xx"
+ // could be a delete or forwards-delete of any one of the x's, or the
+ // result of selecting two of the x's and typing "x".
+ var oldLength = oldText.length;
+ var newLength = newText.length;
+ if (oldRange.length === 0 && (newRange === null || newRange.length === 0)) {
+ // see if we have an insert or delete before or after cursor
+ var oldCursor = oldRange.index;
+ var oldBefore = oldText.slice(0, oldCursor);
+ var oldAfter = oldText.slice(oldCursor);
+ var maybeNewCursor = newRange ? newRange.index : null;
+ editBefore: {
+ // is this an insert or delete right before oldCursor?
+ var newCursor = oldCursor + newLength - oldLength;
+ if (maybeNewCursor !== null && maybeNewCursor !== newCursor) {
+ break editBefore;
+ }
+ if (newCursor < 0 || newCursor > newLength) {
+ break editBefore;
+ }
+ var newBefore = newText.slice(0, newCursor);
+ var newAfter = newText.slice(newCursor);
+ if (newAfter !== oldAfter) {
+ break editBefore;
+ }
+ var prefixLength = Math.min(oldCursor, newCursor);
+ var oldPrefix = oldBefore.slice(0, prefixLength);
+ var newPrefix = newBefore.slice(0, prefixLength);
+ if (oldPrefix !== newPrefix) {
+ break editBefore;
+ }
+ var oldMiddle = oldBefore.slice(prefixLength);
+ var newMiddle = newBefore.slice(prefixLength);
+ return make_edit_splice(oldPrefix, oldMiddle, newMiddle, oldAfter);
+ }
+ editAfter: {
+ // is this an insert or delete right after oldCursor?
+ if (maybeNewCursor !== null && maybeNewCursor !== oldCursor) {
+ break editAfter;
+ }
+ var cursor = oldCursor;
+ var newBefore = newText.slice(0, cursor);
+ var newAfter = newText.slice(cursor);
+ if (newBefore !== oldBefore) {
+ break editAfter;
+ }
+ var suffixLength = Math.min(oldLength - cursor, newLength - cursor);
+ var oldSuffix = oldAfter.slice(oldAfter.length - suffixLength);
+ var newSuffix = newAfter.slice(newAfter.length - suffixLength);
+ if (oldSuffix !== newSuffix) {
+ break editAfter;
+ }
+ var oldMiddle = oldAfter.slice(0, oldAfter.length - suffixLength);
+ var newMiddle = newAfter.slice(0, newAfter.length - suffixLength);
+ return make_edit_splice(oldBefore, oldMiddle, newMiddle, oldSuffix);
+ }
+ }
+ if (oldRange.length > 0 && newRange && newRange.length === 0) {
+ replaceRange: {
+ // see if diff could be a splice of the old selection range
+ var oldPrefix = oldText.slice(0, oldRange.index);
+ var oldSuffix = oldText.slice(oldRange.index + oldRange.length);
+ var prefixLength = oldPrefix.length;
+ var suffixLength = oldSuffix.length;
+ if (newLength < prefixLength + suffixLength) {
+ break replaceRange;
+ }
+ var newPrefix = newText.slice(0, prefixLength);
+ var newSuffix = newText.slice(newLength - suffixLength);
+ if (oldPrefix !== newPrefix || oldSuffix !== newSuffix) {
+ break replaceRange;
+ }
+ var oldMiddle = oldText.slice(prefixLength, oldLength - suffixLength);
+ var newMiddle = newText.slice(prefixLength, newLength - suffixLength);
+ return make_edit_splice(oldPrefix, oldMiddle, newMiddle, oldSuffix);
+ }
+ }
+
+ return null;
+}
+
+function diff(text1, text2, cursor_pos) {
+ // only pass fix_unicode=true at the top level, not when diff_main is
+ // recursively invoked
+ return diff_main(text1, text2, cursor_pos, true);
+}
+
+diff.INSERT = DIFF_INSERT;
+diff.DELETE = DIFF_DELETE;
+diff.EQUAL = DIFF_EQUAL;
+
+module.exports = diff;
\ No newline at end of file
diff --git a/scss/_zotero.scss b/scss/_zotero.scss
index fbae7be4f4..f63195588f 100644
--- a/scss/_zotero.scss
+++ b/scss/_zotero.scss
@@ -3,6 +3,7 @@
@import "abstracts/variables";
@import "abstracts/functions";
+@import "abstracts/layout";
@import "abstracts/mixins";
@import "abstracts/placeholders";
@import "abstracts/utilities";
@@ -25,6 +26,8 @@
@import "components/autosuggest";
@import "components/banners";
@import "components/button";
+@import "components/citationExplorer";
+@import "components/citationDialog";
@import "components/clicky";
@import "components/contextPane";
@import "components/collection-tree";
@@ -73,6 +76,7 @@
@import "elements/attachmentBox";
@import "elements/attachmentPreview";
@import "elements/attachmentPreviewBox";
+@import "elements/bubbleInput";
@import "elements/colorPicker";
@import "elements/guidancePanel";
@import "elements/infoBox";
diff --git a/scss/abstracts/_layout.scss b/scss/abstracts/_layout.scss
new file mode 100644
index 0000000000..6b660fa239
--- /dev/null
+++ b/scss/abstracts/_layout.scss
@@ -0,0 +1,18 @@
+.hbox {
+ display: flex;
+ flex-direction: row;
+
+ .separator {
+ margin: 0 3px;
+ border-right: 2px solid #ddd;
+ }
+}
+
+.vbox {
+ display: flex;
+ flex-direction: column;
+}
+
+.flex {
+ flex: 1
+}
diff --git a/scss/components/_citationDialog.scss b/scss/components/_citationDialog.scss
new file mode 100644
index 0000000000..019241183e
--- /dev/null
+++ b/scss/components/_citationDialog.scss
@@ -0,0 +1,7 @@
+#citation-dialog {
+ min-width: 800px;
+
+ .layout {
+ display: contents;
+ }
+}
\ No newline at end of file
diff --git a/scss/components/_citationExplorer.scss b/scss/components/_citationExplorer.scss
new file mode 100644
index 0000000000..7615565799
--- /dev/null
+++ b/scss/components/_citationExplorer.scss
@@ -0,0 +1,29 @@
+#zotero-citation-explorer-dialog {
+ min-height: 500px;
+
+ dialog {
+ max-height: 100vh;
+ }
+
+ .virtualized-table-container {
+ height: 100%;
+ flex: 1;
+ }
+
+ .xul-border-div {
+ -moz-box-flex: 1;
+ }
+
+ #citations-label {
+ margin: 5px 0 4px;
+ }
+
+ .panel {
+ border: solid 1px ThreeDShadow;
+ padding: 5px;
+ }
+
+ .search-box {
+ margin: 2px -1px;
+ }
+}
diff --git a/scss/components/_collection-tree.scss b/scss/components/_collection-tree.scss
index 1c0f415a98..4ffc9556a9 100644
--- a/scss/components/_collection-tree.scss
+++ b/scss/components/_collection-tree.scss
@@ -60,6 +60,11 @@ $icons: (
padding: 0 8px 8px;
scrollbar-color: var(--color-scrollbar) var(--color-scrollbar-background);
}
+
+ // Highlight takes priority over selection
+ .row.highlighted.selected {
+ background: #FFFF99;
+ }
.cell.primary {
display: flex;
diff --git a/scss/components/_item-tree.scss b/scss/components/_item-tree.scss
index b0c9fb18d4..02c455d873 100644
--- a/scss/components/_item-tree.scss
+++ b/scss/components/_item-tree.scss
@@ -164,7 +164,12 @@
}
}
}
-
+
+ .cell.hasAttachment {
+ height: 100%;
+ // Don't show ellipsis
+ text-overflow: unset;
+ }
.cell.primary {
.retracted {
@@ -172,10 +177,6 @@
margin-inline-start: 3px;
}
}
-
- .cell.no-padding {
- padding: 0;
- }
.cell.hasAttachment {
text-overflow: unset;
@@ -183,11 +184,8 @@
display: flex;
justify-content: center;
- .cell-text {
- text-overflow: unset;
- align-items: center;
- display: flex;
- justify-content: center;
+ .icon-treeitemattachmentpdf {
+ background-size: 10px
}
.icon-missing-file {
diff --git a/scss/components/_virtualized-table.scss b/scss/components/_virtualized-table.scss
index 390490a351..3e9e9f120d 100644
--- a/scss/components/_virtualized-table.scss
+++ b/scss/components/_virtualized-table.scss
@@ -211,6 +211,21 @@
min-width: 0px;
}
}
+
+.virtualized-table.multi-select:focus {
+ .row.focused {
+ border: 1px dotted highlight;
+ z-index: 10000;
+
+ > *:first-child {
+ margin-inline-start: -1px;
+ }
+
+ > *:last-child {
+ margin-inline-end: -1px;
+ }
+ }
+}
.virtualized-table-header {
display: flex;
@@ -232,6 +247,14 @@
&.static-columns {
pointer-events: none;
+ .cell {
+ &:hover {
+ background: inherit;
+ }
+ }
+ [title] {
+ pointer-events: auto;
+ }
}
&::after {
@@ -293,13 +316,15 @@
&.cell-icon {
> .label {
margin-inline-start: 0;
+ display: flex;
+ justify-content: center;
}
.icon-css {
fill: var(--fill-secondary);
}
- justify-content: center;
+ padding: 0;
}
.sort-indicator {
diff --git a/scss/elements/_bubbleInput.scss b/scss/elements/_bubbleInput.scss
new file mode 100644
index 0000000000..0e2fb18b7e
--- /dev/null
+++ b/scss/elements/_bubbleInput.scss
@@ -0,0 +1,106 @@
+bubble-input {
+ min-width: 200px;
+
+ .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 */
+ min-height: 28px;
+ font-size: 13px;
+ background: white;
+ color: black;
+ width: 100%;
+ padding-inline: 6px;
+
+ span {
+ white-space: pre;
+ }
+ }
+
+ input {
+ outline: none;
+ width: 2px;
+ border: none !important;
+ box-sizing: border-box;
+ height: 15px;
+ /* 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;
+ }
+
+ .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);
+ 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;
+
+ &:hover {
+ background-color: #bbcef1;
+ border-color: #6d95e0;
+
+ .cross {
+ display: block;
+ }
+
+ .text {
+ margin-right: -16px;
+ mask-image: linear-gradient(to left, transparent 12px, var(--fill-primary) 24px);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ &.drop-before::before, &.drop-after::after {
+ content: "";
+ display: block;
+ margin-left: -1px;
+ height: 14px;
+ border-left: 1px solid var(--fill-primary);
+ position: absolute;
+ }
+ &.drop-before::before {
+ left: calc(-1 * var(--margin-horizontal));
+ }
+ &.drop-after::after {
+ right: calc(-2 * var(--margin-horizontal));
+ }
+
+ &[selected="true"] {
+ border-radius: 8px !important;
+ background-color: #598bec;
+ color: #fff;
+ }
+
+ .cross {
+ @include svgicon("x-8", "universal", "16");
+ display: none;
+ width: 16px;
+ height: 18px;
+ align-self: start;
+ }
+ }
+}
\ No newline at end of file
diff --git a/scss/mac/_citationExplorer.scss b/scss/mac/_citationExplorer.scss
new file mode 100644
index 0000000000..5b499344ce
--- /dev/null
+++ b/scss/mac/_citationExplorer.scss
@@ -0,0 +1,14 @@
+#zotero-citation-explorer-dialog {
+ tab[visuallyselected="true"]:not(:-moz-window-inactive) {
+ color: initial !important;
+ }
+
+ #items-panel {
+ margin-top: 1.5em;
+ }
+
+ #citations-label {
+ margin: 0.5em 0 0.38em;
+ font-size: 1.2em;
+ }
+}
\ No newline at end of file
diff --git a/scss/win/_citationExplorer.scss b/scss/win/_citationExplorer.scss
new file mode 100644
index 0000000000..d3ed61a654
--- /dev/null
+++ b/scss/win/_citationExplorer.scss
@@ -0,0 +1,13 @@
+#zotero-citation-explorer-dialog {
+ #citations-label {
+ margin: 3px 0 2px;
+ }
+
+ tab:focus-visible > .tab-middle {
+ outline: none;
+ }
+
+ input[type="button"], button {
+ margin-top: 7px;
+ }
+}
\ No newline at end of file
diff --git a/scss/zotero-mac.scss b/scss/zotero-mac.scss
index c25eeb1417..149b5b5698 100644
--- a/scss/zotero-mac.scss
+++ b/scss/zotero-mac.scss
@@ -11,5 +11,6 @@
@import "mac/titleBar";
@import "mac/components/menupopup";
@import "mac/components/menulist";
+@import "mac/citationExplorer";
// Elements
diff --git a/scss/zotero-win.scss b/scss/zotero-win.scss
index 43b62ff20e..17448c86bf 100644
--- a/scss/zotero-win.scss
+++ b/scss/zotero-win.scss
@@ -9,6 +9,7 @@
@import "win/createParent";
@import "win/tabBar";
@import "win/titleBar";
+@import "win/citationExplorer";
// Elements