fx-compat: Implement tagsBox element

This commit is contained in:
Martynas Bagdonas 2022-06-07 17:13:48 +03:00
parent 7e55ea59bb
commit 2e5388af5b
16 changed files with 1099 additions and 176 deletions

View file

@ -27,7 +27,6 @@
// related with `require` not reusing the context // related with `require` not reusing the context
var React = require('react'); var React = require('react');
var ReactDOM = require('react-dom'); var ReactDOM = require('react-dom');
var TagsBoxContainer = require('containers/tagsBoxContainer').default;
var NotesList = require('components/itemPane/notesList').default; var NotesList = require('components/itemPane/notesList').default;
var ZoteroContextPane = new function () { var ZoteroContextPane = new function () {
@ -852,25 +851,11 @@ var ZoteroContextPane = new function () {
panelInfo.append(itemBox); panelInfo.append(itemBox);
// Tags panel // Tags panel
var panelTags = document.createXULElement('tabpanel'); var panelTags = document.createXULElement('tabpanel');
panelTags.setAttribute('orient', 'vertical'); var tagsBox = new (customElements.get('tags-box'));
panelTags.setAttribute('context', 'tags-context-menu'); tagsBox.setAttribute('flex', '1');
panelTags.className = 'tags-pane'; tagsBox.className = 'zotero-editpane-tags';
panelTags.style.display = 'flex'; panelTags.append(tagsBox);
var div = document.createElementNS(HTML_NS, 'div');
div.className = 'tags-box-container';
div.style.display = 'flex';
div.style.flexGrow = '1';
panelTags.append(div);
var tagsBoxRef = React.createRef();
ReactDOM.render(
<TagsBoxContainer
key={'tagsBox-' + parentItem.id}
item={parentItem}
editable={!readOnly}
ref={tagsBoxRef}
/>,
div
);
// Related panel // Related panel
var panelRelated = document.createXULElement('tabpanel'); var panelRelated = document.createXULElement('tabpanel');
var relatedBox = new (customElements.get('related-box')); var relatedBox = new (customElements.get('related-box'));
@ -890,6 +875,9 @@ var ZoteroContextPane = new function () {
itemBox.mode = readOnly ? 'view' : 'edit'; itemBox.mode = readOnly ? 'view' : 'edit';
itemBox.item = parentItem; itemBox.item = parentItem;
tagsBox.mode = readOnly ? 'view' : 'edit';
tagsBox.item = parentItem;
relatedBox.mode = readOnly ? 'view' : 'edit'; relatedBox.mode = readOnly ? 'view' : 'edit';
relatedBox.item = parentItem; relatedBox.item = parentItem;
} }

View file

@ -328,8 +328,6 @@
{ {
let TagsBoxContainer = require('containers/tagsBoxContainer').default;
class LinksBox extends XULElement { class LinksBox extends XULElement {
constructor() { constructor() {
super(); super();
@ -357,7 +355,7 @@
<related-box id="related"/> <related-box id="related"/>
</menupopup> </menupopup>
<menupopup id="tags-popup" width="300" ignorekeys="true"> <menupopup id="tags-popup" width="300" ignorekeys="true">
<div style="display: flex" id="tags-box-container" xmlns="http://www.w3.org/1999/xhtml"/> <tags-box id="tags"/>
</menupopup> </menupopup>
</popupset> </popupset>
`, ['chrome://zotero/locale/zotero.dtd']); `, ['chrome://zotero/locale/zotero.dtd']);
@ -401,6 +399,7 @@
set item(val) { set item(val) {
this._item = val; this._item = val;
this._id('related').item = this._item; this._id('related').item = this._item;
this._id('tags').item = this._item;
this.refresh(); this.refresh();
@ -417,6 +416,7 @@
set mode(val) { set mode(val) {
this._mode = val; this._mode = val;
this._id('related').mode = val; this._id('related').mode = val;
this._id('tags').mode = val;
this.refresh(); this.refresh();
} }
@ -434,22 +434,6 @@
this._updateParentRow(); this._updateParentRow();
this._updateTagsSummary(); this._updateTagsSummary();
this._updateRelatedSummary(); this._updateRelatedSummary();
// TODO: Update tagsBox container state via tagsBoxRef and imperative handle, instead of recreating it
let container = this._id('tags-box-container');
ReactDOM.unmountComponentAtNode(container);
if (this._item) {
var tagsBoxRef = React.createRef();
ReactDOM.render(
<TagsBoxContainer
key={'tagsBox-' + this._item.id}
item={this._item}
editable={this._mode == 'edit'}
ref={tagsBoxRef}
/>,
container
);
}
} }
_updateParentRow() { _updateParentRow() {
@ -546,7 +530,7 @@
// If editable and no existing tags, open new empty row // If editable and no existing tags, open new empty row
if (this._mode == 'edit' && !this._item.getTags().length) { if (this._mode == 'edit' && !this._item.getTags().length) {
setTimeout(() => { setTimeout(() => {
this._id('tags-popup').querySelector('.tags-box-header button').click(); this._id('tags').addNew();
}); });
} }
}; };

View file

@ -33,10 +33,10 @@
} }
/** /**
* Extends AutocompleteInput to fix document.activeElement checks that * Extend AutocompleteInput to work around issues with shadow DOM
* don't work in a shadow DOM context.
*/ */
class ShadowAutocompleteInput extends customElements.get('autocomplete-input') { class ShadowAutocompleteInput extends customElements.get('autocomplete-input') {
// Fix document.activeElement checks that don't work in a shadow DOM context
get focused() { get focused() {
// document.activeElement by itself doesn't traverse shadow DOMs; see // document.activeElement by itself doesn't traverse shadow DOMs; see
// https://www.abeautifulsite.net/posts/finding-the-active-element-in-a-shadow-root/ // https://www.abeautifulsite.net/posts/finding-the-active-element-in-a-shadow-root/
@ -53,6 +53,21 @@
return this === activeElement(document); return this === activeElement(document);
} }
// Look for `autocompletepopup` popup id inside the current shadow root.
// `autocomplete-input` itself can create an autocomplete popup inside the top DOM,
// but it appears behind the tagsBox popup, because the z order of popups messes up
get popup() {
let rootNode = this.getRootNode();
if (rootNode && rootNode instanceof ShadowRoot) {
let id = this.getAttribute('autocompletepopup');
let popup = rootNode.getElementById(id);
if (popup) {
return popup;
}
}
return super.popup;
}
} }
customElements.define("shadow-autocomplete-input", ShadowAutocompleteInput, { customElements.define("shadow-autocomplete-input", ShadowAutocompleteInput, {

View file

@ -0,0 +1,979 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2022 Corporation for Digital Scholarship
Vienna, Virginia, USA
https://www.zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
"use strict";
{
class TagsBox extends XULElement {
constructor() {
super();
this.count = 0;
this.clickHandler = null;
this._lastTabIndex = false;
this._tabDirection = null;
this._tagColors = [];
this._notifierID = null;
this._mode = 'view';
this._item = null;
this.content = MozXULElement.parseXULToFragment(`
<box flex="1" tooltip="html-tooltip" style="display: flex" xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
<div id="tags-box" style="flex-grow: 1" xmlns="http://www.w3.org/1999/xhtml">
<div class="tags-box-header">
<label id="count"/>
<button id="add">&zotero.item.add;</button>
</div>
<ul id="rows" class="tags-box-list"/>
</div>
</box>
<popupset xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
<tooltip id="html-tooltip" page="true"/>
<menupopup id="tags-context-menu">
<menuitem id="remove-all-item-tags" label="&zotero.item.tags.removeAll;"/>
</menupopup>
<!-- Note: autocomplete-input can create this panel by itself, but it appears
in the top DOM and is behind the tags box popup -->
<panel
is="autocomplete-richlistbox-popup"
type="autocomplete-richlistbox"
id="PopupAutoComplete"
role="group"
noautofocus="true"
hidden="true"
overflowpadding="4"
norolluponanchor="true"
nomaxresults="true"
/>
</popupset>
`, ['chrome://zotero/locale/zotero.dtd']);
}
connectedCallback() {
this._destroyed = false;
window.addEventListener("unload", this.destroy);
let shadow = this.attachShadow({ mode: "open" });
let s1 = document.createElement("link");
s1.rel = "stylesheet";
s1.href = "chrome://zotero-platform/content/tagsBox.css";
shadow.append(s1);
let s2 = document.createElement("link");
s2.rel = "stylesheet";
s2.href = "chrome://global/skin/";
shadow.append(s2);
let s3 = document.createElement("link");
s3.rel = "stylesheet";
s3.href = "chrome://zotero/skin/overlay.css";
shadow.append(s3);
let content = document.importNode(this.content, true);
shadow.append(content);
this._id('add').addEventListener('click', this._handleAddButtonClick);
this._id('add').addEventListener('keydown', this._handleAddButtonKeyDown);
this._id('tags-box').addEventListener('click', (event) => {
if (event.target.id == 'tags-box') {
this.blurOpenField();
}
});
let removeAllItemTags = this._id('remove-all-item-tags');
this._id('remove-all-item-tags').addEventListener('command', this.removeAll);
this._id('tags-box').addEventListener('contextmenu', (event) => {
removeAllItemTags.disabled = !this.count;
this._id('tags-context-menu').openPopupAtScreen(event.screenX, event.screenY, true);
});
this._notifierID = Zotero.Notifier.registerObserver(this, ['item-tag', 'setting'], 'tagsBox');
}
destroy() {
if (this._destroyed) {
return;
}
window.removeEventListener("unload", this.destroy);
this._destroyed = true;
Zotero.Notifier.unregisterObserver(this._notifierID);
}
disconnectedCallback() {
this.replaceChildren();
this.destroy();
}
get mode() {
return this._mode;
}
set mode(val) {
this.clickable = false;
this.editable = false;
switch (val) {
case 'view':
case 'merge':
case 'mergeedit':
break;
case 'edit':
this.clickable = true;
this.editable = true;
this.clickHandler = this.showEditor;
this.blurHandler = this.hideEditor;
break;
default:
throw new Error(`Invalid mode ${val}`);
}
this._mode = val;
}
get item() {
return this._item;
}
set item(val) {
// Don't reload if item hasn't changed
if (this._item == val) {
return;
}
this._item = val;
this._lastTabIndex = false;
this.reload();
}
notify(event, type, ids, extraData) {
if (type == 'setting') {
if (ids.some(val => val.split("/")[1] == 'tagColors') && this.item) {
this.reload();
return;
}
}
else if (type == 'item-tag') {
let itemID, tagID;
for (let i = 0; i < ids.length; i++) {
[itemID, tagID] = ids[i].split('-').map(x => parseInt(x));
if (!this.item || itemID != this.item.id) {
continue;
}
let data = extraData[ids[i]];
let tagName = data.tag;
let tagType = data.type;
if (event == 'add') {
var newTabIndex = this.add(tagName, tagType);
if (newTabIndex == -1) {
return;
}
if (this._tabDirection == -1) {
if (this._lastTabIndex > newTabIndex) {
this._lastTabIndex++;
}
}
else if (this._tabDirection == 1) {
if (this._lastTabIndex > newTabIndex) {
this._lastTabIndex++;
}
}
}
else if (event == 'modify') {
let oldTagName = data.old.tag;
this.remove(oldTagName);
this.add(tagName, tagType);
}
else if (event == 'remove') {
var oldTabIndex = this.remove(tagName);
if (oldTabIndex == -1) {
return;
}
if (this._tabDirection == -1) {
if (this._lastTabIndex > oldTabIndex) {
this._lastTabIndex--;
}
}
else if (this._tabDirection == 1) {
if (this._lastTabIndex >= oldTabIndex) {
this._lastTabIndex--;
}
}
}
}
this.updateCount();
}
else if (type == 'tag') {
if (event == 'modify') {
this.reload();
return;
}
}
}
reload() {
Zotero.debug('Reloading tags box');
// Cancel field focusing while we're updating
this._reloading = true;
this._id('add').hidden = !this.editable;
this._tagColors = Zotero.Tags.getColors(this.item.libraryID);
let tagRows = this._id('rows');
tagRows.replaceChildren();
var tags = this.item.getTags();
// Sort tags alphabetically
var collation = Zotero.getLocaleCollation();
tags.sort((a, b) => collation.compareString(1, a.tag, b.tag));
for (let i = 0; i < tags.length; i++) {
this.addDynamicRow(tags[i], i + 1);
}
this.updateCount(tags.length);
this._reloading = false;
this._focusField();
}
addDynamicRow(tagData, tabindex, skipAppend) {
var isNew = !tagData;
var name = tagData ? tagData.tag : "";
if (!tabindex) {
tabindex = this._id('rows').childNodes.length + 1;
}
var icon = document.createElement("img");
icon.className = "zotero-box-icon";
// DEBUG: Why won't just this.nextSibling.blur() work?
icon.addEventListener('click', (event) => {
event.target.nextSibling.blur();
});
var label = this.createValueElement(name, tabindex);
if (this.editable) {
var remove = document.createElement("label");
remove.setAttribute('value', '-');
remove.setAttribute('class', 'zotero-clicky zotero-clicky-minus');
remove.setAttribute('tabindex', -1);
}
var row = document.createElement("li");
if (isNew) {
row.setAttribute('isNew', true);
}
row.appendChild(icon);
row.appendChild(label);
if (this.editable) {
row.appendChild(remove);
}
this.updateRow(row, tagData);
if (!skipAppend) {
this._id('rows').appendChild(row);
}
return row;
}
// Update various attributes of a row to match the given tag
// and current editability
updateRow(row, tagData) {
var tagName = tagData ? tagData.tag : "";
var tagType = (tagData && tagData.type) ? tagData.type : 0;
var icon = row.firstChild;
if (this.editable) {
var remove = row.lastChild;
}
// Row
row.setAttribute('tagName', tagName);
row.setAttribute('tagType', tagType);
// Icon
var iconFile = 'tag';
if (!tagData || tagType == 0) {
icon.setAttribute('title', Zotero.getString('pane.item.tags.icon.user'));
}
else if (tagType == 1) {
iconFile += '-automatic';
icon.setAttribute('title', Zotero.getString('pane.item.tags.icon.automatic'));
}
icon.setAttribute('src', `chrome://zotero/skin/${iconFile}${Zotero.hiDPISuffix}.png`);
// "-" button
if (this.editable) {
remove.setAttribute('disabled', false);
remove.addEventListener('click', async (event) => {
this._lastTabIndex = false;
if (tagData) {
let item = this.item;
this.remove(tagName);
try {
item.removeTag(tagName);
await item.saveTx();
}
catch (e) {
this.reload();
throw e;
}
}
// Remove empty textbox row
else {
row.parentNode.removeChild(row);
}
// TODO: Return focus to items pane
var tree = document.getElementById('zotero-items-tree');
if (tree) {
tree.focus();
}
});
}
}
createValueElement(valueText, tabindex) {
var valueElement = document.createElement("label");
valueElement.setAttribute('fieldname', 'tag');
valueElement.setAttribute('flex', 1);
valueElement.className = 'zotero-box-label';
if (this.clickable) {
if (tabindex) {
valueElement.setAttribute('ztabindex', tabindex);
}
valueElement.addEventListener('click', (event) => {
/* Skip right-click on Windows */
if (event.button) {
return;
}
this.clickHandler(event.target, 1, valueText);
}, false);
valueElement.className += ' zotero-clicky';
}
var firstSpace;
if (typeof valueText == 'string') {
firstSpace = valueText.indexOf(" ");
}
// 29 == arbitrary length at which to chop uninterrupted text
if ((firstSpace == -1 && valueText.length > 29) || firstSpace > 29) {
valueElement.setAttribute('crop', 'end');
valueElement.setAttribute('value', valueText);
}
else {
// Wrap to multiple lines
valueElement.appendChild(document.createTextNode(valueText));
}
// Tag color
var colorData = this._tagColors.get(valueText);
if (colorData) {
valueElement.style.color = colorData.color;
valueElement.style.fontWeight = 'bold';
}
return valueElement;
}
showEditor(elem, rows, value) {
// Blur any active fields
/*
if (this._dynamicFields) {
this._dynamicFields.focus();
}
*/
Zotero.debug('Showing editor');
var fieldName = 'tag';
var tabindex = elem.getAttribute('ztabindex');
var itemID = this._item.id;
var t = document.createElement(rows > 1 ? 'textarea' : 'input', { is: 'shadow-autocomplete-input' });
t.setAttribute('class', 'editable');
t.setAttribute('value', value);
t.setAttribute('fieldname', fieldName);
t.setAttribute('ztabindex', tabindex);
t.setAttribute('ignoreblurwhilesearching', 'true');
t.setAttribute('autocompletepopup', 'PopupAutoComplete');
// Multi-line
if (rows > 1) {
t.setAttribute('rows', rows);
}
// Add auto-complete
else {
t.setAttribute('type', 'autocomplete');
t.setAttribute('autocompletesearch', 'zotero');
let params = {
fieldName: fieldName,
libraryID: this.item.libraryID
};
params.itemID = itemID ? itemID : '';
t.setAttribute(
'autocompletesearchparam', JSON.stringify(params)
);
t.setAttribute('completeselectedindex', true);
}
var box = elem.parentNode;
box.replaceChild(t, elem);
t.addEventListener('blur', this.blurHandler);
t.addEventListener('keydown', this.handleKeyDown);
t.addEventListener('paste', this.handlePaste);
this._tabDirection = false;
this._lastTabIndex = tabindex;
// Prevent error when clicking between a changed field
// and another -- there's probably a better way
if (!t.select) {
return;
}
t.select();
return t;
}
handleKeyDown = async (event) => {
var target = event.target;
var focused = document.activeElement;
switch (event.keyCode) {
case event.DOM_VK_RETURN:
var multiline = target.parentNode.classList.contains('multiline');
var empty = target.value == "";
if (event.shiftKey) {
if (!multiline) {
var self = this;
setTimeout(function () {
var val = target.value;
if (val !== "") {
val += "\n";
}
self.makeMultiline(target, val, 6);
}, 0);
return false;
}
// Submit
}
else if (multiline) {
return true;
}
var row = target.closest('li');
let blurOnly = false;
// If non-empty last row, only blur, because the open textbox will
// be cleared in hideEditor() and remain in place
if (row == row.parentNode.lastChild && !empty) {
blurOnly = true;
}
// If empty non-last row, refocus current row
else if (row != row.parentNode.lastChild && empty) {
var focusField = true;
}
// If non-empty non-last row, return focus to items pane
else {
var focusField = false;
this._lastTabIndex = false;
}
await this.blurHandler(event);
if (blurOnly) {
return false;
}
if (focusField) {
this._focusField();
}
// Return focus to items pane
else {
var tree = document.getElementById('zotero-items-tree');
if (tree) {
tree.focus();
}
}
return false;
case event.DOM_VK_ESCAPE:
// Reset field to original value
target.value = target.getAttribute('value');
var tagsbox = focused.closest('.editable');
this._lastTabIndex = false;
await this.blurHandler(event);
if (tagsbox) {
tagsbox.closePopup();
}
// TODO: Return focus to items pane
var tree = document.getElementById('zotero-items-tree');
if (tree) {
tree.focus();
}
return false;
case event.DOM_VK_TAB:
// If already an empty last row, ignore forward tab
if (target.value == "" && !event.shiftKey) {
var row = Zotero.getAncestorByTagName(target, 'li');
if (row == row.parentNode.lastChild) {
return false;
}
}
this._tabDirection = event.shiftKey ? -1 : 1;
await this.blurHandler(event);
this._focusField();
return false;
}
return true;
};
// Intercept paste, check for newlines, and convert textbox
// to multiline if necessary
handlePaste = (event) => {
var textbox = event.target;
var str = event.clipboardData.getData('text');
var multiline = !!str.trim().match(/\n/);
if (multiline) {
setTimeout(() => {
this.makeMultiline(textbox, str.trim());
});
event.preventDefault();
}
};
makeMultiline(textbox, value, rows) {
textbox.parentNode.classList.add('multiline');
// If rows not specified, use one more than lines in input
if (!rows) {
rows = value.match(/\n/g).length + 1;
}
textbox = this.showEditor(textbox, rows, textbox.getAttribute('value'));
textbox.value = value;
// Move cursor to end
textbox.selectionStart = value.length;
}
hideEditor = async (event) => {
var textbox = event.target;
Zotero.debug('Hiding editor');
var oldValue = textbox.getAttribute('value');
var value = textbox.value = textbox.value.trim();
var tagsbox = textbox.closest('.editable');
if (!tagsbox) {
Zotero.debug('Tagsbox not found', 1);
return;
}
var row = textbox.parentNode;
var isNew = row.getAttribute('isNew');
// Remove empty row at end
if (isNew && value === "") {
row.parentNode.removeChild(row);
return;
}
// If row hasn't changed, change back to label
if (oldValue == value) {
this.textboxToLabel(textbox);
return;
}
var tags = value.split(/\r\n?|\n/).map(val => val.trim()).filter(x => x);
// Modifying existing tag with a single new one
if (!isNew && tags.length < 2) {
if (value !== "") {
if (oldValue !== value) {
// The existing textbox will be removed in notify()
this.removeRow(row);
this.add(value);
if (event.type != 'blur') {
this._focusField();
}
try {
this.item.replaceTag(oldValue, value);
await this.item.saveTx();
}
catch (e) {
this.reload();
throw e;
}
}
}
// Existing tag cleared
else {
try {
this.removeRow(row);
if (event.type != 'blur') {
this._focusField();
}
this.item.removeTag(oldValue);
await this.item.saveTx();
}
catch (e) {
this.reload();
throw e;
}
}
}
// Multiple tags
else if (tags.length > 1) {
var lastTag = row == row.parentNode.lastChild;
if (!isNew) {
// If old tag isn't in array, remove it
if (tags.indexOf(oldValue) == -1) {
this.item.removeTag(oldValue);
}
// If old tag is staying, restore the textbox
// immediately. This isn't strictly necessary, but it
// makes the transition nicer.
else {
textbox.value = textbox.getAttribute('value');
this.textboxToLabel(textbox);
}
}
tags.forEach(tag => this.item.addTag(tag));
await this.item.saveTx();
if (lastTag) {
this._lastTabIndex = this.item.getTags().length;
}
this.reload();
}
// Single tag at end
else {
if (event.type == 'blur') {
this.removeRow(row);
}
else {
textbox.value = '';
}
this.add(value);
this.item.addTag(value);
try {
await this.item.saveTx();
}
catch (e) {
this.reload();
throw e;
}
}
};
newTag() {
var rowsElement = this._id('rows');
var rows = rowsElement.childNodes;
// Don't add new row if there already is one
if (rows.length && rows[rows.length - 1].querySelector('.editable')) {
return;
}
var row = this.addDynamicRow();
// It needs relatively high delay to make focus-on-click work
setTimeout(() => {
row.firstChild.nextSibling.click();
}, 50);
return row;
}
textboxToLabel(textbox) {
var elem = this.createValueElement(
textbox.value, textbox.getAttribute('ztabindex')
);
var row = textbox.parentNode;
row.replaceChild(elem, textbox);
}
add(tagName, tagType) {
var rowsElement = this._id('rows');
var rows = rowsElement.childNodes;
// Get this tag's existing row, if there is one
var row = false;
for (let i = 0; i < rows.length; i++) {
if (rows[i].getAttribute('tagName') === tagName) {
return rows[i].getAttribute('ztabindex');
}
}
var tagData = {
tag: tagName,
type: tagType
};
if (row) {
// Update row and label
this.updateRow(row, tagData);
var elem = this.createValueElement(tagName);
// Remove the old row, which we'll reinsert at the correct place
rowsElement.removeChild(row);
// Find the current label or textbox within the row
// and replace it with the new element -- this is used
// both when creating new rows and when hiding the
// entry textbox
var oldElem = row.getElementsByAttribute('fieldname', 'tag')[0];
row.replaceChild(elem, oldElem);
}
else {
// Create new row, but don't insert it
row = this.addDynamicRow(tagData, false, true);
var elem = row.getElementsByAttribute('fieldname', 'tag')[0];
}
// Move row to appropriate place, alphabetically
var collation = Zotero.getLocaleCollation();
var labels = rowsElement.getElementsByAttribute('fieldname', 'tag');
var inserted = false;
var newTabIndex = false;
for (var i = 0; i < labels.length; i++) {
let index = i + 1;
if (inserted) {
labels[i].setAttribute('ztabindex', index);
continue;
}
if (collation.compareString(1, tagName, labels[i].textContent) > 0
// Ignore textbox at end
&& labels[i].tagName != 'input') {
labels[i].setAttribute('ztabindex', index);
continue;
}
elem.setAttribute('ztabindex', index);
rowsElement.insertBefore(row, labels[i].parentNode);
newTabIndex = index;
inserted = true;
}
if (!inserted) {
newTabIndex = i + 1;
elem.setAttribute('ztabindex', newTabIndex);
rowsElement.appendChild(row);
}
this.updateCount(this.count + 1);
return newTabIndex;
}
remove(tagName) {
var rowsElement = this._id('rows');
var rows = rowsElement.childNodes;
var oldTabIndex = -1;
for (var i = 0; i < rows.length; i++) {
let value = rows[i].getAttribute('tagName');
if (value === tagName) {
oldTabIndex = this.removeRow(rows[i]);
break;
}
}
return oldTabIndex;
}
// Remove the row and update tab indexes
removeRow(row) {
var origTabIndex = row.getElementsByAttribute('fieldname', 'tag')[0].getAttribute('ztabindex');
var origRow = row;
var i = origTabIndex;
while (row = row.nextSibling) {
let elem = row.getElementsByAttribute('fieldname', 'tag')[0];
elem.setAttribute('ztabindex', i++);
}
origRow.parentNode.removeChild(origRow);
this.updateCount(this.count - 1);
return origTabIndex;
}
removeAll = () => {
if (Services.prompt.confirm(null, "", Zotero.getString('pane.item.tags.removeAll'))) {
this.item.setTags([]);
this.item.saveTx();
}
};
updateCount(count) {
if (!this.item) {
return;
}
if (typeof count == 'undefined') {
var tags = this.item.getTags();
if (tags) {
count = tags.length;
}
else {
count = 0;
}
}
this._id('count').replaceChildren(Zotero.getString('pane.item.tags.count', count, count));
this.count = count;
}
closePopup() {
if (this.parentNode.hidePopup) {
this.parentNode.hidePopup();
}
}
// Open the textbox for a particular label
//
// Note: We're basically replicating the built-in tabindex functionality,
// which doesn't work well with the weird label/textbox stuff we're doing.
// (The textbox being tabbed away from is deleted before the blur()
// completes, so it doesn't know where it's supposed to go next.)
_focusField() {
if (this._reloading) {
return;
}
if (this._lastTabIndex === false) {
return;
}
var maxIndex = this._id('rows').childNodes.length + 1;
var tabindex = parseInt(this._lastTabIndex);
var dir = this._tabDirection;
if (dir == 1) {
var nextIndex = tabindex + 1;
}
else if (dir == -1) {
if (tabindex == 1) {
// Focus Add button
this._id('add').focus();
return false;
}
var nextIndex = tabindex - 1;
}
else {
var nextIndex = tabindex;
}
nextIndex = Math.min(nextIndex, maxIndex);
Zotero.debug('Looking for tabindex ' + nextIndex, 4);
var next = this.shadowRoot.getElementsByAttribute('ztabindex', nextIndex);
if (next.length) {
next = next[0];
next.click();
}
else {
next = this.newTag();
next = next.firstChild.nextSibling;
}
if (!next) {
Components.utils.reportError('Next row not found');
return;
}
next.scrollIntoView();
}
_handleAddButtonKeyDown = (event) => {
if (event.keyCode != event.DOM_VK_TAB || event.shiftKey) {
return;
}
this._lastTabIndex = 0;
this._tabDirection = 1;
this._focusField();
event.preventDefault();
};
_handleAddButtonClick = async (event) => {
await this.blurOpenField();
this.newTag();
};
addNew() {
this._handleAddButtonClick();
}
async blurOpenField(stayOpen) {
this._lastTabIndex = false;
var textboxe = this.shadowRoot.querySelector('.editable');
if (textboxe) {
await this.blurHandler({
target: textboxe,
// If coming from the Add button, pretend user pressed return
type: stayOpen ? 'keypress' : 'blur',
// DOM_VK_RETURN
keyCode: stayOpen ? 13 : undefined
});
}
}
_id(id) {
return this.shadowRoot.querySelector(`[id=${id}]`);
}
}
customElements.define("tags-box", TagsBox);
}

View file

@ -44,11 +44,8 @@ var ZoteroItemPane = new function() {
return; return;
} }
// Fake a ref
_tagsBox = {
current: null
};
_notesBox = document.getElementById('zotero-editpane-notes'); _notesBox = document.getElementById('zotero-editpane-notes');
_tagsBox = document.getElementById('zotero-editpane-tags');
_relatedBox = document.getElementById('zotero-editpane-related'); _relatedBox = document.getElementById('zotero-editpane-related');
this._unregisterID = Zotero.Notifier.registerObserver(this, ['item'], 'itemPane'); this._unregisterID = Zotero.Notifier.registerObserver(this, ['item'], 'itemPane');
@ -86,6 +83,10 @@ var ZoteroItemPane = new function() {
box.parentItem = item; box.parentItem = item;
break; break;
case 2:
var box = _tagsBox;
break;
case 3: case 3:
var box = _relatedBox; var box = _relatedBox;
break; break;
@ -101,11 +102,6 @@ var ZoteroItemPane = new function() {
// DEBUG: Currently broken // DEBUG: Currently broken
//box.scrollToTop(); //box.scrollToTop();
break; break;
case 2:
// TEMP
//_tagsBox.current.blurOpenField();
break;
} }
} }
@ -136,19 +132,6 @@ var ZoteroItemPane = new function() {
this.setTranslateButton(); this.setTranslateButton();
} }
} }
else if (index == 2) {
ReactDOM.render(
<TagsBoxContainer
key={"tagsBox-" + item.id}
item={item}
editable={mode != 'view'}
ref={_tagsBox}
onResetSelection={focusItemsList}
/>,
document.getElementById('tags-box-container'),
() => ZoteroPane.updateTagsBoxSize()
);
}
if (box) { if (box) {
if (mode) { if (mode) {
@ -186,7 +169,7 @@ var ZoteroItemPane = new function() {
break; break;
case 2: case 2:
var box = _tagsBox.current; var box = _tagsBox;
if (box) { if (box) {
box.blurOpenField(); box.blurOpenField();
} }

View file

@ -3,7 +3,10 @@
<?xml-stylesheet href="chrome://zotero/skin/zotero.css" type="text/css"?> <?xml-stylesheet href="chrome://zotero/skin/zotero.css" type="text/css"?>
<?xml-stylesheet href="chrome://zotero/skin/overlay.css" type="text/css"?> <?xml-stylesheet href="chrome://zotero/skin/overlay.css" type="text/css"?>
<!DOCTYPE window SYSTEM "chrome://zotero/locale/zotero.dtd"> <!DOCTYPE window [
<!ENTITY % globalDTD SYSTEM "chrome://global/locale/global.dtd"> %globalDTD;
<!ENTITY % zoteroDTD SYSTEM "chrome://zotero/locale/zotero.dtd"> %zoteroDTD;
]>
<window <window
id="zotero-note-window" id="zotero-note-window"
@ -20,12 +23,11 @@
Services.scriptloader.loadSubScript("chrome://zotero/content/include.js", this); Services.scriptloader.loadSubScript("chrome://zotero/content/include.js", this);
Services.scriptloader.loadSubScript("resource://zotero/require.js", this); Services.scriptloader.loadSubScript("resource://zotero/require.js", this);
Services.scriptloader.loadSubScript("chrome://zotero/content/elements/shadowAutocompleteInput.js", this);
Services.scriptloader.loadSubScript("chrome://zotero/content/elements/noteEditor.js", this); Services.scriptloader.loadSubScript("chrome://zotero/content/elements/noteEditor.js", this);
Services.scriptloader.loadSubScript("chrome://zotero/content/elements/relatedBox.js", this); Services.scriptloader.loadSubScript("chrome://zotero/content/elements/relatedBox.js", this);
Services.scriptloader.loadSubScript("chrome://zotero/content/elements/tagsBox.js", this);
Services.scriptloader.loadSubScript("chrome://zotero/content/note.js", this); Services.scriptloader.loadSubScript("chrome://zotero/content/note.js", this);
var React = require('react');
var ReactDOM = require('react-dom');
</script> </script>
<keyset> <keyset>

View file

@ -445,10 +445,16 @@ class ReaderInstance {
_openTagsPopup(item, selector) { _openTagsPopup(item, selector) {
let menupopup = this._window.document.createXULElement('menupopup'); let menupopup = this._window.document.createXULElement('menupopup');
menupopup.addEventListener('popuphidden', function (event) {
if (event.target === menupopup) {
menupopup.remove();
}
});
menupopup.className = 'tags-popup'; menupopup.className = 'tags-popup';
menupopup.style.font = 'inherit';
menupopup.style.minWidth = '300px'; menupopup.style.minWidth = '300px';
menupopup.setAttribute('ignorekeys', true); menupopup.setAttribute('ignorekeys', true);
let tagsbox = this._window.document.createXULElement('tagsbox'); let tagsbox = new (this._window.customElements.get('tags-box'));
menupopup.appendChild(tagsbox); menupopup.appendChild(tagsbox);
tagsbox.setAttribute('flex', '1'); tagsbox.setAttribute('flex', '1');
this._popupset.appendChild(menupopup); this._popupset.appendChild(menupopup);

View file

@ -80,6 +80,7 @@
Services.scriptloader.loadSubScript("chrome://zotero/content/elements/itemBox.js", this); Services.scriptloader.loadSubScript("chrome://zotero/content/elements/itemBox.js", this);
Services.scriptloader.loadSubScript("chrome://zotero/content/elements/noteEditor.js", this); Services.scriptloader.loadSubScript("chrome://zotero/content/elements/noteEditor.js", this);
Services.scriptloader.loadSubScript("chrome://zotero/content/elements/notesBox.js", this); Services.scriptloader.loadSubScript("chrome://zotero/content/elements/notesBox.js", this);
Services.scriptloader.loadSubScript("chrome://zotero/content/elements/tagsBox.js", this);
Services.scriptloader.loadSubScript("chrome://zotero/content/elements/relatedBox.js", this); Services.scriptloader.loadSubScript("chrome://zotero/content/elements/relatedBox.js", this);
Services.scriptloader.loadSubScript("chrome://zotero/content/elements/attachmentBox.js", this); Services.scriptloader.loadSubScript("chrome://zotero/content/elements/attachmentBox.js", this);
@ -1123,8 +1124,8 @@
<notes-box id="zotero-editpane-notes" class="zotero-editpane-notes" flex="1"/> <notes-box id="zotero-editpane-notes" class="zotero-editpane-notes" flex="1"/>
</tabpanel> </tabpanel>
<tabpanel id="tags-pane" class="tags-pane" orient="vertical" context="tags-context-menu"> <tabpanel>
<html:div id="tags-box-container" class="tags-box-container"></html:div> <tags-box id="zotero-editpane-tags" class="zotero-editpane-tags" flex="1"/>
</tabpanel> </tabpanel>
<tabpanel> <tabpanel>
@ -1296,13 +1297,4 @@
this.setAttribute('checked', false); this.setAttribute('checked', false);
event.stopPropagation();"/> event.stopPropagation();"/>
</menupopup> </menupopup>
<!-- Tags Box -->
<popupset>
<menupopup id="tags-context-menu"
onpopupshowing="return ZoteroItemPane.onTagsContextPopupShowing()">
<menuitem id="remove-all-item-tags" label="&zotero.item.tags.removeAll;"
oncommand="ZoteroItemPane.removeAllTags()"/>
</menupopup>
</popupset>
</window> </window>

View file

@ -421,9 +421,7 @@ pane.item.attachments.PDF.installTools.title = PDF Tools Not Installed
pane.item.attachments.PDF.installTools.text = To use this feature, you must first install the PDF tools in the Search pane of the Zotero preferences. pane.item.attachments.PDF.installTools.text = To use this feature, you must first install the PDF tools in the Search pane of the Zotero preferences.
pane.item.attachments.filename = Filename pane.item.attachments.filename = Filename
pane.item.noteEditor.clickHere = click here pane.item.noteEditor.clickHere = click here
pane.item.tags.count.zero = %S tags: pane.item.tags.count = %1$S tag;%1$S tags
pane.item.tags.count.singular = %S tag:
pane.item.tags.count.plural = %S tags:
pane.item.tags.icon.user = User-added tag pane.item.tags.icon.user = User-added tag
pane.item.tags.icon.automatic = Automatically added tag pane.item.tags.icon.automatic = Automatically added tag
pane.item.tags.removeAll = Remove all tags from this item? pane.item.tags.removeAll = Remove all tags from this item?

2
scss/_tagsBox.scss Normal file
View file

@ -0,0 +1,2 @@
@import "components/tagsBox";
@import "components/clicky";

View file

@ -24,3 +24,7 @@
} }
} }
} }
#related-popup, #tags-popup {
font: inherit;
}

View file

@ -1,22 +1,4 @@
.tags-pane { .tags-box-header {
display: flex;
}
.tags-box-container {
flex-grow: 1;
display: flex;
}
.tags-box {
$item-pane-width: 330px;
$icon-width: 16px;
$delete-button-width: 20px;
$li-side-margin: 6px;
flex-grow: 1;
//width: 330px;
.tags-box-header {
display: flex; display: flex;
padding-left: 10px; padding-left: 10px;
align-items: center; align-items: center;
@ -30,76 +12,61 @@
text-shadow: none; text-shadow: none;
font-size: inherit; font-size: inherit;
} }
} }
.tags-box-count { .tags-box-list {
margin-right: 5px;
}
ul.tags-box-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 2px 0 0; // Leave space for textbox border on top tag padding: 2px 0 0; // Leave space for textbox border on top tag
}
ul.tags-box-list > li { li {
display: flex; display: flex;
margin: 3px $li-side-margin; margin: 3px 0;
margin-inline-start: 6px;
align-items: center; align-items: center;
height: 1.5em; height: 1.5em;
// Shift-Enter
&.multiline {
align-items: start;
height: 9em;
textarea.editable {
resize: none;
}
}
&:not(.multiline) .editable {
padding: 0 1px;
}
.zotero-box-icon {
width: 16px;
height: 16px;
}
.zotero-box-label {
flex-grow: 1;
white-space: nowrap;
overflow-x: hidden;
text-overflow: ellipsis;
width: 0;
}
.editable {
font-family: inherit;
font-size: inherit;
flex-grow: 1;
margin: 0 2px;
width: 0;
}
button { button {
border: 0; border: 0;
background: none; background: none;
padding: 0; padding: 0;
width: $delete-button-width; width: 20px;
height: 18px; height: 18px;
} }
} }
.editable-container {
flex-grow: 1;
margin: 0 2px;
// width: $item-pane-width - $icon-width - $delete-button-width - ($li-side-margin * 2);
// This container shouldn't force any width for its parent,
// because tagsBox is used in more places than just item pane,
// and it can have smaller width than $item-pane-width
width: 0;
}
ul.tags-box-list > li:not(.multiline) .editable-container {
padding: 0 1px;
}
// Shift-Enter
ul.tags-box-list > li.multiline {
align-items: start;
height: 9em;
.editable-container {
align-self: stretch;
display: flex;
}
.editable, .input-group {
flex-grow: 1;
display: flex;
align-self: stretch;
}
textarea.editable-control {
flex: 1;
resize: none;
}
}
input.editable-control {
width: 100px; // Dummy value that somehow prevents field from going off screen at large font size
font-family: inherit;
}
textarea.editable-control {
width: 100%; // DEBUG: This still runs off the screen at large font size, though it keeps the delete button visible
}
} }

View file

@ -1,3 +1,3 @@
.tags-box li img { .tags-box-list li img {
margin-right: 1px; margin-right: 1px;
} }

1
scss/tagsBox-mac.scss Normal file
View file

@ -0,0 +1 @@
@import "tagsBox";

1
scss/tagsBox-unix.scss Normal file
View file

@ -0,0 +1 @@
@import "tagsBox";

1
scss/tagsBox-win.scss Normal file
View file

@ -0,0 +1 @@
@import "tagsBox";