From 48fd23ccec73156855f4c59170b332af2e4357dd Mon Sep 17 00:00:00 2001 From: abaevbog Date: Mon, 28 Apr 2025 01:10:28 -0700 Subject: [PATCH] annotations showing in itemTree (#3416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- chrome/content/zotero/customElements.js | 1 + .../zotero/elements/annotationItemsPane.js | 134 ++++++++++++ .../content/zotero/elements/annotationRow.js | 21 +- .../zotero/elements/collapsibleSection.js | 4 +- chrome/content/zotero/elements/itemPane.js | 46 +++- chrome/content/zotero/itemTree.jsx | 207 ++++++++++++++---- chrome/content/zotero/locateMenu.js | 2 +- .../content/zotero/standalone/standalone.js | 7 +- chrome/content/zotero/xpcom/annotations.js | 19 +- chrome/content/zotero/xpcom/data/items.js | 7 +- chrome/content/zotero/xpcom/data/search.js | 22 +- .../zotero/xpcom/data/searchConditions.js | 4 +- chrome/content/zotero/xpcom/quickCopy.js | 38 +++- chrome/content/zotero/xpcom/reader.js | 13 +- .../zotero/xpcom/utilities_internal.js | 6 + chrome/content/zotero/zoteroPane.js | 72 ++++-- chrome/content/zotero/zoteroPane.xhtml | 8 + chrome/locale/en-US/zotero/zotero.ftl | 8 + scss/_zotero.scss | 1 + scss/abstracts/_variables.scss | 3 + scss/components/_item-tree.scss | 38 +++- scss/components/_tagSelector.scss | 2 +- scss/elements/_annotationRow.scss | 3 +- scss/elements/_itemMessagePane.scss | 4 + scss/elements/_itemPaneAnnotations.scss | 69 ++++++ test/tests/itemPaneTest.js | 41 ++++ test/tests/itemTreeTest.js | 148 ++++++++++++- test/tests/searchTest.js | 10 +- 28 files changed, 828 insertions(+), 110 deletions(-) create mode 100644 chrome/content/zotero/elements/annotationItemsPane.js create mode 100644 scss/elements/_itemPaneAnnotations.scss diff --git a/chrome/content/zotero/customElements.js b/chrome/content/zotero/customElements.js index 44c4c0445e..b755d8f672 100644 --- a/chrome/content/zotero/customElements.js +++ b/chrome/content/zotero/customElements.js @@ -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'], diff --git a/chrome/content/zotero/elements/annotationItemsPane.js b/chrome/content/zotero/elements/annotationItemsPane.js new file mode 100644 index 0000000000..aa06a643c0 --- /dev/null +++ b/chrome/content/zotero/elements/annotationItemsPane.js @@ -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 . + + ***** END LICENSE BLOCK ***** +*/ + +{ + class AnnotationItemsPane extends XULElementBase { + content = MozXULElement.parseXULToFragment(` + + + `); + + 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); +} diff --git a/chrome/content/zotero/elements/annotationRow.js b/chrome/content/zotero/elements/annotationRow.js index a5d8f23e49..68e2fddcab 100644 --- a/chrome/content/zotero/elements/annotationRow.js +++ b/chrome/content/zotero/elements/annotationRow.js @@ -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()); } } diff --git a/chrome/content/zotero/elements/collapsibleSection.js b/chrome/content/zotero/elements/collapsibleSection.js index 1dfa3c575f..b3fc51e5ca 100644 --- a/chrome/content/zotero/elements/collapsibleSection.js +++ b/chrome/content/zotero/elements/collapsibleSection.js @@ -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) => { diff --git a/chrome/content/zotero/elements/itemPane.js b/chrome/content/zotero/elements/itemPane.js index f95af15acf..2edfb0dc04 100644 --- a/chrome/content/zotero/elements/itemPane.js +++ b/chrome/content/zotero/elements/itemPane.js @@ -36,6 +36,8 @@ previousfocus="zotero-items-tree" /> + + `); @@ -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) { diff --git a/chrome/content/zotero/itemTree.jsx b/chrome/content/zotero/itemTree.jsx index 9f925c6e96..de8ae4d773 100644 --- a/chrome/content/zotero/itemTree.jsx +++ b/chrome/content/zotero/itemTree.jsx @@ -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 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= 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); } diff --git a/chrome/content/zotero/locateMenu.js b/chrome/content/zotero/locateMenu.js index 7e702ae5c2..b7169d1ceb 100644 --- a/chrome/content/zotero/locateMenu.js +++ b/chrome/content/zotero/locateMenu.js @@ -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; } diff --git a/chrome/content/zotero/standalone/standalone.js b/chrome/content/zotero/standalone/standalone.js index 8585db8f20..e3cb8240a0 100644 --- a/chrome/content/zotero/standalone/standalone.js +++ b/chrome/content/zotero/standalone/standalone.js @@ -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); diff --git a/chrome/content/zotero/xpcom/annotations.js b/chrome/content/zotero/xpcom/annotations.js index 92ec4bd0b6..330c8f74aa 100644 --- a/chrome/content/zotero/xpcom/annotations.js +++ b/chrome/content/zotero/xpcom/annotations.js @@ -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; + }; /** diff --git a/chrome/content/zotero/xpcom/data/items.js b/chrome/content/zotero/xpcom/data/items.js index 4ebb920dfd..81e083a43c 100644 --- a/chrome/content/zotero/xpcom/data/items.js +++ b/chrome/content/zotero/xpcom/data/items.js @@ -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 { diff --git a/chrome/content/zotero/xpcom/data/search.js b/chrome/content/zotero/xpcom/data/search.js index 55f25b247d..bda25b9158 100644 --- a/chrome/content/zotero/xpcom/data/search.js +++ b/chrome/content/zotero/xpcom/data/search.js @@ -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 item.isAnnotation())) { + format = Zotero.QuickCopy.getNoteFormat(); + items = [Zotero.QuickCopy.annotationsToNote(items)]; + } + Zotero.debug("Dragging with format " + format); format = Zotero.QuickCopy.unserializeSetting(format); try { diff --git a/chrome/content/zotero/zoteroPane.js b/chrome/content/zotero/zoteroPane.js index f00f00c459..01e3b9330a 100644 --- a/chrome/content/zotero/zoteroPane.js +++ b/chrome/content/zotero/zoteroPane.js @@ -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')); } } diff --git a/chrome/content/zotero/zoteroPane.xhtml b/chrome/content/zotero/zoteroPane.xhtml index 08103a5c49..595f90399f 100644 --- a/chrome/content/zotero/zoteroPane.xhtml +++ b/chrome/content/zotero/zoteroPane.xhtml @@ -110,6 +110,9 @@ + @@ -388,6 +391,11 @@ command="cmd_zotero_copyBibliography" key="key_copyBibliography" hidden="true"/> +