diff --git a/chrome/content/zotero/contextPane.js b/chrome/content/zotero/contextPane.js index 92122ac7f9..dbf7343404 100644 --- a/chrome/content/zotero/contextPane.js +++ b/chrome/content/zotero/contextPane.js @@ -938,45 +938,30 @@ var ZoteroContextPane = new function () { div.className = 'zotero-view-item'; main.append(div); - let createSection = (pane, showAdd = false) => { - let section = document.createXULElement('collapsible-section'); - section.dataset.pane = pane; - section.setAttribute('data-l10n-id', 'section-' + pane); - section.toggleAttribute('show-add', showAdd); - return section; - }; - // Info - var itemBoxContainer = createSection('info'); var itemBox = new (customElements.get('item-box')); - itemBoxContainer.append(itemBox); + itemBox.setAttribute('data-pane', 'info'); + div.append(itemBox); // Abstract - var abstractBoxContainer = createSection('abstract'); var abstractBox = new (customElements.get('abstract-box')); abstractBox.className = 'zotero-editpane-abstract'; - abstractBoxContainer.append(abstractBox); + abstractBox.setAttribute('data-pane', 'abstract'); + div.append(abstractBox); // TODO: Attachments // Tags - var tagsBoxContainer = createSection('tags', true); var tagsBox = new (customElements.get('tags-box')); tagsBox.className = 'zotero-editpane-tags'; - tagsBoxContainer.append(tagsBox); + tagsBox.setAttribute('data-pane', 'tags'); + div.append(tagsBox); // Related - var relatedBoxContainer = createSection('related', true); var relatedBox = new (customElements.get('related-box')); relatedBox.className = 'zotero-editpane-related'; - relatedBox.addEventListener('click', (event) => { - if (event.originalTarget.closest('.zotero-clicky')) { - Zotero_Tabs.select('zotero-pane'); - } - }); - relatedBoxContainer.append(relatedBox); - - div.append(itemBoxContainer, abstractBoxContainer, tagsBoxContainer, relatedBoxContainer); + relatedBox.setAttribute('data-pane', 'related'); + div.append(relatedBox); // item-pane-sidenav var sidenav = document.createXULElement('item-pane-sidenav'); diff --git a/chrome/content/zotero/customElements.js b/chrome/content/zotero/customElements.js index 25baa48c82..09497f20e9 100644 --- a/chrome/content/zotero/customElements.js +++ b/chrome/content/zotero/customElements.js @@ -50,6 +50,9 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/editableTe Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemPaneSidenav.js', this); Services.scriptloader.loadSubScript('chrome://zotero/content/elements/abstractBox.js', this); Services.scriptloader.loadSubScript('chrome://zotero/content/elements/collapsibleSection.js', this); +Services.scriptloader.loadSubScript('chrome://zotero/content/elements/attachmentsBox.js', this); +Services.scriptloader.loadSubScript('chrome://zotero/content/elements/attachmentRow.js', this); +Services.scriptloader.loadSubScript('chrome://zotero/content/elements/annotationRow.js', this); // Fix missing property bug that breaks arrow key navigation between s { diff --git a/chrome/content/zotero/elements/abstractBox.js b/chrome/content/zotero/elements/abstractBox.js index 8ef6dde904..ac3f4fdf7d 100644 --- a/chrome/content/zotero/elements/abstractBox.js +++ b/chrome/content/zotero/elements/abstractBox.js @@ -28,7 +28,11 @@ { class AbstractBox extends XULElementBase { content = MozXULElement.parseXULToFragment(` - + + + + + `); _item = null; @@ -83,8 +87,10 @@ } async blurOpenField() { - this.abstractField.blur(); - await this.save(); + if (this.abstractField?.matches(':focus-within')) { + this.abstractField.blur(); + await this.save(); + } } render() { @@ -92,9 +98,10 @@ return; } - let title = this.item.getField('abstractNote'); - if (this.abstractField.initialValue !== title) { - this.abstractField.value = title; + let abstract = this.item.getField('abstractNote'); + if (!this.abstractField.initialValue || this.abstractField.initialValue !== abstract) { + this.abstractField.value = abstract; + this.abstractField.initialValue = ''; } this.abstractField.readOnly = this._mode == 'view'; this.abstractField.setAttribute('aria-label', Zotero.ItemFields.getLocalizedString('abstractNote')); diff --git a/chrome/content/zotero/elements/annotationRow.js b/chrome/content/zotero/elements/annotationRow.js new file mode 100644 index 0000000000..78921318b7 --- /dev/null +++ b/chrome/content/zotero/elements/annotationRow.js @@ -0,0 +1,121 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2023 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 ***** +*/ + +"use strict"; + +{ + class AnnotationRow extends XULElementBase { + content = MozXULElement.parseXULToFragment(` + + + + + + + `); + + _annotation = null; + + _mode = null; + + _listenerAdded = false; + + static get observedAttributes() { + return ['annotation-id']; + } + + attributeChangedCallback(name, oldValue, newValue) { + switch (name) { + case 'annotation-id': + this._annotation = Zotero.Items.get(newValue); + break; + } + this.render(); + } + + get annotation() { + return this._annotation; + } + + set annotation(annotation) { + this._annotation = annotation; + this.setAttribute('annotation-id', annotation.id); + } + + init() { + this._head = this.querySelector('.head'); + this._title = this.querySelector('.title'); + this._body = this.querySelector('.body'); + this._tags = this.querySelector('.tags'); + this.render(); + } + + render() { + if (!this.initialized) return; + + this._title.textContent = Zotero.getString('pdfReader.page') + ' ' + + (this._annotation.annotationPageLabel || '-'); + + let type = this._annotation.annotationType; + if (type == 'image') { + type = 'area'; + } + this.querySelector('.icon').src = 'chrome://zotero/skin/16/universal/annotate-' + type + '.svg'; + this._body.replaceChildren(); + + if (['image', 'ink'].includes(this._annotation.annotationType)) { + let imagePath = Zotero.Annotations.getCacheImagePath(this._annotation); + if (imagePath) { + let img = document.createElement('img'); + img.src = Zotero.File.pathToFileURI(imagePath); + img.draggable = false; + this._body.append(img); + } + } + + if (this._annotation.annotationText) { + let text = document.createElement('div'); + text.classList.add('quote'); + text.textContent = this._annotation.annotationText; + this._body.append(text); + } + + if (this._annotation.annotationComment) { + let comment = document.createElement('div'); + comment.classList.add('comment'); + comment.textContent = this._annotation.annotationComment; + this._body.append(comment); + } + + let tags = this._annotation.getTags(); + this._tags.hidden = !tags.length; + this._tags.textContent = tags.map(tag => tag.tag).sort(Zotero.localeCompare).join(Zotero.getString('punctuation.comma') + ' '); + + this.style.setProperty('--annotation-color', this._annotation.annotationColor); + } + } + + customElements.define('annotation-row', AnnotationRow); +} diff --git a/chrome/content/zotero/elements/attachmentRow.js b/chrome/content/zotero/elements/attachmentRow.js new file mode 100644 index 0000000000..827dcbdf4f --- /dev/null +++ b/chrome/content/zotero/elements/attachmentRow.js @@ -0,0 +1,177 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2023 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 ***** +*/ + +"use strict"; + +import { getCSSItemTypeIcon } from 'components/icons'; + +{ + class AttachmentRow extends XULElementBase { + content = MozXULElement.parseXULToFragment(` + + + + + + + + + `); + + _attachment = null; + + _mode = null; + + _listenerAdded = false; + + static get observedAttributes() { + return ['attachment-id']; + } + + attributeChangedCallback(name, oldValue, newValue) { + switch (name) { + case 'attachment-id': + this._attachment = Zotero.Items.get(newValue); + break; + } + this.render(); + } + + get open() { + if (this.empty) { + return false; + } + return this.hasAttribute('open'); + } + + set open(val) { + val = !!val; + let open = this.open; + if (open === val || this.empty) return; + this.render(); + let openHeight = this._body.scrollHeight; + if (openHeight) { + this.style.setProperty('--open-height', `${openHeight}px`); + } + else { + this.style.setProperty('--open-height', 'auto'); + } + + // eslint-disable-next-line no-void + void getComputedStyle(this).maxHeight; // Force style calculation! Without this the animation doesn't work + this.toggleAttribute('open', val); + if (!this.dispatchEvent(new CustomEvent('toggle', { bubbles: false, cancelable: true }))) { + // Revert + this.toggleAttribute('open', open); + return; + } + if (!val && this.ownerDocument?.activeElement && this.contains(this.ownerDocument?.activeElement)) { + this.ownerDocument.activeElement.blur(); + } + } + + get attachment() { + return this._attachment; + } + + set attachment(attachment) { + this._attachment = attachment; + this.setAttribute('attachment-id', attachment.id); + } + + get attachmentTitle() { + return this._attachment.getField('title'); + } + + get empty() { + return !this._attachment || !this._attachment.numAnnotations(); + } + + get contextRow() { + return this.classList.contains('context'); + } + + set contextRow(val) { + this.classList.toggle('context', !!val); + } + + init() { + this._head = this.querySelector('.head'); + this._head.addEventListener('click', this._handleClick); + this._head.addEventListener('keydown', this._handleKeyDown); + + this._label = this.querySelector('.label'); + this._body = this.querySelector('.body'); + this.open = false; + this.render(); + } + + _handleClick = (event) => { + if (event.target.closest('.clicky-item')) { + let win = Zotero.getMainWindow(); + if (win) { + win.ZoteroPane.selectItem(this._attachment.id); + win.Zotero_Tabs.select('zotero-pane'); + win.focus(); + } + return; + } + this.open = !this.open; + }; + + _handleKeyDown = (event) => { + if (event.key === 'Enter' || event.key === ' ') { + this.open = !this.open; + event.preventDefault(); + } + }; + + render() { + if (!this.initialized) return; + + this.querySelector('.icon').replaceWith(getCSSItemTypeIcon(this._attachment.getItemTypeIconName())); + this._label.textContent = this._attachment.getField('title'); + + this._body.replaceChildren(); + for (let annotation of this._attachment.getAnnotations()) { + let row = document.createXULElement('annotation-row'); + row.annotation = annotation; + this._body.append(row); + } + + if (!this._listenerAdded) { + this._body.addEventListener('transitionend', () => { + this.style.setProperty('--open-height', 'auto'); + }); + this._listenerAdded = true; + } + + this._head.setAttribute('aria-expanded', this.open); + this.toggleAttribute('empty', this.empty); + } + } + + customElements.define('attachment-row', AttachmentRow); +} diff --git a/chrome/content/zotero/elements/attachmentsBox.js b/chrome/content/zotero/elements/attachmentsBox.js new file mode 100644 index 0000000000..18a9b4c910 --- /dev/null +++ b/chrome/content/zotero/elements/attachmentsBox.js @@ -0,0 +1,164 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2023 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 ***** +*/ + +"use strict"; + +{ + class AttachmentsBox extends XULElementBase { + content = MozXULElement.parseXULToFragment(` + + + + + `); + + _item = null; + + _mode = null; + + _inTrash = false; + + get item() { + return this._item; + } + + set item(item) { + if (this._item === item) { + return; + } + + this._item = item; + this._body.replaceChildren(); + if (item) { + for (let attachment of Zotero.Items.get(item.getAttachments())) { + this.addRow(attachment); + } + this.updateCount(); + } + } + + get mode() { + return this._mode; + } + + set mode(mode) { + this._mode = mode; + } + + get inTrash() { + return this._inTrash; + } + + set inTrash(inTrash) { + this._inTrash = inTrash; + for (let row of this._body.children) { + row.contextRow = this._isContext(row.attachment); + } + this.updateCount(); + } + + init() { + this._section = this.querySelector('collapsible-section'); + this._section.addEventListener('add', this._handleAdd); + this._body = this.querySelector('.body'); + this._notifierID = Zotero.Notifier.registerObserver(this, ['item'], 'attachmentsBox'); + } + + destroy() { + Zotero.Notifier.unregisterObserver(this._notifierID); + } + + notify(action, type, ids) { + if (!this._item) return; + + let itemAttachmentIDs = this._item.getAttachments(true); + let attachments = Zotero.Items.get(ids.filter(id => itemAttachmentIDs.includes(id))); + if (action == 'add') { + for (let attachment of attachments) { + this.addRow(attachment); + } + } + else if (action == 'modify') { + 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; + } + } + else if (action == 'delete') { + for (let attachment of attachments) { + let row = this.querySelector(`attachment-row[attachment-id="${attachment.id}"]`); + if (row) { + row.remove(); + } + } + } + + this.updateCount(); + } + + addRow(attachment) { + if (attachment.deleted && !this._inTrash) return; + + let row = document.createXULElement('attachment-row'); + row.attachment = attachment; + row.contextRow = this._isContext(attachment); + + let inserted = false; + for (let existingRow of this._body.children) { + if (Zotero.localeCompare(row.attachmentTitle, existingRow.attachmentTitle) < 0) { + continue; + } + existingRow.before(row); + inserted = true; + break; + } + if (!inserted) { + this._body.append(row); + } + return row; + } + + updateCount() { + let count = this._item.numAttachments(this._inTrash); + this._section.setCount(count); + } + + _handleAdd = () => { + ZoteroPane.addAttachmentFromDialog(false, this._item.id); + this._section.empty = false; + this._section.open = true; + }; + + _isContext(attachment) { + return this._inTrash && !this._item.deleted && !attachment.deleted; + } + } + customElements.define("attachments-box", AttachmentsBox); +} diff --git a/chrome/content/zotero/elements/base.js b/chrome/content/zotero/elements/base.js index f828c9fa0e..c1195e6a1f 100644 --- a/chrome/content/zotero/elements/base.js +++ b/chrome/content/zotero/elements/base.js @@ -50,8 +50,8 @@ class XULElementBase extends XULElement { document.l10n.connectRoot(this.shadowRoot); } - this.init(); this.initialized = true; + this.init(); } disconnectedCallback() { diff --git a/chrome/content/zotero/elements/collapsibleSection.js b/chrome/content/zotero/elements/collapsibleSection.js index 59b0d89067..2a1da837cb 100644 --- a/chrome/content/zotero/elements/collapsibleSection.js +++ b/chrome/content/zotero/elements/collapsibleSection.js @@ -36,13 +36,16 @@ _listenerAdded = false; get open() { + if (this.empty) { + return false; + } return this.hasAttribute('open'); } set open(val) { val = !!val; let open = this.open; - if (open === val) return; + if (open === val || this.empty) return; this.render(); let openHeight = this._head?.nextSibling?.scrollHeight; if (openHeight) { @@ -58,11 +61,28 @@ if (!this.dispatchEvent(new CustomEvent('toggle', { bubbles: false, cancelable: true }))) { // Revert this.toggleAttribute('open', open); + return; + } + if (!val && this.ownerDocument?.activeElement && this.contains(this.ownerDocument?.activeElement)) { + this.ownerDocument.activeElement.blur(); } this._saveOpenState(); } + get empty() { + return this.hasAttribute('empty'); + } + + set empty(val) { + this.toggleAttribute('empty', !!val); + } + + setCount(count) { + this.setAttribute('data-l10n-args', JSON.stringify({ count })); + this.empty = !count; + } + get label() { return this.getAttribute('label'); } @@ -80,7 +100,7 @@ } static get observedAttributes() { - return ['open', 'label', 'show-add']; + return ['open', 'empty', 'label', 'show-add']; } attributeChangedCallback() { @@ -92,7 +112,6 @@ throw new Error('data-pane is required'); } - this._restoreOpenState(); this.tabIndex = 0; this._head = document.createElement('div'); @@ -108,8 +127,7 @@ this._addButton = document.createXULElement('toolbarbutton'); this._addButton.className = 'add'; this._addButton.addEventListener('command', (event) => { - // TODO: Is this the best approach? - this._head.nextSibling?.dispatchEvent(new CustomEvent('add', { ...event, bubbles: true })); + this.dispatchEvent(new CustomEvent('add', { ...event, bubbles: false })); }); this._head.append(this._addButton); @@ -118,9 +136,14 @@ this._head.append(twisty); this.prepend(this._head); + this._restoreOpenState(); this.render(); this._notifierID = Zotero.Prefs.registerObserver(`panes.${this.dataset.pane}.open`, this._restoreOpenState.bind(this)); + + if (this.hasAttribute('data-l10n-id') && !this.hasAttribute('data-l10n-args')) { + this.setAttribute('data-l10n-args', JSON.stringify({ count: 0 })); + } } destroy() { diff --git a/chrome/content/zotero/elements/editableText.js b/chrome/content/zotero/elements/editableText.js index b39c2d1179..219b0fd1ae 100644 --- a/chrome/content/zotero/elements/editableText.js +++ b/chrome/content/zotero/elements/editableText.js @@ -27,13 +27,9 @@ { class EditableText extends XULElementBase { - content = MozXULElement.parseXULToFragment(` - - `); + _input; - _textarea; - - static observedAttributes = ['multiline', 'readonly', 'label']; + static observedAttributes = ['multiline', 'readonly', 'placeholder', 'label', 'aria-label', 'value']; get multiline() { return this.hasAttribute('multiline'); @@ -50,45 +46,74 @@ set readOnly(readOnly) { this.toggleAttribute('readonly', readOnly); } - + + // Fluent won't set placeholder on an editable-text for some reason, so we use the label property to store + // the placeholder that will be set on the child