annotations showing in itemTree (#3416)

- Annotations are displayed in itemTree under their file attachments
  on a third level. The annotation spans the entire row.
- The title is constructed on the go. When possible, it
  includes annotation quote and comment as pseudo-columns
  of the row. The comment occupies about twice as much space as
  the quote. Otherwise, (if there is no quote) only the annotation
  comment
  is included as the "title" part of the row
- Non-CJK segments of the quote part of the annotation row are
  italicized. CJK segments are left as is. If CJK segments are present,
  there is more padding between quote and comment parts.
- Search matches the actual attachment instead of its parent file.
- Can create child notes from annotations of the same item or
  standalone notes from annotations across different items from
  the context menu or the header button.
- When an annotation (or multiple annotations) are selected, the
  annotationItemPane component is displayed where annotations are
  grouped by their top-level item. Annotations are displayed fully,
  without having their content cut off.
- Special treatment for annotations to always prompt
  to erase the item regardless of what collectionTree row
  is selected (e.g., if a collection is selected, we
  still want one to be able to delete the annotation).
  This only applies if all selected items are annotations.
  If multiple items are selected, some annotations and
  some not, do nothing. This is until the trash is
  ready. In the future, we may send annotations to trash
- strip all HTML tags from annotation for now, until the logic to
  properly render annotation markup is copied over from the reader
  (applies to both annotation-row component and the annotation
  item rendered in the itemTree)
- Added a generalized "expandToItem" function to itemTree to
  expand all ancestors of a given item, similar to "expandToCollection"
  from collectionTree
- add annotation conditions to advanced search
- show [Image not available] if no annotation file for ink or image
  annotations
- only keep annotation-specific context menu options when some
  annotations are selected in itemTree
- enable Quick Copy of annotations from itemTree via drag-drop,
  shortcut key, or Edit → Copy Annotation
- Minor refactoring of Zotero.Annotation.toJSON() to pull out async code
  that handles ink and image annotations, so that
  Zotero.Annotation.toJSONsync() for highlight, underline, and note
  annotations does not have to be awaited. Since ink and image
  annotation don't seem to work for drag-drop Quick Copy, they are just
  skipped for now.
This commit is contained in:
abaevbog 2025-04-28 01:10:28 -07:00 committed by GitHub
parent 3144314d20
commit 48fd23ccec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 828 additions and 110 deletions

View file

@ -68,6 +68,7 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
['attachment-row', 'chrome://zotero/content/elements/attachmentRow.js'],
['attachment-annotations-box', 'chrome://zotero/content/elements/attachmentAnnotationsBox.js'],
['annotation-row', 'chrome://zotero/content/elements/annotationRow.js'],
['annotation-items-pane', 'chrome://zotero/content/elements/annotationItemsPane.js'],
['context-notes-list', 'chrome://zotero/content/elements/contextNotesList.js'],
['note-row', 'chrome://zotero/content/elements/noteRow.js'],
['notes-context', 'chrome://zotero/content/elements/notesContext.js'],

View file

@ -0,0 +1,134 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2024 Corporation for Digital Scholarship
Vienna, Virginia, USA
https://www.zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
{
class AnnotationItemsPane extends XULElementBase {
content = MozXULElement.parseXULToFragment(`
<html:div class="custom-head"></html:div>
<html:div class="body zotero-view-item"> </html:div>
`);
set items(items) {
if (items.some(item => !item.isAnnotation())) return;
this._items = items;
}
get items() {
return this._items || [];
}
init() {
this._body = this.querySelector('.body');
this._notifierID = Zotero.Notifier.registerObserver(this, ['item']);
}
destroy() {
Zotero.Notifier.unregisterObserver(this._notifierID);
}
notify(action, type, ids) {
if (action == 'modify') {
for (let id of ids) {
let updatedItem = Zotero.Items.get(id);
// If a selected annotation is renamed, re-render its annotation row
if (updatedItem.isAnnotation()) {
let row = this.querySelector(`annotation-row[annotation-id="${id}"]`);
if (row) {
row.render();
}
}
// Update name of collapsible section if item is renamed
else if (updatedItem.isRegularItem()) {
let section = this.querySelector(`collapsible-section[data-item-id="${id}"]`);
if (section) {
section.summary = updatedItem.getDisplayTitle();
}
}
}
}
}
render() {
if (!this.initialized) return;
let topLevelItems = Zotero.Items.getTopLevel(this.items);
// Remove collapsible sections for top-level items whose annotations are no longer selected
for (let section of [...this.querySelectorAll("collapsible-section")]) {
let parentID = section.dataset.pane.split("-")[1];
if (!topLevelItems.some(item => item.id == parentID)) {
section.remove();
}
}
for (let parentItem of topLevelItems) {
let selectedAnnotations = this.items.filter(item => item.topLevelItem.id == parentItem.id);
// Create a collapsible section for each top-level item if it does not exist yet
let section = this.querySelector(`[data-pane="annotations-${parentItem.id}"]`);
if (!section) {
section = document.createXULElement("collapsible-section");
section.dataset.l10nId = "section-attachments-annotations";
section.dataset.pane = `annotations-${parentItem.id}`;
section.summary = parentItem.getDisplayTitle();
let sectionBody = document.createElement("div");
sectionBody.classList.add("body");
section.appendChild(sectionBody);
this._body.append(section);
}
document.l10n.setArgs(section, { count: selectedAnnotations.length });
// Add annotations into this collapsible section
for (let annotation of selectedAnnotations) {
// Skip rows that already exist
if (this.querySelector(`annotation-row[annotation-id="${annotation.id}"]`)) continue;
let row = document.createXULElement('annotation-row');
row.annotation = annotation;
section.querySelector('.body').append(row);
}
}
// Remove annotation rows for annotations that are no longer selected
for (let row of [...this.querySelectorAll("annotation-row")]) {
let rowID = row.getAttribute("annotation-id");
if (!this.items.some(obj => obj.id == rowID)) {
row.remove();
}
}
}
renderCustomHead(callback) {
let customHead = this.querySelector(".custom-head");
customHead.replaceChildren();
let append = (...args) => {
customHead.append(...args);
};
if (callback) callback({
doc: document,
append,
});
}
}
customElements.define("annotation-items-pane", AnnotationItemsPane);
}

View file

@ -88,20 +88,34 @@
img.src = Zotero.File.pathToFileURI(imagePath);
img.draggable = false;
this._body.append(img);
// if the image could not be loaded for some reason (e.g. file is not there),
// show a placeholder text
img.addEventListener('error', () => {
let placeholder = document.createElement('div');
placeholder.classList.add('comment');
document.l10n.setAttributes(placeholder, 'annotation-image-not-available');
this._body.replaceChildren(placeholder);
});
}
}
// Strip all html tags from comment and text for now until the algorithm for safe
// rendering of relevant html tags is carried over from the reader
let parserUtils = Cc["@mozilla.org/parserutils;1"].getService(Ci.nsIParserUtils);
if (this._annotation.annotationText) {
let text = document.createElement('div');
text.classList.add('quote');
text.textContent = this._annotation.annotationText;
let plainQuote = parserUtils.convertToPlainText(this._annotation.annotationText, Ci.nsIDocumentEncoder.OutputRaw, 0);
text.textContent = plainQuote;
this._body.append(text);
}
if (this._annotation.annotationComment) {
let comment = document.createElement('div');
comment.classList.add('comment');
comment.textContent = this._annotation.annotationComment;
let plainComment = parserUtils.convertToPlainText(this._annotation.annotationComment, Ci.nsIDocumentEncoder.OutputRaw, 0);
comment.textContent = plainComment;
this._body.append(comment);
}
@ -110,6 +124,9 @@
this._tags.textContent = tags.map(tag => tag.tag).sort(Zotero.localeCompare).join(Zotero.getString('punctuation.comma') + ' ');
this.style.setProperty('--annotation-color', this._annotation.annotationColor);
// A11y - make focusable + add screen reader's labels
this.setAttribute("tabindex", 0);
this.setAttribute("aria-label", this.annotation.getDisplayTitle());
}
}

View file

@ -372,11 +372,11 @@
}
get _disableCollapsing() {
return !!this.closest('panel, menupopup, merge-pane, scaffold-item-preview');
return !!this.closest('panel, menupopup, merge-pane, scaffold-item-preview, annotation-items-pane');
}
get _disableSavingOpenState() {
return !!this.closest('merge-pane, scaffold-item-preview');
return !!this.closest('merge-pane, scaffold-item-preview, annotation-items-pane');
}
_handleClick = (event) => {

View file

@ -36,6 +36,8 @@
previousfocus="zotero-items-tree" />
<duplicates-merge-pane id="zotero-duplicates-merge-pane" />
<annotation-items-pane id="zotero-annotations-pane" />
</deck>
<item-pane-sidenav id="zotero-view-item-sidenav" class="zotero-view-item-sidenav"/>
`);
@ -45,6 +47,7 @@
this._noteEditor = this.querySelector("#zotero-note-editor");
this._duplicatesPane = this.querySelector("#zotero-duplicates-merge-pane");
this._messagePane = this.querySelector("#zotero-item-message");
this._annotationsPane = this.querySelector("#zotero-annotations-pane");
this._sidenav = this.querySelector("#zotero-view-item-sidenav");
this._deck = this.querySelector("#zotero-item-pane-content");
@ -108,6 +111,10 @@
if (!this.data) return false;
let hideSidenav = false;
let renderStatus = false;
// Only annotations selected
if (this.data.length > 0 && this.data.every(item => item.isAnnotation())) {
return renderStatus = this.renderAnnotations(this.data);
}
// Single item selected
if (this.data.length == 1) {
let item = this.data[0];
@ -140,6 +147,14 @@
}
}
renderAnnotations(annotations) {
this.mode = "annotations";
let annotationsViewer = document.getElementById("zotero-annotations-pane");
annotationsViewer.items = annotations;
annotationsViewer.render();
return true;
}
renderNoteEditor(item) {
this.mode = "note";
@ -318,6 +333,11 @@
this.updateReadLabel();
return;
}
// Create note from annotations button
if (this.data.every(item => item.isAnnotation())) {
container.renderCustomHead(this.renderAnnotationsHead.bind(this));
return;
}
container.renderCustomHead();
}
@ -381,6 +401,21 @@
this.setTranslateButton();
}
renderAnnotationsHead(data) {
let { doc, append } = data;
let button = doc.createXULElement("button");
button.id = 'zotero-item-pane-note-from-annotations';
if (Zotero.Items.getTopLevel(this.data).length == 1) {
button.label = Zotero.getString('pane.items.menu.addNoteFromAnnotations');
button.addEventListener("command", () => ZoteroPane.addNoteFromAnnotationsFromSelected());
}
else {
button.label = Zotero.getString('pane.items.menu.createNoteFromAnnotations');
button.addEventListener("command", () => ZoteroPane.createStandaloneNoteFromAnnotationsFromSelected());
}
append(button);
}
updateReadLabel() {
var items = this.data;
var isUnread = false;
@ -493,8 +528,12 @@
getCurrentPane(mode = undefined) {
if (!mode) {
// Guess a mode from the current data
// Only annotation items selected
if (this.data.every(item => item.isAnnotation())) {
mode = "annotations";
}
// No/multiple objects are selected OR selected object is a trashed collection/search
if (!this.data.length || this.data.length > 1
else if (!this.data.length || this.data.length > 1
|| this.data[0] instanceof Zotero.Collection || this.data[0] instanceof Zotero.Search) {
mode = "message";
}
@ -510,6 +549,7 @@
item: "_itemDetails",
note: "_noteEditor",
duplicates: "_duplicatesPane",
annotations: "_annotationsPane"
};
return this[map[mode]];
}
@ -588,6 +628,10 @@
}
break;
}
case "annotations": {
this._deck.selectedIndex = 4;
break;
}
}
let isViewingItem = type == "item";
if (previousViewType != "item" && isViewingItem) {

View file

@ -268,8 +268,6 @@ var ItemTree = class ItemTree extends LibraryTree {
.concat(await this.collectionTreeRow.getTrashedCollections())
.concat(await Zotero.Searches.getDeleted(this.collectionTreeRow.ref.libraryID));
}
// TEMP: Hide annotations
newSearchItems = newSearchItems.filter(item => !item.isAnnotation());
// Remove notes and attachments if necessary
if (this.props.regularOnly) {
newSearchItems = newSearchItems.filter((item) => {
@ -309,7 +307,8 @@ var ItemTree = class ItemTree extends LibraryTree {
if (row.ref instanceof Zotero.Item && row.ref.parentID) {
continue;
}
let isSearchParent = newSearchParentIDs.has(row.ref.treeViewID);
let attachments = row.ref.isRegularItem() ? row.ref.getAttachments() : [];
let isSearchParent = newSearchParentIDs.has(row.ref.treeViewID) || attachments.some(id => newSearchParentIDs.has(id));
// If not showing children or no children match the search, close
if (this.props.regularOnly || !isSearchParent) {
row.isOpen = false;
@ -333,8 +332,10 @@ var ItemTree = class ItemTree extends LibraryTree {
else if (skipChildren) {
continue;
}
newRows.push(row);
allItemIDs.add(row.ref.treeViewID);
if (!allItemIDs.has(row.ref.id)) {
newRows.push(row);
allItemIDs.add(row.ref.treeViewID);
}
}
// Add new items
@ -348,6 +349,14 @@ var ItemTree = class ItemTree extends LibraryTree {
continue;
}
item = Zotero.Items.get(parentItemID);
// Go up one more level to check for parents of annotation rows
let parentsParent = item.parentItemID;
if (parentsParent) {
if (allItemIDs.has(parentsParent)) {
continue;
}
item = Zotero.Items.get(parentsParent);
}
}
// Parent item may have already been added from child
else if (allItemIDs.has(item.treeViewID)) {
@ -356,9 +365,11 @@ var ItemTree = class ItemTree extends LibraryTree {
// Add new top-level items
let row = new ItemTreeRow(item, 0, false);
newRows.push(row);
allItemIDs.add(item.treeViewID);
addedItemIDs.add(item.treeViewID);
if (!allItemIDs.has(item.treeViewID)) {
newRows.push(row);
allItemIDs.add(item.treeViewID);
addedItemIDs.add(item.treeViewID);
}
}
this._rows = newRows;
@ -1231,15 +1242,11 @@ var ItemTree = class ItemTree extends LibraryTree {
continue;
}
// Get the row of the parent, if there is one
let parent = item.parentItemID;
let parentRow = parent && this._rowMap[parent];
// If row with id isn't visible, check to see if it's hidden under a parent
if (row == undefined) {
if (!parent || parentRow === undefined) {
// No parent -- it's not here
await this.expandToItem(id);
if (!this._rowMap[id]) {
// The row is still not found
// Clear the quick search and tag selection and try again (once)
if (!noRecurse && window.ZoteroPane) {
let cleared1 = await window.ZoteroPane.clearQuicksearch();
@ -1253,13 +1260,6 @@ var ItemTree = class ItemTree extends LibraryTree {
Zotero.debug(`Couldn't find row for item ${id} -- not selecting`);
continue;
}
// If parent is already open and we haven't found the item, the child
// hasn't yet been added to the view, so close parent to allow refresh
await this._closeContainer(parentRow);
// Open the parent
await this.toggleOpenState(parentRow);
}
// Since we're opening containers, we still need to reference by id
@ -1276,7 +1276,6 @@ var ItemTree = class ItemTree extends LibraryTree {
}
rowsToSelect.push(row);
}
if (!rowsToSelect.length) {
return 0;
}
@ -1713,10 +1712,14 @@ var ItemTree = class ItemTree extends LibraryTree {
//Get children
var includeTrashed = this.collectionTreeRow.isTrash();
var attachments = item.getAttachments(includeTrashed);
var notes = item.getNotes(includeTrashed);
var attachments = item.isRegularItem() ? item.getAttachments(includeTrashed) : [];
var notes = item.isRegularItem() ? item.getNotes(includeTrashed) : [];
var annotations = [];
var newRows;
if (item.isFileAttachment()) {
annotations = item.getAnnotations();
}
var newRows = [];
if (attachments.length && notes.length) {
newRows = notes.concat(attachments);
}
@ -1726,10 +1729,14 @@ var ItemTree = class ItemTree extends LibraryTree {
else if (notes.length) {
newRows = notes;
}
if (annotations.length) {
newRows = newRows.concat(annotations);
}
if (newRows) {
newRows = Zotero.Items.get(newRows);
if (!item.isFileAttachment()) {
newRows = Zotero.Items.get(newRows);
}
for (let i = 0; i < newRows.length; i++) {
count++;
this._addRow(
@ -1766,8 +1773,15 @@ var ItemTree = class ItemTree extends LibraryTree {
var savedSelection = this.getSelectedObjects();
for (var i=0; i<this.rowCount; i++) {
var id = this.getRow(i).ref.id;
if (searchParentIDs.has(id) && this.isContainer(i) && !this.isContainerOpen(i)) {
if (!this.isContainer(i) || this.isContainerOpen(i)) {
continue;
}
let item = this.getRow(i).ref;
let attachments = item.isRegularItem() ? item.getAttachments() : [];
// expand item row if it is a parent of a match
// OR if it has a child that is a parent of a match
let shouldBeOpened = searchParentIDs.has(item.id) || attachments.some(id => searchParentIDs.has(id));
if (shouldBeOpened) {
this.toggleOpenState(i, true);
}
}
@ -1885,7 +1899,11 @@ var ItemTree = class ItemTree extends LibraryTree {
let collectionTreeRow = this.collectionTreeRow;
if (collectionTreeRow.isBucket()) {
// If all selected items are annotations, for now erase them skipping trash
if (selectedItems.length && selectedItems.every(item => item.isAnnotation())) {
await Zotero.Items.erase(selectedItemIDs);
}
else if (collectionTreeRow.isBucket()) {
collectionTreeRow.ref.deleteItems(ids);
}
else if (collectionTreeRow.isTrash()) {
@ -2061,8 +2079,9 @@ var ItemTree = class ItemTree extends LibraryTree {
};
isContainer = (index) => {
return this.getRow(index).ref.isRegularItem();
};
let item = this.getRow(index).ref;
return item.isRegularItem() || item.isFileAttachment();
}
isContainerOpen = (index) => {
return this.getRow(index).isOpen;
@ -2074,6 +2093,9 @@ var ItemTree = class ItemTree extends LibraryTree {
}
var item = this.getRow(index).ref;
if (item.isFileAttachment()) {
return item.numAnnotations() == 0;
}
if (!item.isRegularItem()) {
return true;
}
@ -2081,6 +2103,38 @@ var ItemTree = class ItemTree extends LibraryTree {
return item.numNotes(includeTrashed) === 0 && item.numAttachments(includeTrashed) == 0;
};
// Expand all ancestors of the specified item id
expandToItem = async (id) => {
let item = Zotero.Items.get(id);
// Stop if the row already exists of if the item is not found
if (this._rowMap[id] || !item) return;
let toExpand = [];
// Collect all ancestors of the item
while (item.parentItemID) {
item = Zotero.Items.get(item.parentItemID);
toExpand.push(item.id);
}
if (!this.getRow(this._rowMap[item.id])) return;
// Go through ancestors starting from the top-most one
// and expand them if needed
while (toExpand.length > 0) {
let ancestorID = toExpand.pop();
let ancestorRow = this._rowMap[ancestorID];
// If the row for the next ancestor already exists, just move one
if (toExpand.length > 0) {
let nextAncestorID = toExpand[toExpand.length - 1];
let nextAncestorRow = this._rowMap[nextAncestorID];
if (this.getRow(nextAncestorRow)) continue;
}
// Close and re-open the ancestor to reveal the next row until
// we reach the desired item
await this._closeContainer(ancestorRow);
await this.toggleOpenState(ancestorRow);
}
};
////////////////////////////////////////////////////////////////////////////////
///
/// Drag-and-drop methods
@ -2269,6 +2323,11 @@ var ItemTree = class ItemTree extends LibraryTree {
return false;
}
// Disallow drag of annotation items
if (item.isAnnotation()) {
return false;
}
// Disallow cross-library child drag
if (item.libraryID != collectionTreeRow.ref.libraryID) {
return false;
@ -2291,6 +2350,11 @@ var ItemTree = class ItemTree extends LibraryTree {
return false;
}
// Disallow drag of annotation items
if (item.isAnnotation()) {
return false;
}
// Don't allow web attachments to be dragged out of parents,
// except for files that can be recognized
if (item.isWebAttachment()
@ -3142,8 +3206,12 @@ var ItemTree = class ItemTree extends LibraryTree {
: acc;
}, { lowestOrdinal: Infinity, firstColumn: null });
let item = this.getRow(index).ref;
for (let column of columns) {
if (column.hidden) continue;
// Annotation rows have a single cell, created below
if (item.isAnnotation()) continue;
div.appendChild(this._renderCell(index, rowData[column.dataKey], column, column === firstColumn));
}
@ -3188,6 +3256,46 @@ var ItemTree = class ItemTree extends LibraryTree {
}
}
// Render annotation rows as a single cell with title/text and comment
div.classList.toggle("annotation-row", item.isAnnotation());
div.classList.remove("tight");
if (item.isAnnotation()) {
// Use "title" column as a blueprint to render the first part of annotation row
let titleRowData = Object.assign({}, columns.find(column => column.dataKey == "title"));
titleRowData.className = "title";
let title;
// Strip html tags from annotation comment and text until the algorithm
// for safe rendering of relevant html tags is carried over from the reader
let parserUtils = Cc["@mozilla.org/parserutils;1"].getService(Ci.nsIParserUtils);
let plainText = parserUtils.convertToPlainText(item.annotationText || "", Ci.nsIDocumentEncoder.OutputRaw, 0);
let plainComment = parserUtils.convertToPlainText(item.annotationComment || "", Ci.nsIDocumentEncoder.OutputRaw, 0);
if (["highlight", "underline"].includes(item.annotationType)) {
title = this._renderCell(index, plainText, titleRowData, true);
let titleCell = title.querySelector(".cell-text");
// Quote text is in italics
titleCell.classList.add("italics");
// Add quotation marks around the quoted text
titleCell.setAttribute("q-mark-open", Zotero.getString("punctuation.openingQMark"));
title.setAttribute("q-mark-close", Zotero.getString("punctuation.closingQMark"));
if (item.annotationComment) {
let comment = renderCell(null, plainComment, { className: "annotation-comment" });
div.appendChild(comment);
}
// only keep default wider spacing if there are CJK characters
let containsCJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(item.annotationText);
div.classList.toggle("tight", !containsCJK);
}
else if (item.annotationComment) {
// If there is a comment for image, ink, note annotations, use that as the title
title = this._renderCell(index, plainComment, titleRowData, true);
}
else {
// Catch all - use annotation type as the title
let annotationTypeName = Zotero.getString(`pdfReader.${item.annotationType}Annotation`);
title = this._renderCell(index, annotationTypeName, titleRowData, true);
}
div.prepend(title);
}
return div;
};
@ -3682,6 +3790,7 @@ var ItemTree = class ItemTree extends LibraryTree {
_saveOpenState(close) {
if (!this.tree) return [];
var itemIDs = [];
var toClose = [];
if (close) {
if (!this.selection.selectEventsSuppressed) {
var unsuppress = this.selection.selectEventsSuppressed = true;
@ -3689,13 +3798,18 @@ var ItemTree = class ItemTree extends LibraryTree {
}
for (var i=0; i<this._rows.length; i++) {
if (this.isContainer(i) && this.isContainerOpen(i)) {
itemIDs.push(this.getRow(i).ref.id);
if (close) {
this._closeContainer(i, true);
let row = this.getRow(i);
itemIDs.push(row.ref.id);
if (close && row.level == 0) {
toClose.push(this.getRow(i).ref.id);
}
}
}
if (close) {
for (i = toClose.length - 1; i >= 0; i--) {
let row = this._rowMap[toClose[i]];
this._closeContainer(row, true);
}
this._refreshRowMap();
if (unsuppress) {
this.selection.selectEventsSuppressed = false;
@ -3704,13 +3818,17 @@ var ItemTree = class ItemTree extends LibraryTree {
return itemIDs;
}
_rememberOpenState(itemIDs) {
_rememberOpenState(itemIDs, secondLevel = false) {
if (!this.tree) return;
var rowsToOpen = [];
var nextLevelToOpen = [];
for (let id of itemIDs) {
var row = this._rowMap[id];
// Item may not still exist
if (row == undefined) {
if (!secondLevel) {
nextLevelToOpen.push(id);
}
continue;
}
rowsToOpen.push(row);
@ -3727,6 +3845,10 @@ var ItemTree = class ItemTree extends LibraryTree {
this.toggleOpenState(rowsToOpen[i], true);
}
this._refreshRowMap();
if (nextLevelToOpen.length) {
this._rememberOpenState(nextLevelToOpen, true);
}
if (unsuppress) {
this.selection.selectEventsSuppressed = false;
}
@ -3940,6 +4062,17 @@ var ItemTree = class ItemTree extends LibraryTree {
return icon;
}
if (item.isAnnotation()) {
let img = document.createElement("img");
img.className = "annotation-icon";
let type = item.annotationType;
if (type == 'image') {
type = 'area';
}
img.src = 'chrome://zotero/skin/16/universal/annotate-' + type + '.svg';
img.style.fill = item.annotationColor;
return img;
}
var itemType = item.getItemTypeIconName();
return getCSSItemTypeIcon(itemType);
}

View file

@ -349,7 +349,7 @@ var Zotero_LocateMenu = new function() {
var selectedItems = [];
while (selectedItems.length < 50 && allSelectedItems.length) {
var item = allSelectedItems.shift();
if (!item.isNote()) selectedItems.push(item);
if (!item.isNote() && !item.isAnnotation()) selectedItems.push(item);
}
return selectedItems;
}

View file

@ -322,7 +322,8 @@ const ZoteroStandalone = new function() {
var format = Zotero.QuickCopy.getFormatFromURL(Zotero.QuickCopy.lastActiveURL);
var exportingNotes = selected.every(item => item.isNote() || item.isAttachment());
if (exportingNotes) {
var exportingAnnotations = selected.every(item => item.isAnnotation());
if (exportingNotes || exportingAnnotations) {
format = Zotero.QuickCopy.getNoteFormat();
}
format = Zotero.QuickCopy.unserializeSetting(format);
@ -331,11 +332,15 @@ const ZoteroStandalone = new function() {
var copyBibliography = document.getElementById('menu_copyBibliography');
var copyExport = document.getElementById('menu_copyExport');
var copyNote = document.getElementById('menu_copyNote');
var copyAnnotation = document.getElementById('menu_copyAnnotation');
copyCitation.hidden = !selected.length || format.mode != 'bibliography';
copyBibliography.hidden = !selected.length || format.mode != 'bibliography';
copyExport.hidden = !selected.length || format.mode != 'export' || exportingNotes;
copyNote.hidden = !selected.length || format.mode != 'export' || !exportingNotes;
copyAnnotation.hidden = !selected.length || format.mode != 'export' || !exportingAnnotations;
document.l10n.setAttributes(copyAnnotation, "menu-edit-copy-annotation", { count: selected.length });
if (format.mode == 'export') {
try {
let obj = Zotero.Translators.get(format.id);

View file

@ -116,7 +116,7 @@ Zotero.Annotations = new function () {
};
this.toJSON = async function (item) {
this.toJSONSync = function (item) {
var o = {};
o.libraryID = item.libraryID;
o.key = item.key;
@ -142,12 +142,6 @@ Zotero.Annotations = new function () {
if (['highlight', 'underline'].includes(o.type)) {
o.text = item.annotationText;
}
else if (['image', 'ink'].includes(o.type)) {
let file = this.getCacheImagePath(item);
if (await OS.File.exists(file)) {
o.image = await Zotero.File.generateDataURI(file, 'image/png');
}
}
o.comment = item.annotationComment;
o.pageLabel = item.annotationPageLabel;
o.color = item.annotationColor;
@ -184,6 +178,17 @@ Zotero.Annotations = new function () {
o.dateModified = Zotero.Date.sqlToISO8601(item.dateModified);
return o;
};
this.toJSON = async function (item) {
var o = this.toJSONSync(item);
if (['image', 'ink'].includes(o.type)) {
let file = this.getCacheImagePath(item);
if (await OS.File.exists(file)) {
o.image = await Zotero.File.generateDataURI(file, 'image/png');
}
}
return o;
};
/**

View file

@ -254,11 +254,14 @@ Zotero.Items = function() {
item.updateDisplayTitle()
}
catch (e) {
// A few item types need creators to be loaded. Instead of making
// updateDisplayTitle() async and loading conditionally, just catch the error
// A few item types need creators or tags to be loaded. Annotations need to be loaded
// to be displayed in itemTree.
// Instead of making updateDisplayTitle() async and loading conditionally, just catch the error
// and load on demand
if (e instanceof Zotero.Exception.UnloadedDataException) {
yield item.loadDataType('creators');
yield item.loadDataType('annotation');
yield item.loadDataType('tags');
item.updateDisplayTitle()
}
else {

View file

@ -607,7 +607,10 @@ Zotero.Search.prototype.search = Zotero.Promise.coroutine(function* (asTempTable
sql += " OR itemID IN (SELECT itemID FROM itemAttachments"
+ " WHERE parentItemID IN (SELECT itemID FROM " + tmpTable + ")) OR "
+ "itemID IN (SELECT itemID FROM itemNotes"
+ " WHERE parentItemID IN (SELECT itemID FROM " + tmpTable + "))";
+ " WHERE parentItemID IN (SELECT itemID FROM " + tmpTable + "))"
+ " OR itemID IN ( SELECT itemID FROM itemAnnotations WHERE "
+ " parentItemID IN ( SELECT itemID FROM itemAttachments WHERE "
+ " parentItemID IN ( SELECT itemID FROM " + tmpTable + ")))";
}
sql += ")";
@ -948,10 +951,7 @@ Zotero.Search.idsToTempTable = Zotero.Promise.coroutine(function* (ids) {
Zotero.Search.prototype._buildQuery = Zotero.Promise.coroutine(function* () {
this._requireData('conditions');
// TEMP: Match parent attachment for annotation matches
// var sql = 'SELECT itemID FROM items';
var sql = "SELECT COALESCE(IA.parentItemID, itemID) AS itemID FROM items "
+ "LEFT JOIN itemAnnotations IA USING (itemID)";
var sql = 'SELECT itemID FROM items';
var sqlParams = [];
// Separate ANY conditions for 'required' condition support
@ -1216,16 +1216,14 @@ Zotero.Search.prototype._buildQuery = Zotero.Promise.coroutine(function* () {
}
switch (condition.name) {
// TEMP: Match parent attachments of matching annotations
case 'tag':
condSQL += "SELECT COALESCE(IAnT.parentItemID, itemID) FROM itemTags "
condSQL += "SELECT itemID FROM itemTags "
+ "LEFT JOIN itemAnnotations IAnT USING (itemID) WHERE (";
break;
// TEMP: Match parent attachments of matching annotations
case 'annotationText':
case 'annotationComment':
condSQL += `SELECT parentItemID FROM ${condition.table} WHERE (`
condSQL += `SELECT itemID FROM ${condition.table} WHERE (`
break;
default:
@ -1807,11 +1805,7 @@ Zotero.Search.prototype._buildQuery = Zotero.Promise.coroutine(function* () {
// Add on quicksearch conditions
if (quicksearchSQLSet) {
// TEMP: Match parent attachments for annotations
//sql = "SELECT itemID FROM items WHERE itemID IN (" + sql + ") "
sql = "SELECT COALESCE(IAn.parentItemID, itemID) AS itemID FROM items "
+ "LEFT JOIN itemAnnotations IAn USING (itemID) "
+ "WHERE itemID IN (" + sql + ") "
sql = "SELECT itemID FROM items WHERE itemID IN (" + sql + ") "
+ "AND ((" + quicksearchSQLSet.join(') AND (') + "))";
for (var k=0; k<quicksearchParamsSet.length; k++) {

View file

@ -572,7 +572,7 @@ Zotero.SearchConditions = new function(){
},
table: 'itemAnnotations',
field: 'text',
special: true,
special: false,
},
{
@ -583,7 +583,7 @@ Zotero.SearchConditions = new function(){
},
table: 'itemAnnotations',
field: 'comment',
special: true,
special: false,
},
{

View file

@ -308,7 +308,43 @@ Zotero.QuickCopy = new function() {
throw ("Invalid mode '" + format.mode + "' in Zotero.QuickCopy.getContentFromItems()");
};
/**
* Generate a note item to pass to getContentFromItems() from an array of annotations
*
* @param {Zotero.Item[]|Object[]} annotations - An array of Zotero.Item annotations or JSON
* annotations from Zotero.Annotations.toJSON()
* @return {Zotero.Item} - A note item with the annotations serialized as HTML
*/
this.annotationsToNote = function (annotations) {
let jsonAnnotations = [];
for (let annotation of annotations) {
if (annotation instanceof Zotero.Item) {
// Skip ink and image annotations because fetching them
// requires awaiting Zotero.Annotations.toJSON()
if (["ink", "image"].includes(annotation.type)) {
continue;
}
let json = Zotero.Annotations.toJSONSync(annotation);
json.attachmentItemID = annotation.parentItemID;
jsonAnnotations.push(json);
}
else {
jsonAnnotations.push(annotation);
}
}
for (let annotation of jsonAnnotations) {
if (annotation.image && !annotation.imageAttachmentKey) {
annotation.imageAttachmentKey = 'none';
delete annotation.image;
}
}
let { html } = Zotero.EditorInstanceUtilities.serializeAnnotations(jsonAnnotations);
let tmpNote = new Zotero.Item('note');
tmpNote.libraryID = Zotero.Libraries.userLibraryID;
tmpNote.setNote(html);
return tmpNote;
};
/**
* If an export translator is the selected output format, load its code (which must be done

View file

@ -367,17 +367,8 @@ class ReaderInstance {
if (fromText) {
return;
}
for (let annotation of annotations) {
if (annotation.image && !annotation.imageAttachmentKey) {
annotation.imageAttachmentKey = 'none';
delete annotation.image;
}
}
let res = Zotero.EditorInstanceUtilities.serializeAnnotations(annotations);
let tmpNote = new Zotero.Item('note');
tmpNote.libraryID = Zotero.Libraries.userLibraryID;
tmpNote.setNote(res.html);
let items = [tmpNote];
// annotations are wrapped in a temp note for translation
let items = [Zotero.QuickCopy.annotationsToNote(annotations)];
let format = Zotero.QuickCopy.getNoteFormat();
Zotero.debug(`Copying/dragging (${annotations.length}) annotation(s) with ${format}`);
format = Zotero.QuickCopy.unserializeSetting(format);

View file

@ -3359,6 +3359,12 @@ Zotero.Utilities.Internal.onDragItems = function (event, itemIDs, dragImage = ev
format = Zotero.QuickCopy.getNoteFormat();
}
// If all items are annotations, wrap them in a note object for translation
if (items.every(item => item.isAnnotation())) {
format = Zotero.QuickCopy.getNoteFormat();
items = [Zotero.QuickCopy.annotationsToNote(items)];
}
Zotero.debug("Dragging with format " + format);
format = Zotero.QuickCopy.unserializeSetting(format);
try {

View file

@ -2009,7 +2009,7 @@ var ZoteroPane = new function()
let format = Zotero.QuickCopy.getFormatFromURL(Zotero.QuickCopy.lastActiveURL);
format = Zotero.QuickCopy.unserializeSetting(format);
if (format.mode == 'bibliography') {
canCopy = selectedItems.some(item => item.isRegularItem());
canCopy = selectedItems.some(item => item.isRegularItem() || item.isAnnotation());
}
else {
canCopy = true;
@ -2018,6 +2018,7 @@ var ZoteroPane = new function()
document.getElementById('cmd_zotero_copyCitation').setAttribute('disabled', !canCopy);
document.getElementById('cmd_zotero_copyBibliography').setAttribute('disabled', !canCopy);
document.getElementById('cmd_zotero_copyAnnotation').setAttribute('disabled', !canCopy);
};
@ -2177,6 +2178,13 @@ var ZoteroPane = new function()
else if (collectionTreeRow.isShare()) {
return false;
}
// If multiple items are selected, some are annotations and some are not, do nothing,
// since annotations have different treatment from other items
let selected = this.itemsView.getSelectedItems();
if (!selected.every(item => item.isAnnotation())
&& selected.some(item => item.isAnnotation())) {
return false;
}
return true;
};
@ -2226,15 +2234,19 @@ var ZoteroPane = new function()
if (!this.canDeleteSelectedItems()) {
return;
}
if (collectionTreeRow.isPublications()) {
var prompt;
// Backspace on annotation items = prompt to erase
if (this.itemsView.getSelectedItems().every(item => item.isAnnotation())) {
prompt = toDelete;
}
else if (collectionTreeRow.isPublications()) {
let toRemoveFromPublications = {
title: Zotero.getString('pane.items.removeFromPublications.title'),
text: Zotero.getString(
'pane.items.removeFromPublications' + (this.itemsView.selection.count > 1 ? '.multiple' : '')
)
};
var prompt = force ? toTrash : toRemoveFromPublications;
prompt = force ? toTrash : toRemoveFromPublications;
}
else if (collectionTreeRow.isLibrary(true)
|| collectionTreeRow.isSearch()
@ -2242,11 +2254,11 @@ var ZoteroPane = new function()
|| collectionTreeRow.isRetracted()
|| collectionTreeRow.isDuplicates()) {
// In library, don't prompt if meta key was pressed
var prompt = (force && !fromMenu) ? false : toTrash;
prompt = (force && !fromMenu) ? false : toTrash;
}
else if (collectionTreeRow.isCollection()) {
if (force) {
var prompt = toTrash;
prompt = toTrash;
}
else {
// Ignore unmodified action if only child items are selected
@ -2275,12 +2287,12 @@ var ZoteroPane = new function()
}
}
else {
var prompt = toRemove;
prompt = toRemove;
}
}
}
else if (collectionTreeRow.isTrash() || collectionTreeRow.isBucket()) {
var prompt = toDelete;
prompt = toDelete;
}
if (!prompt || Services.prompt.confirm(window, prompt.title, prompt.text)) {
@ -2662,6 +2674,11 @@ var ZoteroPane = new function()
if (items.every(item => item.isNote() || item.isAttachment())) {
format = Zotero.QuickCopy.getNoteFormat();
}
// To copy annotations, wrap them in a temp note
if (items.every(item => item.isAnnotation())) {
format = Zotero.QuickCopy.getNoteFormat();
items = [Zotero.QuickCopy.annotationsToNote(items)];
}
format = Zotero.QuickCopy.unserializeSetting(format);
// In bibliography mode, remove notes and attachments
@ -3699,7 +3716,8 @@ var ZoteroPane = new function()
&& !collectionTreeRow.isDuplicates()
&& !collectionTreeRow.isFeedsOrFeed()) {
if (items.some(item => attachmentsWithExtractableAnnotations(item).length)
|| items.some(item => isAttachmentWithExtractableAnnotations(item))) {
|| items.some(item => isAttachmentWithExtractableAnnotations(item))
|| items.some(item => item.isAnnotation())) {
let menuitem = menu.childNodes[m.createNoteFromAnnotations];
show.add(m.createNoteFromAnnotations);
let key;
@ -3789,7 +3807,7 @@ var ZoteroPane = new function()
}
}
// Show "(Create|Add) Note from Annotations" on attachment with extractable annotations
else if (isAttachmentWithExtractableAnnotations(item)) {
else if (isAttachmentWithExtractableAnnotations(item) || item.isAnnotation()) {
show.add(m.createNoteFromAnnotations);
show.add(m.sep2);
}
@ -3852,6 +3870,9 @@ var ZoteroPane = new function()
show.add(m.sep5);
}
}
else if (item.isAnnotation()) {
// Some annotation specific menus?
}
else if (item.isFeedItem) {
show.add(m.toggleRead);
if (item.isRead) {
@ -4005,6 +4026,17 @@ var ZoteroPane = new function()
show.add(m.changeParentItem);
}
// Only keep annotation-specific options if annotations are selected
let annotationsSelected = items.some(item => item.isAnnotation());
if (annotationsSelected) {
for (let i in m) {
if (i == 'createNoteFromAnnotations') {
continue;
}
show.delete(m[i]);
}
}
// Set labels, plural if necessary
menu.childNodes[m.findFile].setAttribute('label', Zotero.getString('pane.items.menu.findAvailableFile'));
menu.childNodes[m.moveToTrash].setAttribute('label', Zotero.getString('pane.items.menu.moveToTrash' + multiple));
@ -4031,7 +4063,10 @@ var ZoteroPane = new function()
for (let x of show) {
menu.childNodes[x].setAttribute('hidden', false);
}
// No locate menu options if annotations are selected
if (annotationsSelected) return;
// add locate menu options
yield Zotero_LocateMenu.buildContextMenu(menu, true);
});
@ -4955,6 +4990,9 @@ var ZoteroPane = new function()
else if (item.isAttachment()) {
yield this.viewAttachment(item.id, event);
}
else if (item.isAnnotation()) {
this.viewPDF(item.parentItemID, { annotationID: item.key });
}
}
});
@ -5741,6 +5779,7 @@ var ZoteroPane = new function()
}
var attachments = [];
var annotations = [];
for (let item of items) {
if (item.isRegularItem()) {
// Find all child items with extractable annotations
@ -5752,17 +5791,19 @@ var ZoteroPane = new function()
else if (isAttachmentWithExtractableAnnotations(item)) {
attachments.push(item);
}
else if (item.isAnnotation() && item.annotationType != 'ink') {
annotations.push(item);
}
else {
continue;
}
}
if (!attachments.length) {
if (!attachments.length && !annotations.length) {
Zotero.debug("No attachments found", 2);
return;
}
var annotations = [];
for (let attachment of attachments) {
if (attachment.isPDFAttachment()) {
try {
@ -5858,6 +5899,9 @@ var ZoteroPane = new function()
else if (isAttachmentWithExtractableAnnotations(item)) {
attachments.push(item);
}
else if (item.isAnnotation()) {
annotations.push(item);
}
else {
continue;
}
@ -5870,7 +5914,7 @@ var ZoteroPane = new function()
Zotero.logError(e);
}
}
annotations.push(...attachment.getAnnotations().filter(x => x.annotationType != 'ink'));
annotations.push(...attachment.getAnnotations().filter(x => x.annotationType != 'ink' && x.annotationType != 'image'));
}
}

View file

@ -110,6 +110,9 @@
<command id="cmd_zotero_copyBibliography"
oncommand="ZoteroPane_Local.copySelectedItemsToClipboard();"
disabled="true"/>
<command id="cmd_zotero_copyAnnotation"
oncommand="ZoteroPane_Local.copySelectedItemsToClipboard();"
disabled="true"/>
<command id="cmd_zotero_createTimeline" oncommand="Zotero_Timeline_Interface.loadTimeline();"/>
<command id="cmd_zotero_rtfScan" oncommand="window.openDialog('chrome://zotero/content/rtfScan.xhtml', 'rtfScan', 'chrome,centerscreen')"/>
<command id="cmd_zotero_newCollection" oncommand="ZoteroPane_Local.newCollection(ZoteroPane_Local.getSelectedCollection()?.key)"/>
@ -388,6 +391,11 @@
command="cmd_zotero_copyBibliography"
key="key_copyBibliography"
hidden="true"/>
<menuitem id="menu_copyAnnotation"
data-l10n-id="menu-edit-copy-annotation"
command="cmd_zotero_copyAnnotation"
key="key_copyBibliography"
hidden="true"/>
<menuitem id="menu_paste"
key="key_paste"
command="cmd_paste" data-l10n-id="text-action-paste"/>

View file

@ -96,6 +96,12 @@ menu-view-columns-move-left =
menu-view-columns-move-right =
.label = Move Column Right
menu-edit-copy-annotation =
.label = { $count ->
[one] Copy Annotation
*[other] Copy { $count } Annotations
}
main-window-command =
.label = Library
main-window-key =
@ -592,6 +598,8 @@ toggle-preview =
*[unknown] Toggle
} Attachment Preview
annotation-image-not-available = [Image not available]
quicksearch-mode =
.aria-label = Quick Search mode
quicksearch-input =

View file

@ -102,6 +102,7 @@
@import "elements/librariesCollectionsBox";
@import "elements/duplicatesMergePane";
@import "elements/itemMessagePane";
@import "elements/itemPaneAnnotations";
@import "elements/itemDetails";
@import "elements/itemPane";
@import "elements/itemPaneCustomSection";

View file

@ -29,6 +29,9 @@ $font-size-base: 13px;
$font-size-h1: 20px;
$font-size-h2: 16px;
// Font size used in tag selector and annotation rows of itemTree
$font-size-small: 0.923076923em;
$line-height-base: 1.539;
$line-height-computed: ceil($font-size-base * $line-height-base);

View file

@ -113,7 +113,9 @@
}
.icon-item-type + .tag-swatch,
.icon-item-type + .colored-tag-swatches {
.icon-item-type + .colored-tag-swatches,
.annotation-icon + .tag-swatch,
.annotation-icon + .colored-tag-swatches {
margin-inline-start: 4px;
}
@ -218,6 +220,40 @@
opacity: 0.4;
}
}
.annotation-row {
.cell {
font-size: $font-size-small;
max-width: fit-content;
// Do not italicize CJK characters
font-synthesis: none;
&.title {
flex-grow: 1;
flex-basis: 0;
max-width: fit-content;
.cell-text::before {
content: attr(q-mark-open)
}
&::after {
content: attr(q-mark-close)
}
.italics {
font-style: italic;
}
}
&.annotation-comment {
flex-grow: 2;
flex-basis: 0;
}
}
&.tight .cell {
padding: 0 2px;
}
}
.cell .annotation-icon {
-moz-context-properties: fill;
}
.numNotes, .hasAttachment {
text-align: center;

View file

@ -103,7 +103,7 @@
.tag-selector-item {
border-radius: 4px;
cursor: pointer;
font-size: 0.916666667em;
font-size: $font-size-small;
line-height: 1.272727273;
overflow: hidden;
padding: 1px 4px;

View file

@ -5,6 +5,7 @@ annotation-row {
border-radius: 5px;
border: 1px solid var(--color-quinary-on-sidepane);
background: var(--material-background);
@include focus-ring;
.head {
display: flex;
@ -56,7 +57,7 @@ annotation-row {
padding: 3px 8px;
@include comfortable {
padding-block: 4px;
margin-block: 4px;
}
}
}

View file

@ -11,6 +11,7 @@ item-message-pane {
background: var(--material-toolbar);
border-bottom: var(--material-panedivider);
height: 28px;
align-items: center;
&:empty {
display: none;
@ -19,6 +20,9 @@ item-message-pane {
button {
height: 26px;
margin: 0;
@media (-moz-platform: macos) {
margin: 0px -2px;
}
flex-grow: 1;
}
}

View file

@ -0,0 +1,69 @@
annotation-items-pane {
display: flex;
flex-direction: column;
.custom-head {
display: flex;
flex-direction: row;
align-self: stretch;
gap: 8px;
padding: 6px 8px;
background: var(--material-sidepane);
border-bottom: var(--material-panedivider);
height: 28px;
align-items: center;
&:empty {
display: none;
}
button {
height: 26px;
margin: 0;
@media (-moz-platform: macos) {
margin: 0px -2px;
}
flex-grow: 1;
}
}
// Make sure the summary containing name of top-level item is always visible
// and titles are cut off by letter and not by word
collapsible-section > .head .title-box .summary {
opacity: 1 !important;
width: 0;
white-space: nowrap;
display: inline;
}
// Annotation icon for collapsible section
collapsible-section > .head .title::before {
content: '';
width: 16px;
height: 16px;
background: icon-url("itempane/16/attachment-annotations.svg") no-repeat center;
-moz-context-properties: fill, fill-opacity, stroke, stroke-opacity;
fill: var(--tag-purple);
stroke: var(--tag-purple);
}
collapsible-section > .body {
display: flex;
flex-direction: column;
gap: 4px;
@include comfortable {
gap: 8px;
}
}
collapsible-section:not(:last-child) {
border-bottom: 1px solid var(--fill-quinary);
}
// Do not cut off annotation text and comment
annotation-row .body .comment,
annotation-row .body .quote {
-webkit-line-clamp: inherit !important;
}
}

View file

@ -2083,4 +2083,45 @@ describe("Item pane", function () {
Zotero.ItemPaneManager.unregisterSection(registeredID);
});
});
describe("AnnotationItemsPane", function () {
it("should display selected annotations groupped by parent item", async () => {
let toplevelItemOne = await createDataObject('item', { title: "Item one" });
let attachmentOne = await importFileAttachment('test.pdf', { title: 'PDF', parentItemID: toplevelItemOne.id });
let highlightOne = await createAnnotation('highlight', attachmentOne);
let toplevelItemTwo = await createDataObject('item', { title: "Item two" });
let attachmentTwo = await importFileAttachment('test.pdf', { title: 'PDF', parentItemID: toplevelItemTwo.id });
let highlightTwo = await createAnnotation('highlight', attachmentTwo);
ZoteroPane.itemsView.expandAllRows();
await ZoteroPane.itemsView.selectItems([highlightOne.id, highlightTwo.id]);
let sections = [...win.document.querySelectorAll("annotation-items-pane collapsible-section")];
// Top level items' titles are in section summaries
assert.equal(sections[0].summary, toplevelItemOne.getDisplayTitle());
assert.equal(sections[1].summary, toplevelItemTwo.getDisplayTitle());
// Each item's section contains its annotation
assert.equal(sections[0].querySelector("annotation-row").annotation.id, highlightOne.id);
assert.equal(sections[1].querySelector("annotation-row").annotation.id, highlightTwo.id);
});
it("should refresh when annotation is updated", async () => {
let toplevelItemOne = await createDataObject('item', { title: "Item one" });
let attachmentOne = await importFileAttachment('test.pdf', { title: 'PDF', parentItemID: toplevelItemOne.id });
let highlightOne = await createAnnotation('highlight', attachmentOne);
highlightOne.annotationText = "Annotation";
await highlightOne.saveTx();
ZoteroPane.itemsView.expandAllRows();
await ZoteroPane.itemsView.selectItems([highlightOne.id]);
assert.equal(win.document.querySelector("annotation-items-pane annotation-row .quote").textContent, "Annotation");
highlightOne.annotationText = "Updated";
await highlightOne.saveTx();
assert.equal(win.document.querySelector("annotation-items-pane annotation-row .quote").textContent, "Updated");
});
});
});

View file

@ -225,23 +225,28 @@ describe("Zotero.ItemTree", function() {
var note1 = await createDataObject('item', { itemType: 'note', parentID: item1.id });
var note2 = await createDataObject('item', { itemType: 'note', parentID: item2.id });
var note3 = await createDataObject('item', { itemType: 'note', parentID: item3.id });
// one of the items has an attachment with annotations
var attachment = await importFileAttachment('test.pdf', { title: 'PDF', parentItemID: item1.id });
var highlight = await createAnnotation('highlight', attachment);
var underline = await createAnnotation('underline', attachment);
var toSelect = [note1.id, note2.id, note3.id];
var toSelect = [note1.id, note2.id, note3.id, highlight.id, underline.id];
itemsView.collapseAllRows();
var numSelected = await itemsView.selectItems(toSelect);
assert.equal(numSelected, 3);
assert.equal(numSelected, 5);
var selected = itemsView.getSelectedItems(true);
assert.lengthOf(selected, 3);
assert.lengthOf(selected, 5);
assert.sameMembers(selected, toSelect);
// Again with the ids given in reverse order
itemsView.collapseAllRows();
toSelect = toSelect.reverse();
var numSelected = await itemsView.selectItems(toSelect);
assert.equal(numSelected, 3);
var selected = itemsView.getSelectedItems(true);
assert.lengthOf(selected, 3);
numSelected = await itemsView.selectItems(toSelect);
assert.equal(numSelected, 5);
selected = itemsView.getSelectedItems(true);
assert.lengthOf(selected, 5);
assert.sameMembers(selected, toSelect);
});
});
@ -1587,4 +1592,133 @@ describe("Zotero.ItemTree", function() {
assert.equal(cellText.innerHTML, 'Review of <i xmlns="http://www.w3.org/1999/xhtml">Review of <span style="font-style: normal;">B<sub>oo</sub>k</span> &lt;another-tag/&gt;</i>');
});
});
describe("Annotations", function() {
let toplevelItem, attachment, highlight, underline, ink, image, note;
before(async () => {
var collection = await createDataObject('collection');
await select(win, collection);
});
beforeEach(async () => {
toplevelItem = await createDataObject('item', { title: "Item" });
attachment = await importFileAttachment('test.pdf', { title: 'PDF', parentItemID: toplevelItem.id });
highlight = await createAnnotation('highlight', attachment);
underline = await createAnnotation('underline', attachment);
ink = await createAnnotation('ink', attachment);
image = await createAnnotation('image', attachment);
note = await createAnnotation('image', attachment);
});
it("should display annotations as child rows of attachments", async () => {
zp.itemsView.expandAllRows();
var attachmentRowIndex = zp.itemsView.getRowIndexByID(attachment.id);
let offset = 0;
for (let annotation of attachment.getAnnotations()) {
let annotationRowIndex = zp.itemsView.getRowIndexByID(annotation.id);
offset += 1;
assert.equal(annotationRowIndex, attachmentRowIndex + offset);
}
});
it("should preserve order of annotation rows after sorting", async () => {
let itemAboveOne = await createDataObject('item', { title: "AAA" });
let itemAboveTwo = await createDataObject('item', { title: "BBB" });
let itemBelowOne = await createDataObject('item', { title: "ZZZ" });
// Initially, everything is sorted by title
var colIndex = itemsView.tree._getColumns().findIndex(column => column.dataKey == 'title');
await zp.itemsView.tree._columns.toggleSort(colIndex);
// Expand annotations
var itemRowIndex = zp.itemsView.getRowIndexByID(toplevelItem.id);
await zp.itemsView.toggleOpenState(itemRowIndex);
var attachmentRowIndex = zp.itemsView.getRowIndexByID(attachment.id);
await zp.itemsView.toggleOpenState(attachmentRowIndex);
// Record sequence of items
let rowIDs = zp.itemsView._rows.map(row => row.id);
// Sort by title in reverse
await zp.itemsView.tree._columns.toggleSort(colIndex);
attachmentRowIndex = zp.itemsView.getRowIndexByID(attachment.id);
// Make sure annotations appear after the attachment
let offset = 0;
for (let annotation of attachment.getAnnotations()) {
let annotationRowIndex = zp.itemsView.getRowIndexByID(annotation.id);
offset += 1;
assert.equal(annotationRowIndex, attachmentRowIndex + offset);
}
// Sort back and make sure the order of rows is the same as in the beginning
await zp.itemsView.tree._columns.toggleSort(colIndex);
assert.deepEqual(rowIDs, zp.itemsView._rows.map(row => row.id));
});
it("should erase annotation on escape when row is selected", async () => {
zp.itemsView.expandAllRows();
// Select and delete ink annotation
let inkID = ink.id;
await zp.itemsView.selectItems([inkID]);
await zp.itemsView.deleteSelection();
// Make sure it is deleted and the row is gone
assert.isFalse(Zotero.Items.get(inkID));
assert.isFalse(zp.itemsView.getRowIndexByID(inkID));
});
it("should add note from selected annotation rows of the same parent item", async () => {
zp.itemsView.expandAllRows();
// make sure underline has some text, just like highlight
underline.annotationText = "underline";
await underline.saveTx();
await zp.itemsView.selectItems([highlight.id, underline.id]);
// Click button in the header of annotations pane
win.document.querySelector("annotation-items-pane .custom-head button").click();
await waitForItemEvent('add');
await waitForItemEvent('modify');
// Make sure note is created as a child of top level item
let note = Zotero.Items.get(toplevelItem.getNotes()[0]);
assert.exists(note);
let text = note.getNote();
// Only two paragraphs, one for each annotation, should be added
assert.equal(text.split("<p>").length - 1, 2);
});
it("should create note from selected annotation rows of different parent items", async () => {
let toplevelItemTwo = await createDataObject('item', { title: "Another entry" });
let attachmentTwo = await importFileAttachment('test.pdf', { title: 'PDF two', parentItemID: toplevelItemTwo.id });
let highlightTwo = await createAnnotation('highlight', attachmentTwo);
zp.itemsView.expandAllRows();
await zp.itemsView.selectItems([highlight.id, highlightTwo.id]);
// Click button in the header of annotations pane
win.document.querySelector("annotation-items-pane .custom-head button").click();
await waitForItemEvent('add');
await waitForItemEvent('modify');
let note = zp.getSelectedItems()[0];
assert.isTrue(note.isNote());
assert.isFalse(note.parentID);
let text = note.getNote();
// Only two paragraphs, one for each annotation, should be added
assert.equal(text.split("<p>").length - 1, 2);
// Headers of both top level items are present
assert.include(text, toplevelItem.getDisplayTitle());
assert.include(text, toplevelItemTwo.getDisplayTitle());
});
});
})

View file

@ -248,7 +248,7 @@ describe("Zotero.Search", function() {
s.libraryID = userLibraryID;
s.addCondition('tag', 'is', tag);
var matches = await s.search();
assert.sameMembers(matches, [attachment.id]);
assert.sameMembers(matches, [annotation.id]);
});
// TEMP
@ -421,7 +421,7 @@ describe("Zotero.Search", function() {
s.addCondition('annotationText', 'contains', str);
var matches = await s.search();
// TEMP: Match parent attachment
assert.sameMembers(matches, [attachment.id]);
assert.sameMembers(matches, [annotation.id]);
});
});
@ -437,7 +437,7 @@ describe("Zotero.Search", function() {
s.addCondition('annotationComment', 'contains', str);
var matches = await s.search();
// TEMP: Match parent attachment
assert.sameMembers(matches, [attachment.id]);
assert.sameMembers(matches, [annotation.id]);
});
});
@ -659,7 +659,7 @@ describe("Zotero.Search", function() {
s.addCondition('quicksearch-fields', 'contains', tag);
var matches = await s.search();
// TEMP: Match parent attachment
assert.sameMembers(matches, [attachment.id]);
assert.sameMembers(matches, [annotation.id]);
});
})
@ -674,7 +674,7 @@ describe("Zotero.Search", function() {
s.addCondition('quicksearch-everything', 'contains', comment);
var matches = await s.search();
// TEMP: Match parent attachment
assert.sameMembers(matches, [attachment.id]);
assert.sameMembers(matches, [annotation.id]);
});
it("should not include items outside of scope during phrase search", async function () {