diff --git a/chrome/content/zotero/elements/attachmentAnnotationsBox.js b/chrome/content/zotero/elements/attachmentAnnotationsBox.js index fa942c5d5b..186752108a 100644 --- a/chrome/content/zotero/elements/attachmentAnnotationsBox.js +++ b/chrome/content/zotero/elements/attachmentAnnotationsBox.js @@ -46,53 +46,95 @@ } set item(item) { - super.item = item; + super.item = (item instanceof Zotero.Item && item.isFileAttachment()) ? item : null; this._updateHidden(); } init() { this.initCollapsibleSection(); + this._notifierID = Zotero.Notifier.registerObserver(this, ['item'], 'attachmentAnnotationsBox'); + this._body = this.querySelector('.body'); + + this._annotationItems = []; } - destroy() {} + destroy() { + Zotero.Notifier.unregisterObserver(this._notifierID); + } - notify(action, type, ids) { - if (action == 'modify' && this.item && ids.includes(this.item.id)) { - this._forceRenderAll(); + notify(event, _type, ids, _extraData) { + if (!this.item) return; + + this._annotationItems = this.item.getAnnotations(); + let annotations = this._annotationItems.filter( + annotation => ids.includes(annotation.id)); + + if (["add", "modify"].includes(event)) { + for (let annotation of annotations) { + let row = this.querySelector(`annotation-row[annotation-id="${annotation.id}"]`); + row?.remove(); + this.addRow(annotation); + } } + else if (event == 'delete') { + for (let id of ids) { + let row = this.querySelector(`annotation-row[annotation-id="${id}"]`); + row?.remove(); + } + } + this.updateCount(); } render() { + this._annotationItems = this.item.getAnnotations(); + this.updateCount(); + } + + async asyncRender() { if (!this.initialized || !this.item?.isFileAttachment()) return; if (this._isAlreadyRendered()) return; - let annotations = this.item.getAnnotations(); - this._section.setCount(annotations.length); + await Zotero.PDFWorker.renderAttachmentAnnotations(this.item.id); this._body.replaceChildren(); - if (!this._section.open) { - return; - } - - let count = annotations.length; - if (count === 0) { - this.hidden = true; + if (!this._section.open || this._annotationItems.length === 0) { return; } this.hidden = false; - for (let annotation of annotations) { - let row = document.createXULElement('annotation-row'); - row.annotation = annotation; - this._body.append(row); + for (let annotation of this._annotationItems) { + this.addRow(annotation); } } + addRow(annotation) { + let row = document.createXULElement('annotation-row'); + row.annotation = annotation; + + let index = this._annotationItems.findIndex(item => item.id == annotation.id); + if (index < 0 || index >= this._body.children.length) { + this._body.append(row); + } + else { + this._body.insertBefore(row, this._body.children[index]); + } + return row; + } + + updateCount() { + let count = this._annotationItems.length; + this._section.setCount(count); + if (count === 0) { + this.hidden = true; + } + return count; + } + _updateHidden() { - this.hidden = !this.item?.isFileAttachment() || this.tabType == "reader"; + this.hidden = !this.item || this.tabType == "reader"; } } customElements.define("attachment-annotations-box", AttachmentAnnotationsBox); diff --git a/chrome/content/zotero/elements/attachmentBox.js b/chrome/content/zotero/elements/attachmentBox.js index 819240c5ff..b6c4f7b1af 100644 --- a/chrome/content/zotero/elements/attachmentBox.js +++ b/chrome/content/zotero/elements/attachmentBox.js @@ -93,7 +93,7 @@ this._section = null; this._preview = null; - this._isRendering = false; + this._asyncRendering = false; this._isEditingFilename = false; } @@ -304,12 +304,12 @@ async asyncRender() { if (!this.item) return; - if (this._isRendering) return; + if (this._asyncRendering) return; if (!this._section.open) return; if (this._isAlreadyRendered("async")) return; Zotero.debug('Refreshing attachment box'); - this._isRendering = true; + this._asyncRendering = true; // Cancel editing filename when refreshing this._isEditingFilename = false; @@ -457,7 +457,7 @@ else { selectButton.hidden = true; } - this._isRendering = false; + this._asyncRendering = false; } onViewClick(event) { diff --git a/chrome/content/zotero/elements/attachmentPreview.js b/chrome/content/zotero/elements/attachmentPreview.js index f081187491..9efdae924e 100644 --- a/chrome/content/zotero/elements/attachmentPreview.js +++ b/chrome/content/zotero/elements/attachmentPreview.js @@ -165,6 +165,7 @@ this.addEventListener("focusin", this._handleFocusIn); this.addEventListener("keypress", this._handleKeypress); this.setAttribute("data-preview-type", "unknown"); + this._notifierID = Zotero.Notifier.registerObserver(this, ["item"], "attachmentPreview"); } destroy() { @@ -177,6 +178,38 @@ this.removeEventListener("click", this._handleFocusIn); this.removeEventListener("focusin", this._handleFocusIn); this.removeEventListener("keypress", this._handleKeypress); + Zotero.Notifier.unregisterObserver(this._notifierID); + } + + notify(event, type, ids, extraData) { + if (!this.item) return; + if (this.isReaderType && this._reader) { + // Following chrome/content/zotero/xpcom/reader.js + if (event === "delete") { + let disappearedIDs = this._reader.annotationItemIDs.filter(x => ids.includes(x)); + if (disappearedIDs.length) { + let keys = disappearedIDs.map(id => extraData[id].key); + this._reader.unsetAnnotations(keys); + } + } + else if (["add", "modify"].includes(event)) { + let annotationItems = this.item.getAnnotations(); + this._reader.annotationItemIDs = annotationItems.map(x => x.id); + let affectedAnnotations = annotationItems.filter(({ id }) => ( + ids.includes(id) + && !(extraData && extraData[id] && extraData[id].instanceID === this._reader._instanceID) + )); + if (affectedAnnotations.length) { + this._reader.setAnnotations(affectedAnnotations); + } + } + return; + } + if (this.isMediaType) { + if (["refresh", "modify"].includes(event) && ids.includes(this.item.id)) { + this.discard().then(() => this.render()); + } + } } async render() { @@ -184,6 +217,14 @@ if (!this.initialized && itemID === this._renderingItemID) { return; } + // For tests + let resolve; + if (Zotero.test) { + this._renderPromise = new Promise(r => resolve = r); + // Expose `resolve` for `this.discard` + this._renderPromise.resolve = resolve; + } + this._renderingItemID = itemID; let success = false; if (this.isValidType && await this._item.fileExists()) { @@ -203,6 +244,9 @@ if (this._renderingItemID === itemID) { this._renderingItemID = null; } + if (Zotero.test) { + resolve(); + } } async discard(force = false) { @@ -213,7 +257,7 @@ if (this._isDiscarding) { return; } - if (!force && this.isVisible) { + if (!force && (this.isVisible || !this._reader)) { return; } this._isDiscarding = true; @@ -237,6 +281,7 @@ this._id("preview")?.after(this.nextPreview); this.setPreviewStatus("loading"); this._isDiscarding = false; + this._renderPromise?.resolve(); } async openAttachment(event) { diff --git a/chrome/content/zotero/elements/attachmentsBox.js b/chrome/content/zotero/elements/attachmentsBox.js index 82da8df629..dc47573dd6 100644 --- a/chrome/content/zotero/elements/attachmentsBox.js +++ b/chrome/content/zotero/elements/attachmentsBox.js @@ -70,7 +70,9 @@ set usePreview(val) { this.toggleAttribute('data-use-preview', val); - this.updatePreview(); + if (this.item) { + this.updatePreview(); + } } init() { @@ -83,11 +85,17 @@ this._addPopup.id = ''; this.querySelector('popupset').append(this._addPopup); + this.usePreview = Zotero.Prefs.get('showAttachmentPreview'); this._preview = this.querySelector('attachment-preview'); this._notifierID = Zotero.Notifier.registerObserver(this, ['item'], 'attachmentsBox'); this._section._contextMenu.addEventListener('popupshowing', this._handleContextMenu, { once: true }); + + // For tests + this._asyncRendering = false; + // Indicate if the preview should update, can be none | initial | final + this._renderStage = "none"; } destroy() { @@ -96,16 +104,13 @@ } notify(action, type, ids) { - if (ids.includes(this._item?.id)) { - this._resetRenderedFlags(); - } if (!this._item?.isRegularItem()) return; this._updateAttachmentIDs().then(() => { this.updatePreview(); let attachments = Zotero.Items.get((this._attachmentIDs).filter(id => ids.includes(id))); - if (attachments.length === 0) { + if (attachments.length === 0 && action !== "delete") { return; } if (action == 'add') { @@ -113,20 +118,20 @@ this.addRow(attachment); } } - else if (action == 'modify') { + // When annotation added to attachment, action=modify + // When annotation deleted from attachment, action=refresh + else if (action == 'modify' || action == 'refresh') { for (let attachment of attachments) { let row = this.querySelector(`attachment-row[attachment-id="${attachment.id}"]`); - let open = false; if (row) { - open = row.open; row.remove(); } - this.addRow(attachment).open = open; + this.addRow(attachment); } } else if (action == 'delete') { - for (let attachment of attachments) { - let row = this.querySelector(`attachment-row[attachment-id="${attachment.id}"]`); + for (let id of ids) { + let row = this.querySelector(`attachment-row[attachment-id="${id}"]`); if (row) { row.remove(); } @@ -137,11 +142,9 @@ }); } - addRow(attachment, open = false) { + addRow(attachment) { let row = document.createXULElement('attachment-row'); this._updateRowAttributes(row, attachment); - // Set open state before adding to dom to prevent animation - row.toggleAttribute("open", open); let index = this._attachmentIDs.indexOf(attachment.id); if (index < 0 || index >= this._attachments.children.length) { @@ -156,12 +159,15 @@ render() { if (!this._item) return; if (this._isAlreadyRendered()) return; + this._renderStage = "initial"; this.updateCount(); } async asyncRender() { if (!this._item) return; if (this._isAlreadyRendered("async")) return; + this._renderStage = "final"; + this._asyncRendering = true; await this._updateAttachmentIDs(); @@ -171,15 +177,29 @@ for (let attachment of itemAttachments) { this.addRow(attachment); } - this.usePreview = Zotero.Prefs.get('showAttachmentPreview'); + await this.updatePreview(); + this._asyncRendering = false; } updateCount() { + if (!this._item?.isRegularItem()) { + return; + } let count = this._item.numAttachments(this.inTrash); this._section.setCount(count); } async updatePreview() { + // Skip if asyncRender is not finished/executed, which means the box is invisible + // The box will be rendered when it becomes visible + if (this._renderStage !== "final") { + return; + } + let attachment = await this._getPreviewAttachment(); + this.toggleAttribute('data-use-preview', !!attachment && Zotero.Prefs.get('showAttachmentPreview')); + if (!attachment) { + return; + } if (!this.usePreview // Skip only when the section is manually collapsed (when there's attachment), // This is necessary to ensure the rendering of the first added attachment @@ -187,11 +207,6 @@ || (this._attachmentIDs.length && !this._section.open)) { return; } - let attachment = await this._getPreviewAttachment(); - if (!attachment) { - this.toggleAttribute('data-use-preview', false); - return; - } this._preview.item = attachment; await this._preview.render(); } @@ -199,7 +214,7 @@ async _getPreviewAttachment() { let attachment = await this._item.getBestAttachment(); if (this.tabType === "reader" - && Zotero_Tabs._getTab(Zotero_Tabs.selectedID)?.tab?.data?.itemID == attachment.id) { + && Zotero_Tabs._getTab(this.tabID)?.tab?.data?.itemID == attachment.id) { // In the reader, only show the preview when viewing a secondary attachment return null; } diff --git a/chrome/content/zotero/elements/contextPane.js b/chrome/content/zotero/elements/contextPane.js index 5f60be7d4c..4dacbd1425 100644 --- a/chrome/content/zotero/elements/contextPane.js +++ b/chrome/content/zotero/elements/contextPane.js @@ -295,6 +295,7 @@ this._itemPaneDeck.appendChild(itemDetails); itemDetails.editable = editable; + itemDetails.tabID = tabID; itemDetails.tabType = "reader"; itemDetails.item = targetItem; // Manually cache parentID diff --git a/chrome/content/zotero/elements/itemDetails.js b/chrome/content/zotero/elements/itemDetails.js index 48d78759f3..7a9ecb795e 100644 --- a/chrome/content/zotero/elements/itemDetails.js +++ b/chrome/content/zotero/elements/itemDetails.js @@ -100,6 +100,14 @@ this.toggleAttribute('readonly', !editable); } + get tabID() { + return this._tabID; + } + + set tabID(tabID) { + this._tabID = tabID; + } + get tabType() { return this._tabType; } @@ -215,12 +223,18 @@ let item = this.item; Zotero.debug('Viewing item'); this._isRendering = true; + // For tests + let resolve; + if (Zotero.test) { + this._renderPromise = new Promise(r => resolve = r); + } this.renderCustomSections(); let panes = this.getPanes(); for (let box of [this._header, ...panes]) { box.editable = this.editable; + box.tabID = this.tabID; box.tabType = this.tabType; box.item = item; // Execute sync render immediately @@ -260,6 +274,9 @@ if (this.item.id == item.id) { this._isRendering = false; } + if (Zotero.test) { + resolve(); + } } renderCustomSections() { diff --git a/chrome/content/zotero/elements/itemPane.js b/chrome/content/zotero/elements/itemPane.js index 219f5bbd4b..88438c47a4 100644 --- a/chrome/content/zotero/elements/itemPane.js +++ b/chrome/content/zotero/elements/itemPane.js @@ -151,6 +151,7 @@ this.mode = "item"; this._itemDetails.editable = this.editable; + this._itemDetails.tabID = "zotero-pane"; this._itemDetails.tabType = "library"; this._itemDetails.item = item; diff --git a/chrome/content/zotero/elements/itemPaneSection.js b/chrome/content/zotero/elements/itemPaneSection.js index 4d28ee77dc..11b8d01762 100644 --- a/chrome/content/zotero/elements/itemPaneSection.js +++ b/chrome/content/zotero/elements/itemPaneSection.js @@ -43,6 +43,14 @@ class ItemPaneSectionElementBase extends XULElementBase { this.toggleAttribute('readonly', !editable); } + get tabID() { + return this._tabID; + } + + set tabID(tabID) { + this._tabID = tabID; + } + get tabType() { return this._tabType; } diff --git a/test/content/support.js b/test/content/support.js index 52a3d8087c..8f19bab194 100644 --- a/test/content/support.js +++ b/test/content/support.js @@ -384,6 +384,31 @@ async function delay(ms) { return Zotero.Promise.delay(ms); } +async function waitForFrame() { + return waitNoLongerThan(new Promise((resolve) => { + requestAnimationFrame(resolve); + }), 30); +} + +async function waitForFrames(n) { + for (let i = 0; i < n; i++) { + await waitForFrame(); + } +} + +async function waitNoLongerThan(promise, ms = 1000) { + return Promise.race([ + promise, + Zotero.Promise.delay(ms) + ]); +} + +async function waitForScrollToPane(itemDetails, paneID) { + await itemDetails._renderPromise; + itemDetails.scrollToPane(paneID, "instant"); + // Wait for some frames or up to 150ms to ensure the pane is visible + await waitForFrames(5); +} function clickOnItemsRow(win, itemsView, row) { itemsView._treebox.scrollToRow(row); @@ -1115,3 +1140,4 @@ async function startHTTPServer(port = null) { var baseURL = `http://localhost:${port}/` return { httpd, port, baseURL }; } + diff --git a/test/tests/itemPaneTest.js b/test/tests/itemPaneTest.js index 0beb841602..2595db9bd6 100644 --- a/test/tests/itemPaneTest.js +++ b/test/tests/itemPaneTest.js @@ -1,9 +1,38 @@ describe("Item pane", function () { - var win, doc, itemsView; + var win, doc, ZoteroPane, Zotero_Tabs, ZoteroContextPane, itemsView; + + async function waitForPreviewBoxRender(box) { + let success = await waitForCallback( + () => box._asyncRenderItemID && !box._asyncRendering); + if (!success) { + throw new Error("Wait for box render time out"); + } + await box._preview._renderPromise; + return success; + } + + async function waitForPreviewBoxReader(box, itemID) { + await waitForPreviewBoxRender(box); + let success = await waitForCallback( + () => box._preview._reader?.itemID == itemID, 100, 3000); + if (!success) { + throw new Error("Wait for box preview reader time out"); + } + await box._preview._reader._initPromise; + return success; + } + + function isPreviewDisplayed(box) { + return !!(box._preview.hasPreview + && win.getComputedStyle(box._preview).display !== "none"); + } before(function* () { win = yield loadZoteroPane(); doc = win.document; + ZoteroPane = win.ZoteroPane; + Zotero_Tabs = win.Zotero_Tabs; + ZoteroContextPane = win.ZoteroContextPane; itemsView = win.ZoteroPane.itemsView; }); after(function () { @@ -168,7 +197,7 @@ describe("Item pane", function () { assert.equal(label.value, 'Test'); yield Zotero.Items.erase(id); - }) + }); it("should swap creator names", async function () { @@ -344,7 +373,479 @@ describe("Item pane", function () { '1' ); }); - }) + }); + + describe("Attachments pane", function () { + let paneID = "attachments"; + + beforeEach(function () { + Zotero.Prefs.set("panes.attachments.open", true); + Zotero.Prefs.set("showAttachmentPreview", true); + Zotero_Tabs.select("zotero-pane"); + }); + + afterEach(function () { + Zotero_Tabs.select("zotero-pane"); + }); + + it("should show attachments pane in library for regular item", async function () { + // Regular item: show + let attachmentsBox = ZoteroPane.itemPane._itemDetails.getPane(paneID); + let item = new Zotero.Item('book'); + await item.saveTx(); + await ZoteroPane.selectItem(item.id); + assert.isFalse(attachmentsBox.hidden); + + // Child attachment: hide + let file = getTestDataDirectory(); + file.append('test.pdf'); + let attachment = await Zotero.Attachments.importFromFile({ + file, + parentItemID: item.id + }); + await ZoteroPane.selectItem(attachment.id); + assert.isTrue(attachmentsBox.hidden); + + // Standalone attachment: hide + let attachment1 = await importFileAttachment('test.pdf'); + await ZoteroPane.selectItem(attachment1.id); + assert.isTrue(attachmentsBox.hidden); + }); + + it("should not show attachments pane preview in reader best-matched attachment item", async function () { + let item = new Zotero.Item('book'); + let file = getTestDataDirectory(); + file.append('test.pdf'); + await item.saveTx(); + let attachment = await Zotero.Attachments.importFromFile({ + file, + parentItemID: item.id + }); + await ZoteroPane.viewItems([attachment]); + let tabID = Zotero_Tabs.selectedID; + let itemDetails = ZoteroContextPane.context._getItemContext(tabID); + let attachmentsBox = itemDetails.getPane(paneID); + assert.isFalse(attachmentsBox.hidden); + + await waitForScrollToPane(itemDetails, paneID); + + assert.isFalse(isPreviewDisplayed(attachmentsBox)); + }); + + it("should not show attachments pane in reader standalone attachment item", async function () { + let attachment = await importFileAttachment('test.pdf'); + await ZoteroPane.viewItems([attachment]); + let tabID = Zotero_Tabs.selectedID; + let itemDetails = ZoteroContextPane.context._getItemContext(tabID); + let attachmentsBox = itemDetails.getPane(paneID); + assert.isTrue(attachmentsBox.hidden); + }); + + it("should show attachments pane preview in reader non-best-matched attachment item", async function () { + let item = new Zotero.Item('book'); + let file = getTestDataDirectory(); + file.append('test.pdf'); + await item.saveTx(); + await Zotero.Attachments.importFromFile({ + file, + parentItemID: item.id + }); + await Zotero.Attachments.importFromFile({ + file, + parentItemID: item.id + }); + + let bestAttachments = await item.getBestAttachments(); + await ZoteroPane.viewItems([bestAttachments[1]]); + // Ensure context pane is open + ZoteroContextPane.splitter.setAttribute("state", "open"); + await waitForFrame(); + let tabID = Zotero_Tabs.selectedID; + let itemDetails = ZoteroContextPane.context._getItemContext(tabID); + let attachmentsBox = itemDetails.getPane(paneID); + assert.isFalse(attachmentsBox.hidden); + + await waitForScrollToPane(itemDetails, paneID); + await waitForPreviewBoxRender(attachmentsBox); + assert.isTrue(isPreviewDisplayed(attachmentsBox)); + }); + + it("should not render attachments pane preview when show preview is disabled", async function () { + Zotero.Prefs.set("showAttachmentPreview", false); + + let itemDetails = ZoteroPane.itemPane._itemDetails; + let attachmentsBox = itemDetails.getPane(paneID); + let item = new Zotero.Item('book'); + await item.saveTx(); + await ZoteroPane.selectItem(item.id); + assert.isFalse(attachmentsBox.hidden); + + await waitForScrollToPane(itemDetails, paneID); + + assert.isFalse(isPreviewDisplayed(attachmentsBox)); + }); + + it("should only render after attachments pane becomes visible", async function () { + // Resize to very small height to ensure the attachment box is not in view + let height = doc.documentElement.clientHeight; + win.resizeTo(null, 100); + + let itemDetails = ZoteroPane.itemPane._itemDetails; + let attachmentsBox = itemDetails.getPane(paneID); + let preview = attachmentsBox._preview; + // Force discard previous preview + await preview.discard(true); + + let item = new Zotero.Item('book'); + await item.saveTx(); + let file = getTestDataDirectory(); + file.append('test.pdf'); + await Zotero.Attachments.importFromFile({ + file, + parentItemID: item.id + }); + + await ZoteroPane.selectItem(item.id); + assert.isFalse(itemDetails.isPaneVisible(paneID)); + // Do not use _isAlreadyRendered, since that changes the render flag state + assert.equal(attachmentsBox._syncRenderItemID, item.id); + assert.notEqual(attachmentsBox._asyncRenderItemID, item.id); + assert.isFalse(isPreviewDisplayed(attachmentsBox)); + + await waitForScrollToPane(itemDetails, paneID); + await waitForPreviewBoxRender(attachmentsBox); + assert.isTrue(itemDetails.isPaneVisible(paneID)); + assert.equal(attachmentsBox._syncRenderItemID, item.id); + assert.equal(attachmentsBox._asyncRenderItemID, item.id); + + assert.isTrue(isPreviewDisplayed(attachmentsBox)); + assert.isTrue(preview.hasPreview); + win.resizeTo(null, height); + }); + + it("should update attachments pane when attachments changed", async function () { + // https://forums.zotero.org/discussion/113632/zotero-7-beta-pdf-attachment-preview-and-annotations-not-refreshed-after-adding-annotations + + let itemDetails = ZoteroPane.itemPane._itemDetails; + let attachmentsBox = itemDetails.getPane(paneID); + let preview = attachmentsBox._preview; + // Force discard previous preview + await preview.discard(true); + + // Pin the pane to ensure it's rendered + itemDetails.pinnedPane = paneID; + + let item = new Zotero.Item('book'); + await item.saveTx(); + + await ZoteroPane.selectItem(item.id); + assert.isTrue(await waitForPreviewBoxRender(attachmentsBox)); + // No preview + assert.isFalse(isPreviewDisplayed(attachmentsBox)); + // No row + assert.equal(attachmentsBox.querySelectorAll("attachment-row").length, 0); + + // Add an attachment + let file = getTestDataDirectory(); + file.append('test.png'); + let _attachment1 = await Zotero.Attachments.importFromFile({ + file, + parentItemID: item.id + }); + await ZoteroPane.selectItem(item.id); + await itemDetails._renderPromise; + await waitForPreviewBoxRender(attachmentsBox); + // Image preview for item with image attachment + assert.isTrue(isPreviewDisplayed(attachmentsBox)); + assert.equal(preview.previewType, "image"); + // 1 row + assert.equal(attachmentsBox.querySelectorAll("attachment-row").length, 1); + + // Add an PDF attachment, which will be best match and update the preview + file = getTestDataDirectory(); + file.append('test.pdf'); + let attachment2 = await Zotero.Attachments.importFromFile({ + file, + parentItemID: item.id + }); + await waitForPreviewBoxReader(attachmentsBox, attachment2.id); + await Zotero.Promise.delay(100); + // PDF preview + assert.isTrue(isPreviewDisplayed(attachmentsBox)); + assert.equal(preview.previewType, "pdf"); + // 2 rows + assert.equal(attachmentsBox.querySelectorAll("attachment-row").length, 2); + + // Created annotations should be update in preview and attachment row + let annotation = await createAnnotation('highlight', attachment2); + await Zotero.Promise.delay(100); + // Annotation updated in preview reader + let readerAnnotation + = preview._reader._internalReader._annotationManager._annotations.find( + a => a.libraryID === annotation.libraryID && a.id === annotation.key + ); + assert.exists(readerAnnotation); + + assert.equal(attachmentsBox.querySelectorAll("attachment-row").length, 2); + let attachmentRow = attachmentsBox.querySelector(`attachment-row[attachment-id="${attachment2.id}"]`); + assert.isFalse(attachmentRow._annotationButton.hidden); + // 1 annotation + assert.equal(attachmentRow._annotationButton.querySelector('.label').textContent, "1"); + + // Deleted annotations should be removed from preview and attachment row + await annotation.eraseTx(); + await Zotero.Promise.delay(100); + // Annotation removed from preview reader + readerAnnotation + = preview._reader._internalReader._annotationManager._annotations.find( + a => a.libraryID === annotation.libraryID && a.id === annotation.key + ); + assert.notExists(readerAnnotation); + // Row might be recreated + attachmentRow = attachmentsBox.querySelector(`attachment-row[attachment-id="${attachment2.id}"]`); + assert.isTrue(attachmentRow._annotationButton.hidden); + // 0 annotation + assert.equal(attachmentRow._annotationButton.querySelector('.label').textContent, "0"); + + // Delete attachment + await attachment2.eraseTx(); + await Zotero.Promise.delay(100); + // Image preview for item with image attachment + assert.isTrue(isPreviewDisplayed(attachmentsBox)); + assert.equal(preview.previewType, "image"); + // 1 row + assert.equal(attachmentsBox.querySelectorAll("attachment-row").length, 1); + // The corresponding row should be removed + attachmentRow = attachmentsBox.querySelector(`attachment-row[attachment-id="${attachment2.id}"]`); + assert.notExists(attachmentRow); + + // Unpin + itemDetails.pinnedPane = ""; + }); + + it("should keep attachments pane preview status after switching tab", async function () { + // https://forums.zotero.org/discussion/113658/zotero-7-beta-preview-appearing-in-the-item-pane-of-the-pdf-tab + + let item = new Zotero.Item('book'); + let file = getTestDataDirectory(); + file.append('test.pdf'); + await item.saveTx(); + let attachment = await Zotero.Attachments.importFromFile({ + file, + parentItemID: item.id + }); + + // Open reader + await ZoteroPane.viewItems([attachment]); + let tabID = Zotero_Tabs.selectedID; + await Zotero.Reader.getByTabID(tabID)._waitForReader(); + // Ensure context pane is open + ZoteroContextPane.splitter.setAttribute("state", "open"); + await waitForFrame(); + + let itemDetails = ZoteroContextPane.context._getItemContext(tabID); + let attachmentsBox = itemDetails.getPane(paneID); + assert.isFalse(attachmentsBox.hidden); + + await waitForScrollToPane(itemDetails, paneID); + assert.isFalse(isPreviewDisplayed(attachmentsBox)); + + // Select library tab + Zotero_Tabs.select("zotero-pane"); + let libraryItemDetails = ZoteroPane.itemPane._itemDetails; + let libraryAttachmentsBox = libraryItemDetails.getPane(paneID); + await ZoteroPane.selectItem(item.id); + await waitForScrollToPane(libraryItemDetails, paneID); + // Collapse section + libraryAttachmentsBox.querySelector('collapsible-section > .head').click(); + await Zotero.Promise.delay(50); + // Open section + libraryAttachmentsBox.querySelector('collapsible-section > .head').click(); + await Zotero.Promise.delay(50); + + // Select reader tab + Zotero_Tabs.select(tabID); + + // Make sure the preview status is not changed in reader + assert.isFalse(isPreviewDisplayed(attachmentsBox)); + }); + + /** + * This test is essential to ensure the proper functioning of the sync/async rendering, + * scrolling handler, and pinning mechanism of ItemDetails. + * AttachmentsBox serves as a good example since it involves both sync and async rendering. + * If this test fails, it is not recommended to add timeouts as a quick fix. + */ + it("should keep attachments pane status after changing selection", async function () { + let itemDetails = ZoteroPane.itemPane._itemDetails; + let attachmentsBox = itemDetails.getPane(paneID); + let preview = attachmentsBox._preview; + + // Pin the pane to avoid always scrolling to the section + itemDetails.pinnedPane = paneID; + + // item with attachment (1 annotation) + let item1 = new Zotero.Item('book'); + await item1.saveTx(); + let file = getTestDataDirectory(); + file.append('test.pdf'); + let attachment1 = await Zotero.Attachments.importFromFile({ + file, + parentItemID: item1.id + }); + let annotation = await createAnnotation('highlight', attachment1); + + await itemDetails._renderPromise; + await waitForPreviewBoxReader(attachmentsBox, attachment1.id); + + assert.isFalse(attachmentsBox.hidden); + let readerAnnotation + = preview._reader._internalReader._annotationManager._annotations.find( + a => a.libraryID === annotation.libraryID && a.id === annotation.key + ); + assert.exists(readerAnnotation); + assert.equal(attachmentsBox.querySelectorAll("attachment-row").length, 1); + let attachmentRow = attachmentsBox.querySelector(`attachment-row[attachment-id="${attachment1.id}"]`); + assert.isFalse(attachmentRow._annotationButton.hidden); + // 1 annotation + assert.equal(attachmentRow._annotationButton.querySelector('.label').textContent, "1"); + + // item with attachment (no annotation) + let item2 = new Zotero.Item('book'); + await item2.saveTx(); + file = getTestDataDirectory(); + file.append('wonderland_short.pdf'); + let attachment2 = await Zotero.Attachments.importFromFile({ + file, + parentItemID: item2.id + }); + + // Select item with attachment (no annotation) + await itemDetails._renderPromise; + await waitForPreviewBoxReader(attachmentsBox, attachment2.id); + + assert.isFalse(attachmentsBox.hidden); + readerAnnotation + = preview._reader._internalReader._annotationManager._annotations.find( + a => a.libraryID === annotation.libraryID && a.id === annotation.key + ); + assert.notExists(readerAnnotation); + assert.equal(attachmentsBox.querySelectorAll("attachment-row").length, 1); + attachmentRow = attachmentsBox.querySelector(`attachment-row[attachment-id="${attachment2.id}"]`); + assert.isTrue(attachmentRow._annotationButton.hidden); + // 0 annotation + assert.equal(attachmentRow._annotationButton.querySelector('.label').textContent, "0"); + + let item3 = new Zotero.Item('book'); + await item3.saveTx(); + + // Select item without attachment + await itemDetails._renderPromise; + + assert.isFalse(attachmentsBox.hidden); + assert.equal(attachmentsBox.querySelectorAll("attachment-row").length, 0); + + // Again, select item with attachment (1 annotation) + await ZoteroPane.selectItem(item1.id); + await itemDetails._renderPromise; + await waitForPreviewBoxReader(attachmentsBox, attachment1.id); + + assert.isFalse(attachmentsBox.hidden); + readerAnnotation + = preview._reader._internalReader._annotationManager._annotations.find( + a => a.libraryID === annotation.libraryID && a.id === annotation.key + ); + assert.exists(readerAnnotation); + assert.equal(attachmentsBox.querySelectorAll("attachment-row").length, 1); + attachmentRow = attachmentsBox.querySelector(`attachment-row[attachment-id="${attachment1.id}"]`); + assert.isFalse(attachmentRow._annotationButton.hidden); + // 1 annotation + assert.equal(attachmentRow._annotationButton.querySelector('.label').textContent, "1"); + + // Unpin + itemDetails.pinnedPane = ""; + }); + + it("should open attachment on clicking attachment row", async function () { + let itemDetails = ZoteroPane.itemPane._itemDetails; + let attachmentsBox = itemDetails.getPane(paneID); + + let item = new Zotero.Item('book'); + await item.saveTx(); + let file = getTestDataDirectory(); + file.append('test.pdf'); + let attachment = await Zotero.Attachments.importFromFile({ + file, + parentItemID: item.id + }); + + await ZoteroPane.selectItem(item.id); + await waitForScrollToPane(itemDetails, paneID); + await waitForPreviewBoxRender(attachmentsBox); + + let attachmentRow = attachmentsBox.querySelector(`attachment-row[attachment-id="${attachment.id}"]`); + attachmentRow._attachmentButton.click(); + await Zotero.Promise.delay(100); + let reader = await Zotero.Reader.getByTabID(Zotero_Tabs.selectedID); + // Should open attachment + assert.equal(reader.itemID, attachment.id); + }); + + it("should select attachment on clicking annotation button of attachment row", async function () { + let itemDetails = ZoteroPane.itemPane._itemDetails; + let attachmentsBox = itemDetails.getPane(paneID); + + let item = new Zotero.Item('book'); + await item.saveTx(); + let file = getTestDataDirectory(); + file.append('test.pdf'); + let attachment = await Zotero.Attachments.importFromFile({ + file, + parentItemID: item.id + }); + let _annotation = await createAnnotation('highlight', attachment); + + await ZoteroPane.selectItem(item.id); + await waitForScrollToPane(itemDetails, paneID); + await waitForPreviewBoxRender(attachmentsBox); + + let attachmentRow = attachmentsBox.querySelector(`attachment-row[attachment-id="${attachment.id}"]`); + attachmentRow._annotationButton.click(); + await Zotero.Promise.delay(100); + // Should select attachment + assert.equal(ZoteroPane.getSelectedItems(true)[0], attachment.id); + }); + + it("should open attachment on double-clicking attachments pane preview", async function () { + let itemDetails = ZoteroPane.itemPane._itemDetails; + let attachmentsBox = itemDetails.getPane(paneID); + let preview = attachmentsBox._preview; + + let item = new Zotero.Item('book'); + await item.saveTx(); + let file = getTestDataDirectory(); + file.append('test.pdf'); + let attachment = await Zotero.Attachments.importFromFile({ + file, + parentItemID: item.id + }); + + await ZoteroPane.selectItem(item.id); + await waitForScrollToPane(itemDetails, paneID); + await waitForPreviewBoxRender(attachmentsBox); + + let event = new MouseEvent('dblclick', { + bubbles: true, + cancelable: true, + view: window + }); + preview.dispatchEvent(event); + await Zotero.Promise.delay(100); + let reader = await Zotero.Reader.getByTabID(Zotero_Tabs.selectedID); + // Should open attachment + assert.equal(reader.itemID, attachment.id); + }); + }); describe("Notes pane", function () { @@ -461,6 +962,18 @@ describe("Item pane", function () { describe("Attachment pane", function () { + let paneID = "attachment-info"; + + beforeEach(function () { + Zotero.Prefs.set("panes.attachment-info.open", true); + Zotero.Prefs.set("showAttachmentPreview", true); + Zotero_Tabs.select("zotero-pane"); + }); + + afterEach(function () { + Zotero_Tabs.select("zotero-pane"); + }); + it("should refresh on file rename", async function () { let file = getTestDataDirectory(); file.append('test.png'); @@ -499,6 +1012,95 @@ describe("Item pane", function () { await promise; assert.equal(label.value, newTitle); }); + + it("should show attachment pane in library for attachment item", async function () { + // Regular item: hide + let itemDetails = ZoteroPane.itemPane._itemDetails; + let box = itemDetails.getPane(paneID); + let item = new Zotero.Item('book'); + await item.saveTx(); + await ZoteroPane.selectItem(item.id); + await waitForScrollToPane(itemDetails, paneID); + assert.isTrue(box.hidden); + + // Child attachment: show + let file = getTestDataDirectory(); + file.append('test.pdf'); + let attachment = await Zotero.Attachments.importFromFile({ + file, + parentItemID: item.id + }); + await ZoteroPane.selectItem(attachment.id); + await waitForScrollToPane(itemDetails, paneID); + await waitForPreviewBoxReader(box, attachment.id); + assert.isFalse(box.hidden); + await Zotero.Promise.delay(100); + assert.isTrue(isPreviewDisplayed(box)); + + // Standalone attachment: show + let attachment1 = await importFileAttachment('test.pdf'); + await ZoteroPane.selectItem(attachment1.id); + await waitForScrollToPane(itemDetails, paneID); + await waitForPreviewBoxReader(box, attachment1.id); + assert.isFalse(box.hidden); + await Zotero.Promise.delay(100); + assert.isTrue(isPreviewDisplayed(box)); + }); + + it("should show attachment pane without preview in reader for standalone attachment item", async function () { + // Attachment item with parent item: hide + let item = new Zotero.Item('book'); + let file = getTestDataDirectory(); + file.append('test.pdf'); + await item.saveTx(); + let attachment = await Zotero.Attachments.importFromFile({ + file, + parentItemID: item.id + }); + await ZoteroPane.viewItems([attachment]); + let tabID = Zotero_Tabs.selectedID; + let itemDetails = ZoteroContextPane.context._getItemContext(tabID); + let box = itemDetails.getPane(paneID); + assert.isTrue(box.hidden); + + // Standalone attachment item: show + attachment = await importFileAttachment('test.pdf'); + await ZoteroPane.viewItems([attachment]); + tabID = Zotero_Tabs.selectedID; + itemDetails = ZoteroContextPane.context._getItemContext(tabID); + box = itemDetails.getPane(paneID); + assert.isFalse(box.hidden); + + await waitForScrollToPane(itemDetails, paneID); + // No preview + assert.isFalse(isPreviewDisplayed(box)); + }); + + it("should only show attachment note container when exists", async function () { + let itemDetails = ZoteroPane.itemPane._itemDetails; + let box = itemDetails.getPane(paneID); + let noteContainer = box._id("note-container"); + let noteEditor = box._id('attachment-note-editor'); + + // Hide note container by default + let attachment = await importFileAttachment('test.pdf'); + await ZoteroPane.selectItem(attachment.id); + await itemDetails._renderPromise; + await waitForScrollToPane(itemDetails, paneID); + await waitForPreviewBoxRender(box); + assert.isTrue(noteContainer.hidden); + + // Add attachment note + let itemModifyPromise = waitForItemEvent("modify"); + attachment.setNote("