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:
Bogdan Abaev 2026-04-16 17:26:14 -07:00
parent 7ca8242a3e
commit e8397e48c5
11 changed files with 1621 additions and 594 deletions

File diff suppressed because it is too large Load diff

View file

@ -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">

View file

@ -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"));
}
}

View file

@ -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;
}

View file

@ -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);

View file

@ -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.

View file

@ -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)];
};
/**

View file

@ -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

View file

@ -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;
}
}
}
}
}

View file

@ -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);

View file

@ -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 () {