mirror of
https://github.com/zotero/zotero.git
synced 2026-10-10 03:28:10 +00:00
citation dlg: insert note refactoring
- allow one to insert multiple notes at a time - in list mode, match notes by their parent item and render them as children of collapsible regular items - in library mode, add parent title to the item card similar to annotation and add note preview sidebar that displays the content of the note when it is selected - refactor list mode to use virtualized-table for rendering. Now that we need to render parent-child row relations, virtualized-table offers more flexibility, and we can no longer have the same workflow for suggested items in library mode as for list mode.
This commit is contained in:
parent
7ca8242a3e
commit
e8397e48c5
11 changed files with 1621 additions and 594 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -31,7 +31,9 @@
|
|||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
|
||||
drawintitlebar-platforms="mac,win"
|
||||
resizable="false">
|
||||
resizable="false"
|
||||
windowtype="zotero:citationDialog"
|
||||
>
|
||||
<head>
|
||||
<title data-l10n-id="integration-citationDialog"></title>
|
||||
<link rel="localization" href="zotero.ftl"/>
|
||||
|
|
@ -68,7 +70,7 @@
|
|||
|
||||
<div id="list-layout" class="layout" hidden="true">
|
||||
<div class="divider"></div>
|
||||
<div id="list-layout-wrapper" class="vbox search-items" data-arrow-nav="vertical" data-multiselectable="true" role="listbox" aria-describedby="item-description"></div>
|
||||
<div id="list-layout-wrapper" class="vbox"></div>
|
||||
</div>
|
||||
|
||||
<div id="library-layout" class="layout" hidden="true">
|
||||
|
|
@ -86,6 +88,10 @@
|
|||
<div id="zotero-items-tree" data-tabindex="60" style="--highlight-color: var(--accent-blue30);"/>
|
||||
</div>
|
||||
<div id="sidebar" class="vbox">
|
||||
<div id="add-note-sidebar" class="vbox" data-dialog-type="add-note">
|
||||
<xul:note-editor id="note-preview" flex="1"/>
|
||||
<div id="empty-note-preview-message" data-l10n-id="integration-citationDialog-insertNote-preview-msg"></div>
|
||||
</div>
|
||||
<div id="annotations-sidebar" class="vbox" data-dialog-type="annotations">
|
||||
<!-- Uses styling from _search.scss -->
|
||||
<div id="annotations-sidebar-filter-wrapper" class="search" hidden="true">
|
||||
|
|
|
|||
|
|
@ -27,8 +27,9 @@ import { Zotero } from "chrome://zotero/content/zotero.mjs";
|
|||
|
||||
// Helper functions for citationDialog.js
|
||||
export class CitationDialogHelpers {
|
||||
constructor({ doc }) {
|
||||
constructor({ doc, dialogState }) {
|
||||
this.doc = doc;
|
||||
this.dialogState = dialogState;
|
||||
this.smoothResizingPromise = Zotero.Promise.resolve();
|
||||
}
|
||||
|
||||
|
|
@ -65,9 +66,10 @@ export class CitationDialogHelpers {
|
|||
}
|
||||
|
||||
// build and return a node with the description (e.g. creator/published/date/etc) of an item
|
||||
buildItemDescription(item) {
|
||||
buildItemDescription(item, { twoLines } = {}) {
|
||||
let descriptionWrapper = this.doc.createElement("div");
|
||||
descriptionWrapper.classList = "description";
|
||||
descriptionWrapper.id = `description-${item.id}`;
|
||||
let wrapTextInSpan = (text, styles = {}) => {
|
||||
let span = this.doc.createElement("span");
|
||||
for (let [style, value] of Object.entries(styles)) {
|
||||
|
|
@ -88,16 +90,26 @@ export class CitationDialogHelpers {
|
|||
var date = Zotero.Date.sqlToDate(item.dateModified, true);
|
||||
date = Zotero.Date.toFriendlyDate(date);
|
||||
let dateLabel = wrapTextInSpan(date);
|
||||
|
||||
|
||||
var text = item.note;
|
||||
text = Zotero.Utilities.unescapeHTML(text);
|
||||
text = text.trim();
|
||||
text = text.slice(0, 500);
|
||||
// Display two lines of the note if specified
|
||||
var parts = text.split('\n').map(x => x.trim()).filter(x => x.length);
|
||||
if (parts[1]) {
|
||||
dateLabel.textContent += ` ${parts[1]}.`;
|
||||
}
|
||||
let firstLine = parts[1];
|
||||
let secondLine = parts[2];
|
||||
dateLabel.textContent += " ";
|
||||
descriptionWrapper.appendChild(dateLabel);
|
||||
if (firstLine) {
|
||||
dateLabel.textContent += `${firstLine}`;
|
||||
if (twoLines && secondLine) {
|
||||
let lineBreak = this.doc.createElement("br");
|
||||
descriptionWrapper.appendChild(lineBreak);
|
||||
let secondLineSpan = wrapTextInSpan(secondLine);
|
||||
descriptionWrapper.appendChild(secondLineSpan);
|
||||
}
|
||||
}
|
||||
addPeriodIfNeeded(descriptionWrapper);
|
||||
return descriptionWrapper;
|
||||
}
|
||||
|
|
@ -187,25 +199,89 @@ export class CitationDialogHelpers {
|
|||
|
||||
let addAllBtn = this.createNode("span", { tabindex: -1, 'data-tabindex': 22, role: "button", "aria-describedby": headerSpan.id }, "add-all keyboard-clickable");
|
||||
buttonGroup.append(addAllBtn);
|
||||
|
||||
if (dialogMode == "list") {
|
||||
headerSpan.setAttribute("role", "button");
|
||||
headerSpan.setAttribute("tabindex", -1);
|
||||
headerSpan.setAttribute("data-tabindex", 21);
|
||||
headerSpan.classList.add("keyboard-clickable");
|
||||
}
|
||||
if (dialogMode == "library") {
|
||||
itemContainer.setAttribute("tabindex", -1);
|
||||
itemContainer.setAttribute("data-tabindex", 30);
|
||||
itemContainer.setAttribute("tabindex", -1);
|
||||
itemContainer.setAttribute("data-tabindex", 30);
|
||||
|
||||
let collapseSectionBtn = this.createNode("button", { tabindex: -1, 'data-tabindex': 21, "aria-describedby": headerSpan.id }, "btn-icon collapse-section-btn keyboard-clickable");
|
||||
this.doc.l10n.setAttributes(collapseSectionBtn, "integration-citationDialog-collapse-section");
|
||||
buttonGroup.prepend(collapseSectionBtn);
|
||||
}
|
||||
let collapseSectionBtn = this.createNode("button", { tabindex: -1, 'data-tabindex': 21, "aria-describedby": headerSpan.id }, "btn-icon collapse-section-btn keyboard-clickable");
|
||||
this.doc.l10n.setAttributes(collapseSectionBtn, "integration-citationDialog-collapse-section");
|
||||
buttonGroup.prepend(collapseSectionBtn);
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
// Build the header node for a section in list mode (rendered as its own row in the
|
||||
// virtualized table). For collapsible sections an "Add all" button is included.
|
||||
// All classes (collapsible, collapsed, selected, focused) are applied here based
|
||||
// on the row state. Click handlers are wired from the provided callbacks.
|
||||
buildListHeader(row, isSelected, isFocused, { labelClick, addAllClick } = {}) {
|
||||
let { headerText } = row.ref;
|
||||
let isCollapsible = row.isCollapsible;
|
||||
let id = row.id;
|
||||
let header = this.createNode("div", { id }, "header");
|
||||
let headerSpan = this.createNode("span", {}, "header-label");
|
||||
headerSpan.innerText = headerText;
|
||||
header.append(headerSpan);
|
||||
|
||||
let buttonGroup = this.createNode("div", {}, "header-btn-group");
|
||||
header.append(buttonGroup);
|
||||
|
||||
// Block mouse events from bubbling to VT's row-level handlers.
|
||||
// Bubbling phase so that inner capturing handlers fire first.
|
||||
for (let evt of ["mousedown", "mouseup", "dblclick"]) {
|
||||
header.addEventListener(evt, (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
if (isCollapsible) {
|
||||
header.classList.add("collapsible");
|
||||
header.classList.toggle("collapsed", !row.isOpen);
|
||||
header.classList.toggle("selected", isSelected);
|
||||
header.classList.toggle("focused", isFocused);
|
||||
headerSpan.id = `header_${id}`;
|
||||
if (labelClick) {
|
||||
headerSpan.addEventListener("mousedown", (e) => {
|
||||
e.stopPropagation();
|
||||
labelClick();
|
||||
}, true);
|
||||
headerSpan.addEventListener("mouseup", e => e.stopPropagation(), true);
|
||||
}
|
||||
let addAllBtn = this.createNode("span", { "aria-describedby": headerSpan.id }, "add-all");
|
||||
addAllBtn.textContent = Zotero.getString("integration-citationDialog-add-all");
|
||||
if (addAllClick) {
|
||||
addAllBtn.addEventListener("mousedown", (e) => {
|
||||
e.stopPropagation();
|
||||
addAllClick();
|
||||
}, true);
|
||||
}
|
||||
buttonGroup.append(addAllBtn);
|
||||
}
|
||||
return header;
|
||||
}
|
||||
|
||||
// Generate a label for a section of search results (e.g. "Selected Items (3)")
|
||||
// `group` is an array of { item, children } objects from SearchHandler.
|
||||
async getSectionLabel(key, group, allSelectedItemsCount) {
|
||||
let count = group.length || 0;
|
||||
// in list mode of insertNote, only count top-level items since
|
||||
// child items are nested under their parents
|
||||
if (this.dialogState.isAddingNote() && this.dialogState.getCurrentLayoutType() === "list") {
|
||||
count = group.filter(entry => !entry.item.parentItemID).length;
|
||||
}
|
||||
if (key == "selected") {
|
||||
// when citing notes, we don't want to use the label that includes the
|
||||
// total count because it's confusing in list mode when parents on
|
||||
// selected notes also appear
|
||||
if (this.dialogState.isAddingNote()) {
|
||||
return this.doc.l10n.formatValue(`integration-citationDialog-section-selectedItems`, { count });
|
||||
}
|
||||
// when not citing notes, add the total count param
|
||||
return this.doc.l10n.formatValue(`integration-citationDialog-section-selected`, { count, total: allSelectedItemsCount });
|
||||
}
|
||||
return this.doc.l10n.formatValue(`integration-citationDialog-section-${key}`, { count });
|
||||
}
|
||||
|
||||
// Create mock item node to use as a the placeholder for cited items that are loading
|
||||
createCitedItemPlaceholder() {
|
||||
let itemNode = this.createNode("div", {
|
||||
|
|
@ -395,8 +471,9 @@ export class CitationDialogHelpers {
|
|||
// Try to fetch the short form of the locator label. E.g. "p." for "page"
|
||||
// If there is no locator label, default to "page" for now
|
||||
let label = (Zotero.Cite.getLocatorString(bubbleItem.label || 'page', 'short') || '').toLocaleLowerCase();
|
||||
|
||||
str += `, ${label} ${bubbleItem.locator}`;
|
||||
let locator = bubbleItem.locator.substr(0, 10) + (bubbleItem.locator.length > 10 ? "…" : "");
|
||||
|
||||
str += `, ${label} ${locator}`;
|
||||
}
|
||||
|
||||
// Prefix
|
||||
|
|
@ -454,7 +531,7 @@ export class CitationDialogHelpers {
|
|||
let chromeHeight = win.outerHeight - win.innerHeight;
|
||||
|
||||
// On Linux, animated resizing is too jumpy, so just resize in one step
|
||||
if (Zotero.isLinux) {
|
||||
if (Zotero.isLinux || this.dialogState.isTestRun) {
|
||||
win.resizeTo(targetWidth, targetHeight);
|
||||
resolve();
|
||||
if (onComplete) {
|
||||
|
|
@ -506,4 +583,75 @@ export class CitationDialogHelpers {
|
|||
resolve();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
selectItemNodesRange(startNode, endNode = null) {
|
||||
let itemNodes = [...this.doc.querySelectorAll(".item")];
|
||||
for (let node of itemNodes) {
|
||||
node.classList.remove("selected");
|
||||
}
|
||||
if (startNode === null) return;
|
||||
|
||||
// can't select the collapsed deck of items
|
||||
if (startNode.classList.contains("itemsContainer")) return;
|
||||
|
||||
let startIndex = itemNodes.indexOf(startNode);
|
||||
let endIndex = endNode ? itemNodes.indexOf(endNode) : startIndex;
|
||||
|
||||
// if startIndex is after endIndex, just swap them
|
||||
if (startIndex > endIndex) [startIndex, endIndex] = [endIndex, startIndex];
|
||||
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
this.toggleItemNodeSelect(itemNodes[i], true);
|
||||
}
|
||||
}
|
||||
|
||||
toggleItemNodeSelect(itemNode, isSelected = null) {
|
||||
if (isSelected === true) {
|
||||
itemNode.classList.add("selected");
|
||||
}
|
||||
else if (isSelected === false) {
|
||||
itemNode.classList.remove("selected");
|
||||
}
|
||||
else {
|
||||
itemNode.classList.toggle("selected");
|
||||
}
|
||||
}
|
||||
|
||||
toggleSectionCollapse(section, status) {
|
||||
// set desired class
|
||||
if (status == "expanded" && !section.classList.contains("expanded")) {
|
||||
section.classList.add("expanded");
|
||||
}
|
||||
else if (status == "collapsed" && section.classList.contains("expanded")) {
|
||||
section.classList.remove("expanded");
|
||||
}
|
||||
else if (!status) {
|
||||
section.classList.toggle("expanded");
|
||||
}
|
||||
// mark collapsed items as unfocusable
|
||||
if (section.classList.contains("expandable") && !section.classList.contains("expanded")) {
|
||||
for (let item of [...section.querySelectorAll(".item")]) {
|
||||
item.removeAttribute("tabindex");
|
||||
item.setAttribute("draggable", false);
|
||||
item.classList.remove("current");
|
||||
item.classList.remove("selected");
|
||||
}
|
||||
section.querySelector(".itemsContainer").setAttribute("tabindex", -1);
|
||||
section.querySelector(".itemsContainer").dataset.arrowNavEnabled = true;
|
||||
if (this.doc.activeElement.classList.contains("item")) {
|
||||
section.querySelector(".itemsContainer").focus();
|
||||
}
|
||||
}
|
||||
// when expanded, make them focusable again
|
||||
else {
|
||||
for (let item of [...section.querySelectorAll(".item")]) {
|
||||
item.setAttribute("tabindex", -1);
|
||||
item.setAttribute("draggable", true);
|
||||
}
|
||||
let container = section.querySelector(".itemsContainer");
|
||||
container.removeAttribute("tabindex");
|
||||
container.classList.remove("selected", "current");
|
||||
}
|
||||
section.querySelector(".header-label").setAttribute("aria-expanded", section.classList.contains("expanded"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ export class CitationDialogKeyboardHandler {
|
|||
this._multiselectStart = null;
|
||||
}
|
||||
|
||||
get listLayout() {
|
||||
return this.doc.ownerGlobal.listLayout;
|
||||
}
|
||||
|
||||
_id(id) {
|
||||
return this.doc.getElementById(id);
|
||||
}
|
||||
|
|
@ -81,6 +85,20 @@ export class CitationDialogKeyboardHandler {
|
|||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
// arrowUp from the top-most selectable row of the list-mode table will refocus bubble-input
|
||||
if (this._id("citationDialog-list-table")?.contains(event.target) && event.key == "ArrowUp" && noModifiers) {
|
||||
let table = this.listLayout?._table;
|
||||
let focused = table.selection.focused;
|
||||
// Check if focused row is the first selectable (item) row
|
||||
let rows = this.listLayout._listRows;
|
||||
let firstSelectable = rows.findIndex(r => r.isSelectable);
|
||||
if (focused <= firstSelectable) {
|
||||
table.selection.clearSelection();
|
||||
this._id("bubble-input").refocusInput();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_handleTopLevelKeydown(event) {
|
||||
|
|
@ -149,26 +167,15 @@ export class CitationDialogKeyboardHandler {
|
|||
}
|
||||
// arrow up/down from bubble-input in list mode will move selection in the items list
|
||||
else if (!this._id("list-layout").hidden && (event.key == "ArrowDown" || event.key == "ArrowUp") && this._id("bubble-input").contains(event.target) && onlyShiftModifierPossible) {
|
||||
let group = this.doc.querySelector("#list-layout [data-arrow-nav]");
|
||||
let current = group.querySelector(".current");
|
||||
let firstRow = group.querySelector('[data-arrow-nav-enabled="true"][tabindex]');
|
||||
// on arrowUp from the first row, clear selection
|
||||
if (current === firstRow && event.key == "ArrowUp" && !event.shiftKey) {
|
||||
this._selectItems(null);
|
||||
firstRow?.classList.remove("current");
|
||||
group.scrollTo(0, 0);
|
||||
this._multiselectStart = null;
|
||||
}
|
||||
else if (current || event.key == "ArrowDown") {
|
||||
// Arrow down from input will just change the selected item without moving focus
|
||||
let multiSelect = event.shiftKey;
|
||||
this._navigateGroup({ group, current, forward: event.key == "ArrowDown", shouldSelect: true, shouldFocus: false, multiSelect });
|
||||
}
|
||||
let forward = event.key == "ArrowDown";
|
||||
let multiSelect = event.shiftKey;
|
||||
this._navigateListSelection(forward, multiSelect);
|
||||
handled = true;
|
||||
}
|
||||
// arrowUp from the first item will refocus bubbleInput
|
||||
else if (event.key == "ArrowUp" && this._shouldRefocusBubbleInputOnArrowUp() && noModifiers) {
|
||||
this._id("bubble-input").refocusInput();
|
||||
this.listLayout._table.selection.clearSelection();
|
||||
handled = true;
|
||||
}
|
||||
// handle focus and selection movement within bubble-input and item groups
|
||||
|
|
@ -245,6 +252,9 @@ export class CitationDialogKeyboardHandler {
|
|||
}
|
||||
else if (nodeToFocus.getAttribute("tabindex")) {
|
||||
nodeToFocus.focus();
|
||||
if (nodeToFocus.id === "citationDialog-list-table") {
|
||||
this._ensureListSelection();
|
||||
}
|
||||
}
|
||||
else {
|
||||
nodeToFocus.querySelector("[tabindex]:not([hidden])")?.focus();
|
||||
|
|
@ -256,7 +266,7 @@ export class CitationDialogKeyboardHandler {
|
|||
// Navigate the group by moving selection or focus between nodes in a group
|
||||
_navigateGroup({ group, current, forward, multiSelect, shouldFocus, shouldSelect }) {
|
||||
// navigable nodes have to be marked with data-arrow-nav-enabled
|
||||
let allFocusableWithinGroup = [...group.querySelectorAll("[tabindex][data-arrow-nav-enabled]")];
|
||||
let allFocusableWithinGroup = [...group.querySelectorAll("[tabindex][data-arrow-nav-enabled]:not([hidden]):not([disabled])")];
|
||||
let nextFocusableIndex = 0;
|
||||
for (let i = 0; i < allFocusableWithinGroup.length; i++) {
|
||||
if (allFocusableWithinGroup[i] == current) {
|
||||
|
|
@ -277,14 +287,6 @@ export class CitationDialogKeyboardHandler {
|
|||
|
||||
current?.classList.remove("current");
|
||||
nextNode.classList.add("current");
|
||||
// if the node is not being focused in list mode, make sure we scroll to it so it is visible
|
||||
if (!shouldFocus) {
|
||||
let wrapperRect = this._id("list-layout-wrapper").getBoundingClientRect();
|
||||
let nodeRect = nextNode.getBoundingClientRect();
|
||||
if (nodeRect.bottom > wrapperRect.bottom || nodeRect.top < wrapperRect.top) {
|
||||
nextNode.scrollIntoView();
|
||||
}
|
||||
}
|
||||
|
||||
if (multiSelect) {
|
||||
// on arrow keypress while holding shift, move focus and also perform multiselect
|
||||
|
|
@ -302,12 +304,74 @@ export class CitationDialogKeyboardHandler {
|
|||
return nextNode;
|
||||
}
|
||||
|
||||
// When focus enters the list-mode table with no selection, select the
|
||||
// first item so the user has a starting point for arrow navigation.
|
||||
_ensureListSelection() {
|
||||
let layout = this.listLayout;
|
||||
if (!layout?._table || layout._table.selection.count > 0) return;
|
||||
let firstItem = layout._listRows.findIndex(r => r.canBeAdded);
|
||||
if (firstItem >= 0) {
|
||||
layout._table.selection.select(firstItem);
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate VT selection up/down while focus stays on bubble-input.
|
||||
// ArrowUp past the first selectable row clears selection.
|
||||
_navigateListSelection(forward, multiSelect) {
|
||||
let table = this.listLayout?._table;
|
||||
if (!table) return;
|
||||
let selection = table.selection;
|
||||
let rows = this.listLayout._listRows;
|
||||
let rowCount = rows.length;
|
||||
|
||||
// Find the next selectable index from a given position
|
||||
let findSelectable = (from, fwd) => {
|
||||
let i = from;
|
||||
while (i >= 0 && i < rowCount) {
|
||||
if (rows[i]?.canBeAdded) return i;
|
||||
i += fwd ? 1 : -1;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
let focused = selection.focused;
|
||||
// Nothing selected yet -- select the first selectable row on ArrowDown
|
||||
if (selection.count === 0 || focused < 0) {
|
||||
if (!forward) return;
|
||||
let first = findSelectable(0, true);
|
||||
if (first >= 0) {
|
||||
selection.select(first);
|
||||
table.scrollToRow(first);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let next = findSelectable(focused + (forward ? 1 : -1), forward);
|
||||
// ArrowUp past the first selectable row -- clear selection
|
||||
if (next < 0 && !forward) {
|
||||
selection.clearSelection();
|
||||
table.scrollToRow(0);
|
||||
return;
|
||||
}
|
||||
if (next < 0) return;
|
||||
|
||||
if (multiSelect) {
|
||||
selection.shiftSelect(next);
|
||||
}
|
||||
else {
|
||||
selection.select(next);
|
||||
}
|
||||
table.scrollToRow(next);
|
||||
}
|
||||
|
||||
_shouldRefocusBubbleInputOnArrowUp() {
|
||||
if (!this._id("library-layout").hidden) {
|
||||
return this._id("library-other-items").contains(this.doc.activeElement);
|
||||
}
|
||||
if (!this._id("list-layout").hidden) {
|
||||
return this.doc.activeElement == this.doc.querySelector(".item");
|
||||
// In list mode, focus stays on bubble-input; arrowUp from the
|
||||
// first selected row clears selection (handled in navigateSelection).
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,18 +89,30 @@ export class CitationDialogSearchHandler {
|
|||
// Takes citedItems as a parameter to filter them out from Selected, Opened and Cited groups.
|
||||
getOrderedSearchResultGroups(citedItemIDs = new Set()) {
|
||||
let removeItemsIncludedInCitation = (items) => {
|
||||
return items.filter(i => !citedItemIDs.has(i.cslItemID ? i.cslItemID : i.id));
|
||||
items = items.filter(i => !citedItemIDs.has(i.cslItemID ? i.cslItemID : i.id));
|
||||
// When adding notes, also remove parent items whose child notes are all cited
|
||||
if (this.dialogState.isAddingNote()) {
|
||||
items = items.filter((item) => {
|
||||
if (!item.isRegularItem()) return true;
|
||||
let noteIDs = item.getNotes();
|
||||
return noteIDs.some(id => !citedItemIDs.has(id));
|
||||
});
|
||||
}
|
||||
return items;
|
||||
};
|
||||
let result = [];
|
||||
let groupKeys = ["selected", "open", "cited"];
|
||||
if (this.dialogState.isAddingAnnotations()) {
|
||||
groupKeys = ["selectedAnnotations", "selectedItems", "open", "cited"];
|
||||
}
|
||||
else if (this.dialogState.isAddingNote() && this.dialogState.getCurrentLayoutType() == "library") {
|
||||
groupKeys = ["selectedNotes", "selectedItems", "open", "cited"];
|
||||
}
|
||||
// selected/open/cited go first
|
||||
for (let groupKey of groupKeys) {
|
||||
let groupItems = this.results[groupKey];
|
||||
// in selected and opened items, do not display items already in the citation
|
||||
if (["selected", "selectedAnnotations", "selectedItems", "open"].includes(groupKey)) {
|
||||
if (["selected", "selectedAnnotations", "selectedItems", "selectedNotes", "open"].includes(groupKey)) {
|
||||
groupItems = removeItemsIncludedInCitation(groupItems);
|
||||
}
|
||||
if (groupItems.length) {
|
||||
|
|
@ -116,22 +128,29 @@ export class CitationDialogSearchHandler {
|
|||
// library items go after
|
||||
let libraryItems = Object.values(this.results.found.reduce((acc, item) => {
|
||||
if (!acc[item.libraryID]) {
|
||||
acc[item.libraryID] = { key: item.libraryID, group: [], isLibrary: true };
|
||||
acc[item.libraryID] = { key: item.libraryID, group: [], label: Zotero.Libraries.get(item.libraryID).name };
|
||||
}
|
||||
acc[item.libraryID].group.push(item);
|
||||
return acc;
|
||||
}, {}));
|
||||
|
||||
// sort actual items or notes
|
||||
let itemComparator = this.dialogState.isAddingNote() ? this._createNotesSort() : this._createItemsSort();
|
||||
libraryItems.forEach((library) => {
|
||||
library.group.sort(itemComparator);
|
||||
});
|
||||
|
||||
// sort libraries by the number of items
|
||||
libraryItems.sort((a, b) => b.group.length - a.group.length);
|
||||
result.push(...libraryItems);
|
||||
|
||||
// post processing: normalize every group into an array of { item, children }
|
||||
// objects so callers always receive the same shape.
|
||||
for (let section of result) {
|
||||
if (this.dialogState.isAddingNote() && this.dialogState.getCurrentLayoutType() === "list") {
|
||||
// _groupNotesWithParents already returns { item, children } objects
|
||||
section.group = this._groupNotesWithParents(section.group, citedItemIDs);
|
||||
}
|
||||
else {
|
||||
let itemComparator = this.dialogState.isAddingNote() ? this._createNotesSort() : this._createItemsSort();
|
||||
section.group.sort(itemComparator);
|
||||
section.group = section.group.map(item => ({ item, children: [] }));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -155,6 +174,11 @@ export class CitationDialogSearchHandler {
|
|||
this.results.selectedItems = this.results.selected.filter(item => !item.isAnnotation());
|
||||
this.results.selectedAnnotations = this.results.selected.filter(item => item.isAnnotation());
|
||||
}
|
||||
// when notes are being cited, in lib mode selected items are separated into top-level items and notes
|
||||
if (this.dialogState.isAddingNote()) {
|
||||
this.results.selectedItems = this.results.selected.filter(item => !item.isNote());
|
||||
this.results.selectedNotes = this.results.selected.filter(item => item.isNote());
|
||||
}
|
||||
// if a specific library ID is specified, only keep items from that library
|
||||
if (this.io.filterLibraryIDs) {
|
||||
this.results.open = this.results.open.filter(item => this.io.filterLibraryIDs.includes(item.libraryID));
|
||||
|
|
@ -232,6 +256,10 @@ export class CitationDialogSearchHandler {
|
|||
return str;
|
||||
}
|
||||
|
||||
keepItemsWithNotes(items) {
|
||||
return items.filter(item => this.isItemWithNotes(item));
|
||||
}
|
||||
|
||||
// Return items that are either annotations or are ancestors of annotations
|
||||
keepItemsWithAnnotations(items) {
|
||||
return items.filter(item => this.isItemWithAnnotations(item));
|
||||
|
|
@ -291,29 +319,31 @@ export class CitationDialogSearchHandler {
|
|||
this.io.filterLibraryIDs.forEach(id => s.addCondition("libraryID", "is", id));
|
||||
}
|
||||
let realInputRegex = /[\w\u007F-\uFFFF]/;
|
||||
if (!realInputRegex.test(this.searchValue)) return [];
|
||||
if (this.dialogState.isAddingNote()) {
|
||||
s.addCondition("quicksearch-titleCreatorYearNote", "contains", this.searchValue);
|
||||
let scope = new Zotero.Search;
|
||||
// Allow to search by ISBN/DOI for the top-level item
|
||||
let addedIdentifierCondition = this._addIdentifierConditions(scope, this.searchValue);
|
||||
if (!addedIdentifierCondition) {
|
||||
// If identifier is not provided, search by conditions equivalent to quicksearch-titleCreatorYear
|
||||
this._addQuickSearchEquivalentConditions(scope);
|
||||
}
|
||||
scope.addCondition("includeChildren", "true");
|
||||
s.setScope(scope);
|
||||
s.addCondition("itemType", "is", "note");
|
||||
}
|
||||
else if (realInputRegex.test(this.searchValue)) {
|
||||
// search for the identifier if it is provided,
|
||||
// otherwise look up by title, creator and year
|
||||
let isDOI = Zotero.Utilities.cleanDOI(this.searchValue);
|
||||
let isISBN = Zotero.Utilities.cleanISBN(this.searchValue);
|
||||
if (isDOI) {
|
||||
s.addCondition("DOI", "contains", this.searchValue);
|
||||
}
|
||||
else if (isISBN) {
|
||||
s.addCondition("ISBN", "contains", this.searchValue);
|
||||
}
|
||||
else {
|
||||
else {
|
||||
// search items by the identifier if provided, or by title/creator/year otherwise
|
||||
let addedIdentifierCondition = this._addIdentifierConditions(s, this.searchValue);
|
||||
if (!addedIdentifierCondition) {
|
||||
s.addCondition("quicksearch-titleCreatorYear", "contains", this.searchValue);
|
||||
s.addCondition("itemType", "isNot", "attachment");
|
||||
}
|
||||
s.addCondition("itemType", "isNot", "attachment");
|
||||
}
|
||||
let searchResultIDs = await s.search();
|
||||
// Search results might be in an unloaded library, so get items asynchronously and load necessary data
|
||||
await this._ensureRelevantItemsAreLoaded(searchResultIDs);
|
||||
var items = await Zotero.Items.getAsync(searchResultIDs);
|
||||
await Zotero.Items.loadDataTypes(items);
|
||||
return items;
|
||||
}
|
||||
|
||||
|
|
@ -326,7 +356,6 @@ export class CitationDialogSearchHandler {
|
|||
}
|
||||
|
||||
async _getReaderOpenItems() {
|
||||
if (this.dialogState.isAddingNote()) return [];
|
||||
let tabs = [];
|
||||
let win = Zotero.getMainWindow();
|
||||
// If the main window is open, use it to get open tabs
|
||||
|
|
@ -339,7 +368,7 @@ export class CitationDialogSearchHandler {
|
|||
if (!mainWindowLastState) return [];
|
||||
tabs = mainWindowLastState.tabs;
|
||||
}
|
||||
let itemIDs = tabs.filter(t => t.type === 'reader').sort((a, b) => {
|
||||
let itemIDs = tabs.filter(t => ['reader', 'note'].includes(t.type)).sort((a, b) => {
|
||||
// Sort selected tab first
|
||||
if (a.selected) return -1;
|
||||
else if (b.selected) return 1;
|
||||
|
|
@ -352,21 +381,21 @@ export class CitationDialogSearchHandler {
|
|||
if (!itemIDs.length) return [];
|
||||
|
||||
// Fetch top-most items and load necessary data, in case tabs belong to an unloaded library
|
||||
await this._ensureRelevantItemsAreLoaded(itemIDs);
|
||||
let items = [];
|
||||
for (let itemID of itemIDs) {
|
||||
let item = await Zotero.Items.getAsync(itemID);
|
||||
if (!item) continue;
|
||||
if (item && item.parentItemID) {
|
||||
item = await Zotero.Items.getAsync(item.parentItemID);
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
if (this.dialogState.isAddingAnnotations()) {
|
||||
// Make sure annotations are loaded on unloaded tabs from unloaded libraries
|
||||
await this._ensureRelevantItemsAreLoaded(items);
|
||||
items = this.keepItemsWithAnnotations(items);
|
||||
}
|
||||
else {
|
||||
await Zotero.Items.loadDataTypes(items);
|
||||
if (this.dialogState.isAddingNote()) {
|
||||
items = this.keepItemsWithNotes(items);
|
||||
}
|
||||
// Return deduplicated items since there may be multiple tabs opened for the same
|
||||
// top-level item (duplicate tabs or a multiple attachments belonging to the same item)
|
||||
|
|
@ -376,7 +405,7 @@ export class CitationDialogSearchHandler {
|
|||
_getSelectedLibraryItems() {
|
||||
let selected = Zotero.getActiveZoteroPane()?.getSelectedItems() || [];
|
||||
if (this.dialogState.isAddingNote()) {
|
||||
return selected.filter(i => i.isNote()) || [];
|
||||
return this.keepItemsWithNotes(selected);
|
||||
}
|
||||
if (this.dialogState.isAddingAnnotations()) {
|
||||
return this.keepItemsWithAnnotations(selected);
|
||||
|
|
@ -418,6 +447,16 @@ export class CitationDialogSearchHandler {
|
|||
itemStr += " " + makeSearchString(item.topLevelItem);
|
||||
}
|
||||
}
|
||||
// Handle searching through selected notes
|
||||
if (this.dialogState.isAddingNote()) {
|
||||
if (item.isNote()) {
|
||||
itemStr += ` ${item.getNote().toLowerCase()}`;
|
||||
if (item.parentItemID) {
|
||||
let parentStr = makeSearchString(item.topLevelItem);
|
||||
itemStr += ` ${parentStr}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Include items that match every word that was typed
|
||||
let allMatch = splits.every(split => itemStr.includes(split));
|
||||
if (allMatch) {
|
||||
|
|
@ -433,8 +472,16 @@ export class CitationDialogSearchHandler {
|
|||
let searchParts = Zotero.SearchConditions.parseSearchString(searchString);
|
||||
var collation = Zotero.getLocaleCollation();
|
||||
return ((a, b) => {
|
||||
// When displaying notes, top level notes may appear among top-level
|
||||
// container items.
|
||||
if (this.dialogState.isAddingNote()) {
|
||||
if (a.isNote() || b.isNote()) {
|
||||
return collation.compareString(1, a.getDisplayTitle(), b.getDisplayTitle());
|
||||
}
|
||||
}
|
||||
// Sort by left-bound name matches first
|
||||
var firstCreatorA = a.firstCreator, firstCreatorB = b.firstCreator;
|
||||
|
||||
|
||||
// Favor left-bound name matches (e.g., "Baum" < "Appelbaum"),
|
||||
// using last name of first author
|
||||
if (firstCreatorA && firstCreatorB) {
|
||||
|
|
@ -491,21 +538,104 @@ export class CitationDialogSearchHandler {
|
|||
return stringNoYear + " " + year;
|
||||
}
|
||||
|
||||
// load all ancestors,descendants, and siblings of provided items
|
||||
async _ensureRelevantItemsAreLoaded(items) {
|
||||
_addIdentifierConditions(search, searchValue) {
|
||||
let cleanDOI = Zotero.Utilities.cleanDOI(searchValue);
|
||||
let cleanISBN = Zotero.Utilities.cleanISBN(searchValue);
|
||||
|
||||
if (cleanDOI) {
|
||||
search.addCondition("DOI", "contains", cleanDOI);
|
||||
return true;
|
||||
}
|
||||
else if (cleanISBN) {
|
||||
search.addCondition("ISBN", "contains", cleanISBN);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Replicate quicksearch-titleCreatorYear conditions on a search object,
|
||||
// with an additional "note" condition so that note content is also matched.
|
||||
_addQuickSearchEquivalentConditions(search) {
|
||||
let parts = Zotero.SearchConditions.parseSearchString(this.searchValue);
|
||||
for (let part of parts) {
|
||||
search.addCondition("blockStart");
|
||||
search.addCondition("key", "is", part.text, false);
|
||||
search.addCondition("title", "contains", part.text, false);
|
||||
search.addCondition("publicationTitle", "contains", part.text, false);
|
||||
search.addCondition("shortTitle", "contains", part.text, false);
|
||||
search.addCondition("court", "contains", part.text, false);
|
||||
search.addCondition("year", "contains", part.text, false);
|
||||
search.addCondition("citationKey", "contains", part.text, false);
|
||||
search.addCondition("creator", "contains", part.text, false);
|
||||
search.addCondition("note", "contains", part.text, false);
|
||||
search.addCondition("blockEnd");
|
||||
}
|
||||
}
|
||||
|
||||
// Given an array of note items, construct a new array of { item, children }
|
||||
// objects where each parent regular item is paired with its child notes.
|
||||
// Top-level notes (no parent) have an empty children array.
|
||||
// Returns [{ item: parentA, children: [noteA1, noteA2] }, { item: topLevelNote, children: [] }]
|
||||
_groupNotesWithParents(items, citedItemIDs = new Set()) {
|
||||
let topLevelItems = {};
|
||||
for (let item of items) {
|
||||
let topLevel = item.topLevelItem;
|
||||
if (!topLevelItems[topLevel.id]) {
|
||||
topLevelItems[topLevel.id] = [];
|
||||
}
|
||||
if (item.parentItemID) {
|
||||
topLevelItems[topLevel.id].push(item);
|
||||
}
|
||||
}
|
||||
let topLevelArray = Object.keys(topLevelItems).map(id => Zotero.Items.get(id));
|
||||
topLevelArray.sort(this._createItemsSort());
|
||||
let result = [];
|
||||
for (let topLevelItem of topLevelArray) {
|
||||
let childNotes = topLevelItems[topLevelItem.id];
|
||||
// if there is a parent item with no notes, add all of its child notes
|
||||
// (e.g. for an opened item)
|
||||
if (childNotes.length === 0 && topLevelItem.isRegularItem()) {
|
||||
let noteIDs = topLevelItem.getNotes();
|
||||
childNotes = Zotero.Items.get(noteIDs);
|
||||
}
|
||||
// Exclude notes already added to the citation
|
||||
childNotes = childNotes.filter(note => !citedItemIDs.has(note.id));
|
||||
// Skip regular items whose child notes are all cited
|
||||
if (!childNotes.length && topLevelItem.isRegularItem()) continue;
|
||||
childNotes.sort(this._createNotesSort());
|
||||
result.push({ item: topLevelItem, children: childNotes });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// load all ancestors, descendants, and siblings of provided item IDs
|
||||
async _ensureRelevantItemsAreLoaded(itemIDs) {
|
||||
// Load the items themselves first so we can walk up to parents
|
||||
let items = await Zotero.Items.getAsync(itemIDs);
|
||||
await Zotero.Items.loadDataTypes(items);
|
||||
// Walk up to ensure all ancestors are loaded (e.g. for notes whose
|
||||
// parent may not be loaded yet, which would cause topLevelItem to throw)
|
||||
for (let item of items) {
|
||||
while (item.parentItemID) {
|
||||
item = await Zotero.Items.getAsync(item.parentItemID);
|
||||
}
|
||||
}
|
||||
let topLevelItems = items.map(item => item.topLevelItem);
|
||||
// load all data of top-level items and their attachments
|
||||
// Load all data of top-level items
|
||||
await Zotero.Items.loadDataTypes(topLevelItems);
|
||||
|
||||
// Load children: attachments and notes
|
||||
let regularItems = topLevelItems.filter(item => item.isRegularItem());
|
||||
let attachmentIDs = regularItems.flatMap(item => item.getAttachments());
|
||||
let attachments = await Zotero.Items.getAsync(attachmentIDs);
|
||||
await Zotero.Items.loadDataTypes(attachments);
|
||||
let noteIDs = regularItems.flatMap(item => item.getNotes());
|
||||
let children = await Zotero.Items.getAsync([...attachmentIDs, ...noteIDs]);
|
||||
await Zotero.Items.loadDataTypes(children);
|
||||
|
||||
// Load annotations.
|
||||
// Zotero.Items.loadDataTypes on parent items will set the
|
||||
// _annotations cache on attachments but not load the annotations themselves.
|
||||
// So fetch annotationIDs from the cache and load annotations separately.
|
||||
attachments = attachments.filter(attachment => attachment.isFileAttachment());
|
||||
let attachments = children.filter(item => item.isFileAttachment?.());
|
||||
let annotationIDs = attachments.flatMap(attachment => attachment.getAnnotations(false, true));
|
||||
let annotations = await Zotero.Items.getAsync(annotationIDs);
|
||||
await Zotero.Items.loadDataTypes(annotations);
|
||||
|
|
|
|||
|
|
@ -459,6 +459,68 @@ Zotero.Notes = new function () {
|
|||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an unsaved note item combining the content of multiple notes.
|
||||
* Embedded images are inlined as data URIs and citation metadata from all
|
||||
* notes is merged into a single container so that getExportableNote can
|
||||
* resolve citations that reference items not in the local library.
|
||||
*
|
||||
* @param {Zotero.Item[]} notes - Array of note items to combine
|
||||
* @returns {Promise<Zotero.Item>} - Unsaved note item with combined content
|
||||
*/
|
||||
this.createCombinedNote = async function (notes) {
|
||||
let parser = new DOMParser();
|
||||
let allCitationItems = [];
|
||||
let combinedInnerHTML = "";
|
||||
let schemaVersion = 0;
|
||||
for (let note of notes) {
|
||||
let noteHTML = note.getNote();
|
||||
// Replace <img data-attachment-key="..."> with inline data URIs
|
||||
let attachments = Zotero.Items.get(note.getAttachments());
|
||||
for (let attachment of attachments) {
|
||||
if (!attachment.isEmbeddedImageAttachment()) continue;
|
||||
let path = await attachment.getFilePathAsync();
|
||||
if (!path || !await IOUtils.exists(path)) continue;
|
||||
let contentType = attachment.attachmentContentType || "image/png";
|
||||
let dataURI = await Zotero.File.generateDataURI(path, contentType);
|
||||
noteHTML = noteHTML.replace(
|
||||
new RegExp(`(<img[^>]*?)data-attachment-key="${attachment.key}"`, "g"),
|
||||
`$1src="${dataURI}"`
|
||||
);
|
||||
}
|
||||
// Parse the note HTML and extract citation-items metadata + inner content
|
||||
let doc = parser.parseFromString(noteHTML, "text/html");
|
||||
let container = doc.querySelector('div[data-schema-version]');
|
||||
if (container) {
|
||||
// Track the highest schema version across all notes
|
||||
let sv = parseInt(container.getAttribute("data-schema-version")) || 0;
|
||||
if (sv > schemaVersion) {
|
||||
schemaVersion = sv;
|
||||
}
|
||||
// Collect citation items from this note's metadata
|
||||
let citationItemsAttr = container.getAttribute("data-citation-items");
|
||||
if (citationItemsAttr) {
|
||||
try {
|
||||
let items = JSON.parse(decodeURIComponent(citationItemsAttr));
|
||||
allCitationItems.push(...items);
|
||||
}
|
||||
catch (e) {
|
||||
Zotero.logError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (combinedInnerHTML) combinedInnerHTML += "<br><br>";
|
||||
combinedInnerHTML += container ? container.innerHTML : noteHTML;
|
||||
}
|
||||
// Wrap combined content in a single schema container with merged citation metadata
|
||||
let mergedCitationItems = encodeURIComponent(JSON.stringify(allCitationItems));
|
||||
let mergedHTML = `<div data-schema-version="${schemaVersion}" data-citation-items="${mergedCitationItems}">${combinedInnerHTML}</div>`;
|
||||
let mockNote = new Zotero.Item('note');
|
||||
mockNote.libraryID = notes[0].libraryID;
|
||||
mockNote.setNote(mergedHTML);
|
||||
return mockNote;
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy embedded images from one note to another and update
|
||||
* item keys in note HTML.
|
||||
|
|
|
|||
|
|
@ -1677,13 +1677,17 @@ Zotero.Integration.Session.prototype._insertCitingResult = async function (field
|
|||
);
|
||||
return this._insertNoteIntoDocument(fieldIndex, field, mockNote);
|
||||
}
|
||||
let firstItem = Zotero.Cite.getItem(citation.citationItems[0].id);
|
||||
if (firstItem && firstItem.isNote()) {
|
||||
return this._insertNoteIntoDocument(fieldIndex, field, firstItem);
|
||||
}
|
||||
else {
|
||||
return [await this._insertItemsIntoDocument(fieldIndex++, field, citation)];
|
||||
// Handle inserting notes
|
||||
if (allItems.every(item => item.isNote())) {
|
||||
// If a single note is selected, just insert it
|
||||
if (allItems.length == 1) {
|
||||
return this._insertNoteIntoDocument(fieldIndex, field, allItems[0]);
|
||||
}
|
||||
// If multiple notes are selected, combine them into a single unsaved note before inserting
|
||||
let mockNote = await Zotero.Notes.createCombinedNote(allItems);
|
||||
return this._insertNoteIntoDocument(fieldIndex, field, mockNote);
|
||||
}
|
||||
return [await this._insertItemsIntoDocument(fieldIndex++, field, citation)];
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ integration-citationDialog = Citation Dialog
|
|||
integration-citationDialog-section-open = Open Documents ({ $count })
|
||||
integration-citationDialog-section-selected = Selected Items ({ $count }/{ $total })
|
||||
integration-citationDialog-section-selectedAnnotations = Selected Annotations
|
||||
integration-citationDialog-section-selectedItems = Selected Items
|
||||
integration-citationDialog-section-selectedItems = Selected Items ({ $count })
|
||||
integration-citationDialog-section-selectedNotes = Selected Notes ({ $count })
|
||||
integration-citationDialog-section-cited = { $count ->
|
||||
[0] Cited Items
|
||||
*[other] Cited Items ({ $count })
|
||||
|
|
@ -97,6 +98,7 @@ integration-citationDialog-collapse-section =
|
|||
.title = Collapse section
|
||||
integration-citationDialog-bubble-empty = (no title)
|
||||
integration-citationDialog-add-to-citation = Add to Citation
|
||||
integration-citationDialog-insertNote-preview-msg = Select a note to see its preview
|
||||
integration-citationDialog-annotations-filter =
|
||||
.placeholder = Filter annotations
|
||||
integration-citationDialog-annotations-empty = Select an item, attachment, or annotation to view annotation details
|
||||
|
|
|
|||
|
|
@ -385,143 +385,161 @@
|
|||
#list-layout {
|
||||
#list-layout-wrapper {
|
||||
-moz-user-select: none;
|
||||
overflow-y: auto;
|
||||
scrollbar-color: var(--color-scrollbar) var(--color-scrollbar-background);
|
||||
height: 100%;
|
||||
.section {
|
||||
&:first-child {
|
||||
padding-top: 4px;
|
||||
|
||||
// Fill remaining space between the search row and #bottom-area,
|
||||
// and clip overflow so the inner virtualized table provides scrolling.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
// Let the virtualized table fill the wrapper so its body can scroll
|
||||
// instead of expanding the wrapper to fit all rows.
|
||||
.virtualized-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
// Neutralize default virtualized-table styling so list-mode rows behave like
|
||||
// the old section-based layout (stacked blocks, no flex-centering, no row
|
||||
// background/outline from VT's default selected/focused styling).
|
||||
.virtualized-table-body {
|
||||
padding: 4px 0 8px 0;
|
||||
}
|
||||
.row {
|
||||
display: block;
|
||||
border-radius: 0;
|
||||
// Clip row contents so collapsed rows (animated to height: 0
|
||||
// during section collapse) don't overflow on top of each other.
|
||||
overflow: hidden;
|
||||
|
||||
&.selected {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
&:last-child {
|
||||
padding-bottom: 8px;
|
||||
&.focused:not(.selected) {
|
||||
outline: none;
|
||||
}
|
||||
// show dividers except for on the last section
|
||||
.divider {
|
||||
margin: 4px 16px;
|
||||
border-bottom: 1px solid var(--fill-quinary);
|
||||
}
|
||||
&:last-child {
|
||||
.divider {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.header {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: var(--fill-secondary);
|
||||
padding: 4px 8px 4px 16px;
|
||||
}
|
||||
|
||||
// Section dividers -- emitted as their own row between sections.
|
||||
// The trailing divider after the last section is omitted in JS.
|
||||
.row > .divider {
|
||||
margin: 4px 16px;
|
||||
border-bottom: 1px solid var(--fill-quinary);
|
||||
}
|
||||
|
||||
// Section header -- emitted as its own row at the start of every section.
|
||||
.row > .header {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: var(--fill-secondary);
|
||||
margin: 4px 8px 4px 16px;
|
||||
display: flex;
|
||||
|
||||
.header-btn-group {
|
||||
display: flex;
|
||||
.header-btn-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 17px;
|
||||
}
|
||||
align-items: center;
|
||||
height: 17px;
|
||||
}
|
||||
.item {
|
||||
@include focus-ring(true);
|
||||
margin: 0 8px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
// Collapsible header (currently only the "selected" group when it has
|
||||
// multiple items). Keyed on a class set by _renderRow, replacing the old
|
||||
// `.section.expandable` / `.section.expandable.expanded` ancestors.
|
||||
.row > .header.collapsible.selected.focused {
|
||||
outline: 2px solid var(--accent-blue30);
|
||||
outline-offset: 1px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.row > .header.collapsible {
|
||||
justify-content: space-between;
|
||||
|
||||
.header-label {
|
||||
@include focus-ring;
|
||||
border-radius: 5px;
|
||||
color: var(--fill-primary);
|
||||
cursor: default;
|
||||
-moz-window-dragging: no-drag;
|
||||
&:not([disabled]):hover {
|
||||
background-color: var(--fill-quinary);
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
&.selected {
|
||||
background-color: var(--selected-item-background);
|
||||
border-radius: 0;
|
||||
|
||||
}
|
||||
&.selected-first {
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
}
|
||||
&.selected-last {
|
||||
border-bottom-left-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-top: -4px;
|
||||
margin-inline-end: 4px;
|
||||
flex-shrink: 0;
|
||||
&.retracted {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--accent-red);
|
||||
@include svgicon("cross", "universal", "16");
|
||||
}
|
||||
}
|
||||
.description {
|
||||
// Needed to have empty item row description to still occupy height
|
||||
&::before {
|
||||
@include svgicon("chevron-8", "universal", "8");
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
display: inline-block;
|
||||
content: "";
|
||||
white-space: pre;
|
||||
margin-inline-start: 20px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
overflow: hidden;
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
margin-inline-end: 3px;
|
||||
|
||||
transform: rotate(180deg);
|
||||
transform-origin: center;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
vertical-align: middle;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
}
|
||||
&.expandable {
|
||||
// styling for expandable header with a twisty
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.row > .header.collapsible.collapsed .header-label::before {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.header-label {
|
||||
@include focus-ring;
|
||||
border-radius: 5px;
|
||||
-moz-window-dragging: no-drag;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
&::before {
|
||||
@include svgicon("chevron-8", "universal", "8");
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
display: inline-block;
|
||||
content: "";
|
||||
white-space: pre;
|
||||
margin-inline-end: 3px;
|
||||
// Items (including the cited-items loading placeholder).
|
||||
.row > .item {
|
||||
@include focus-ring(true);
|
||||
margin: 0 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 5px;
|
||||
color: var(--fill-primary);
|
||||
cursor: default;
|
||||
-moz-window-dragging: no-drag;
|
||||
|
||||
transform: rotate(0deg);
|
||||
transform-origin: center;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
vertical-align: middle;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.expanded {
|
||||
.header-label::before {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
// item container has no height when it is collapsed
|
||||
.itemsContainer {
|
||||
transition: height 0.3s ease;
|
||||
overflow: hidden;
|
||||
// add small padding at the top and bottom to make sure the focus-ring (if it appear) is not
|
||||
// cutoff by the overflow:hidden. Negative margin is to preserve spacing set by other components.
|
||||
padding: 1px 0;
|
||||
margin: -1px 0;
|
||||
}
|
||||
&:not(.expanded) {
|
||||
.itemsContainer {
|
||||
height: 0 !important; // important to override inline height with items displayed
|
||||
}
|
||||
&:not([disabled]):hover {
|
||||
background-color: var(--fill-quinary);
|
||||
}
|
||||
&.selected {
|
||||
background-color: var(--selected-item-background);
|
||||
border-radius: 0;
|
||||
}
|
||||
&.first-selected {
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
}
|
||||
&.last-selected {
|
||||
border-bottom-left-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-top: -4px;
|
||||
margin-inline-end: 4px;
|
||||
flex-shrink: 0;
|
||||
&.retracted {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--accent-red);
|
||||
@include svgicon("cross", "universal", "16");
|
||||
}
|
||||
}
|
||||
.description {
|
||||
// Needed to have empty item row description to still occupy height
|
||||
white-space: pre;
|
||||
margin-inline-start: 20px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
overflow: hidden;
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.empty {
|
||||
#list-layout-wrapper {
|
||||
#list-layout-wrapper .virtualized-table-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -877,3 +895,87 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#citation-dialog[dialog-type="add-note"] {
|
||||
#library-layout {
|
||||
#sidebar {
|
||||
display: block;
|
||||
width: 250px;
|
||||
border-inline-start: var(--material-panedivider);
|
||||
background: var(--material-sidepane) !important;
|
||||
#add-note-sidebar {
|
||||
height: 100%;
|
||||
#note-preview {
|
||||
#links-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
#empty-note-preview-message {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
// when adding a note, the item cards are slightly taller
|
||||
#library-other-items {
|
||||
height: 100px;
|
||||
.item {
|
||||
height: 58px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
#list-layout {
|
||||
#list-layout-wrapper {
|
||||
// Items rendered inside VirtualizedTable rows use .row > .item
|
||||
.row > .item {
|
||||
border-radius: 5px;
|
||||
// container parent items of notes are not properly selectable --
|
||||
// clicking them toggles collapse instead
|
||||
&.container {
|
||||
background-color: var(--color-background);
|
||||
&:hover {
|
||||
outline: 2px solid var(--fill-quinary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
// Selected containers get the same outline treatment as
|
||||
// selected collapsible headers -- they're not addable, so
|
||||
// the item's background-based selection style doesn't apply.
|
||||
&.selected {
|
||||
outline: 2px solid var(--accent-blue30);
|
||||
outline-offset: -2px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.title::before {
|
||||
@include svgicon("chevron-8", "universal", "8");
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
display: inline-block;
|
||||
content: "";
|
||||
margin-inline-end: 4px;
|
||||
vertical-align: middle;
|
||||
margin-bottom: 3px;
|
||||
fill: var(--fill-secondary);
|
||||
}
|
||||
&.collapsed .title::before {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
// push description a bit more to the right so that it
|
||||
// aligns with title after twisty is added
|
||||
.description {
|
||||
margin-inline-start: 32px;
|
||||
}
|
||||
}
|
||||
&:not(.container) {
|
||||
padding-inline: 20px;
|
||||
}
|
||||
&.child {
|
||||
padding-inline: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,8 +74,6 @@ bubble-input {
|
|||
position: relative;
|
||||
cursor: pointer;
|
||||
background-color: var(--fill-quinary);
|
||||
// don't let the bubble with a very long locator stretch bubble-input
|
||||
overflow-x: hidden;
|
||||
|
||||
&:hover:not(.showingDetails) {
|
||||
background-color: var(--fill-quarternary);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ describe("Citation Dialog", function () {
|
|||
Services.ww.openWindow(null, "chrome://zotero/content/integration/citationDialog.xhtml", "", "", io);
|
||||
dialog = await dialogPromise;
|
||||
doc = dialog.document;
|
||||
dialog.DIALOG_STATE.isTestRun = true;
|
||||
IOManager = dialog.IOManager;
|
||||
CitationDataManager = dialog.CitationDataManager;
|
||||
SearchHandler = dialog.SearchHandler;
|
||||
|
|
@ -648,7 +649,7 @@ describe("Citation Dialog", function () {
|
|||
}
|
||||
|
||||
// Before switching to add-note, note should not appear in search results
|
||||
let results = dialog.SearchHandler.getOrderedSearchResultGroups()[0].group;
|
||||
let results = dialog.SearchHandler.getOrderedSearchResultGroups()[0].group.map(e => e.item);
|
||||
assert.notIncludeMembers(results, [note]);
|
||||
|
||||
// Switch to add-note type
|
||||
|
|
@ -659,7 +660,7 @@ describe("Citation Dialog", function () {
|
|||
}
|
||||
|
||||
// After switching to add-note, the note must appear in search results
|
||||
results = dialog.SearchHandler.getOrderedSearchResultGroups()[0].group;
|
||||
results = dialog.SearchHandler.getOrderedSearchResultGroups()[0].group.map(e => e.item);
|
||||
assert.includeMembers(results, [note]);
|
||||
// And it's node is rendered
|
||||
let noteNode = dialog.document.querySelector(`.item[id="${note.id}"]`);
|
||||
|
|
@ -675,7 +676,7 @@ describe("Citation Dialog", function () {
|
|||
|
||||
// Before switching to add/edit citation, item should not appear in search results
|
||||
assert.equal(dialog.DIALOG_STATE.type, "citation");
|
||||
let results = dialog.SearchHandler.getOrderedSearchResultGroups()[0].group;
|
||||
let results = dialog.SearchHandler.getOrderedSearchResultGroups()[0].group.map(e => e.item);
|
||||
|
||||
// Switch to add/edit citation
|
||||
await dialog.setDialogType("citation");
|
||||
|
|
@ -684,7 +685,7 @@ describe("Citation Dialog", function () {
|
|||
}
|
||||
|
||||
// Item must now appear in search results
|
||||
results = dialog.SearchHandler.getOrderedSearchResultGroups()[0].group;
|
||||
results = dialog.SearchHandler.getOrderedSearchResultGroups()[0].group.map(e => e.item);
|
||||
assert.includeMembers(results, [item]);
|
||||
// Item node must be rendered
|
||||
let itemNode = dialog.document.querySelector(`.item[id="${item.id}"]`);
|
||||
|
|
@ -728,6 +729,130 @@ describe("Citation Dialog", function () {
|
|||
|
||||
await parentItem.eraseTx();
|
||||
});
|
||||
|
||||
it("should combine multiple notes into a single unsaved note", async function () {
|
||||
let noteA = await createDataObject('item', { itemType: 'note' });
|
||||
noteA.setNote("<div data-schema-version=\"9\"><p>Content from note A</p></div>");
|
||||
await noteA.saveTx();
|
||||
|
||||
let noteB = await createDataObject('item', { itemType: 'note' });
|
||||
noteB.setNote("<div data-schema-version=\"9\"><p>Content from note B</p></div>");
|
||||
await noteB.saveTx();
|
||||
|
||||
let combined = await Zotero.Notes.createCombinedNote([noteA, noteB]);
|
||||
|
||||
// The combined note should be unsaved (no id)
|
||||
assert.isFalse(combined.id > 0);
|
||||
|
||||
// The combined note content should include both notes' content
|
||||
let html = combined.getNote();
|
||||
assert.include(html, "Content from note A");
|
||||
assert.include(html, "Content from note B");
|
||||
|
||||
// There should be a line break separating the two notes
|
||||
assert.include(html, "<br><br>");
|
||||
|
||||
// The combined note should have the schema version wrapper
|
||||
assert.include(html, 'data-schema-version="9"');
|
||||
|
||||
await noteA.eraseTx();
|
||||
await noteB.eraseTx();
|
||||
});
|
||||
|
||||
it("should render child notes under parent item container in list mode", async function () {
|
||||
await dialog.setDialogType("add-note");
|
||||
await IOManager.toggleDialogMode("list");
|
||||
while (SearchHandler.searching) {
|
||||
await Zotero.Promise.delay(10);
|
||||
}
|
||||
|
||||
let parentItem = await createDataObject('item', { title: "unique_parent_for_note_test" });
|
||||
let childNote = await createDataObject('item', { itemType: 'note', parentID: parentItem.id });
|
||||
childNote.setNote("<p>Child note content for testing</p>");
|
||||
await childNote.saveTx();
|
||||
|
||||
// Search for the parent item title
|
||||
await dialog.currentLayout.search("unique_parent_for_note_test", { skipDebounce: true });
|
||||
|
||||
// The parent item should be rendered as a container
|
||||
let parentNode = dialog.document.querySelector(`.item[id="${parentItem.id}"]`);
|
||||
while (!parentNode) {
|
||||
await Zotero.Promise.delay(10);
|
||||
parentNode = dialog.document.querySelector(`.item[id="${parentItem.id}"]`);
|
||||
}
|
||||
assert.isOk(parentNode, "parent item node should exist");
|
||||
assert.isTrue(parentNode.classList.contains("container"), "parent should have container class");
|
||||
|
||||
// The child note should be rendered as a child
|
||||
let childNode = dialog.document.querySelector(`.item[id="${childNote.id}"]`);
|
||||
while (!childNode) {
|
||||
await Zotero.Promise.delay(10);
|
||||
childNode = dialog.document.querySelector(`.item[id="${childNote.id}"]`);
|
||||
}
|
||||
assert.isOk(childNode, "child note node should exist");
|
||||
assert.isTrue(childNode.classList.contains("child"), "child note should have child class");
|
||||
|
||||
await parentItem.eraseTx();
|
||||
});
|
||||
|
||||
it("should add multiple selected notes to bubble-input", async function () {
|
||||
await dialog.setDialogType("add-note");
|
||||
await IOManager.toggleDialogMode("list");
|
||||
while (SearchHandler.searching) {
|
||||
await Zotero.Promise.delay(10);
|
||||
}
|
||||
|
||||
// Clear any existing items in the citation
|
||||
dialog.CitationDataManager.items = [];
|
||||
dialog.CitationDataManager.updateItemAddedCache();
|
||||
IOManager.updateBubbleInput();
|
||||
|
||||
let noteOne = await createDataObject('item', { itemType: 'note' });
|
||||
noteOne.setNote("<p>First note for multi-select</p>");
|
||||
await noteOne.saveTx();
|
||||
|
||||
let noteTwo = await createDataObject('item', { itemType: 'note' });
|
||||
noteTwo.setNote("<p>Second note for multi-select</p>");
|
||||
await noteTwo.saveTx();
|
||||
|
||||
// Search to make both notes appear
|
||||
await dialog.currentLayout.search("note for multi-select", { skipDebounce: true });
|
||||
|
||||
// Find the row indices for both notes
|
||||
let rows = dialog.listLayout._listRows;
|
||||
let noteOneIndex = rows.findIndex(r => r.kind === "item" && r.ref?.id === noteOne.id);
|
||||
let noteTwoIndex = rows.findIndex(r => r.kind === "item" && r.ref?.id === noteTwo.id);
|
||||
assert.isAbove(noteOneIndex, -1, "noteOne should be in _listRows");
|
||||
assert.isAbove(noteTwoIndex, -1, "noteTwo should be in _listRows");
|
||||
|
||||
// Select both rows
|
||||
dialog.listLayout._table.selection.select(noteOneIndex);
|
||||
dialog.listLayout._table.selection.toggleSelect(noteTwoIndex);
|
||||
|
||||
// Activate (simulates Enter) on the selected rows
|
||||
let indices = Array.from(dialog.listLayout._table.selection.selected);
|
||||
dialog.listLayout._handleActivate(
|
||||
{ stopPropagation: () => {} },
|
||||
indices
|
||||
);
|
||||
|
||||
// Both notes should be in CitationDataManager.items
|
||||
let citedIDs = dialog.CitationDataManager.items.map(i => i.item.id);
|
||||
assert.sameMembers(citedIDs, [noteOne.id, noteTwo.id]);
|
||||
|
||||
// Both should appear as bubbles in bubble-input
|
||||
let bubbleOne, bubbleTwo;
|
||||
while (!bubbleOne && !bubbleTwo) {
|
||||
bubbleOne = dialog.document.querySelector(`.bubble[dialogReferenceID="${dialog.CitationDataManager.items[0].dialogReferenceID}"]`);
|
||||
bubbleTwo = dialog.document.querySelector(`.bubble[dialogReferenceID="${dialog.CitationDataManager.items[1].dialogReferenceID}"]`);
|
||||
await Zotero.Promise.delay(10);
|
||||
}
|
||||
assert.ok(bubbleOne);
|
||||
assert.ok(bubbleTwo);
|
||||
|
||||
await noteOne.eraseTx();
|
||||
await noteTwo.eraseTx();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
|
@ -770,7 +895,7 @@ describe("Citation Dialog", function () {
|
|||
while (SearchHandler.searching) {
|
||||
await Zotero.Promise.delay(10);
|
||||
}
|
||||
dialog.IOManager._lastClickTime = null;
|
||||
dialog.libraryLayout._lastClickTime = null;
|
||||
});
|
||||
|
||||
after(function () {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue