diff --git a/.babelrc b/.babelrc index c11d720d86..2965585ef6 100644 --- a/.babelrc +++ b/.babelrc @@ -18,6 +18,7 @@ "syntax-jsx", "transform-react-jsx", "transform-react-display-name", + "transform-class-properties", [ "transform-es2015-modules-commonjs", { diff --git a/chrome/content/zotero/bindings/tagselector.xml b/chrome/content/zotero/bindings/tagselector.xml deleted file mode 100644 index 9db67dc840..0000000000 --- a/chrome/content/zotero/bindings/tagselector.xml +++ /dev/null @@ -1,1213 +0,0 @@ - - - - - - - - - - - - - - - - - - - - false - false - null - - null - null - - - "view" - - - - - - - - - - - - - - - - - - - - - null - - - - - - - - - false - null - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Zotero.Promise.check(this.mode)); - - let { div, emptyRegular } = this.createTagsList(this._tags); - - this._emptyRegular = emptyRegular; - tagsBox.innerHTML = ""; - tagsBox.appendChild(div); - this._dirty = false; - this._tagsDiv = null; - } - // Otherwise just update based on visibility - else { - // If only a few tags, regenerate buttons from scratch - if (this.filterToScope && this._scope.size <= 100) { - // If full set is currently displayed, store it for later - if (!this._tagsDiv) { - this._tagsDiv = tagsBox.firstChild; - } - - let tags = []; - this._scope.forEach(function (types, name) { - tags.push(...types.map(type => { - return { - tag: name, - type - }; - })); - }); - let { div, emptyRegular } = this.createTagsList(tags); - tagsBox.replaceChild(div, tagsBox.firstChild); - this._emptyRegular = emptyRegular; - } - // Otherwise swap in the stored buttons from the last full run, - // after updating their visibility - else { - let oldDiv = tagsBox.removeChild(tagsBox.firstChild); - if (!this._tagsDiv) { - this._tagsDiv = oldDiv; - } - - let elems = this._tagsDiv.childNodes; - let tagColors = Zotero.Tags.getColors(this.libraryID); - for (let i = 0; i < elems.length; i++) { - let elem = elems[i]; - let visible = this._updateClickableTag( - elem, elem.textContent, tagColors - ); - if (visible) { - this._emptyRegular = false; - } - } - tagsBox.appendChild(this._tagsDiv); - } - } - - //start tag cloud code - - var tagCloud = Zotero.Prefs.get('tagCloud'); - if (false && tagCloud) { - var labels = tagsBox.getElementsByTagName('label'); - - //loop through displayed labels and find number of linked items - var numlinked= []; - for (var i=0; i - - - - - - tag.tag)); - [...new Set(tagColors.keys())].filter(x => !regularTags.has(x)).forEach((x) => { - tags.push(Zotero.Tags.cleanData({ tag: x })); - }); - - // Sort by name - var t = new Date(); - Zotero.debug("Sorting tags"); - var collation = Zotero.getLocaleCollation(); - tags.sort(function (a, b) { - return collation.compareString(1, a.tag, b.tag); - }); - Zotero.debug(`Sorted tags in ${new Date() - t} ms`); - - var div = document.createElementNS('http://www.w3.org/1999/xhtml', 'div'); - var emptyRegular = true; - var lastTag; - for (let i = 0; i < tags.length; i++) { - let tagData = tags[i]; - - // Only show tags of different types once - if (tagData.tag === lastTag) { - continue; - } - lastTag = tagData.tag; - - let elem = this._insertClickableTag(div, tagData); - let visible = this._updateClickableTag( - elem, tagData.tag, tagColors - ); - if (visible) { - emptyRegular = false; - } - } - return { div, emptyRegular }; - ]]> - - - - - - - - - - - - - - - - - - - - - - - - - - - - val.split("/")[1] == 'tagColors')) { - yield this.refresh(true); - } - return; - } - - // Ignore anything other than deletes in duplicates view - if (this.collectionTreeRow.isDuplicates()) { - switch (event) { - case 'delete': - case 'trash': - break; - - default: - return; - } - } - - // Ignore item events other than 'trash' - if (type == 'item' && event != 'trash') { - return; - } - - var selectionChanged = false; - - // If a selected tag no longer exists, deselect it - if (event == 'delete' || event == 'trash' || event == 'modify') { - // TODO: necessary, or just use notifier value? - this._tags = yield Zotero.Tags.getAll(this.libraryID, this._types); - - for (let tag of this.selection) { - for (let tag2 of this._tags) { - if (tag == tag2) { - var found = true; - break; - } - } - if (!found) { - this.selection.delete(tag); - selectionChanged = true; - } - } - } - - if (event == 'add') { - if (type == 'item-tag') { - let tagObjs = ids - // Get tag name and type - .map(x => extraData[x]) - // Ignore tag adds for items not in the current library, if there is one - .filter(function (x) { - if (!this._libraryID) return true; - return x.libraryID == this._libraryID; - }.bind(this)); - - if (tagObjs.length) { - this.insertSorted(this.id('tags-box').firstChild, tagObjs); - // If full set isn't currently displayed, update it too - if (this._tagsDiv) { - this.insertSorted(this._tagsDiv, tagObjs); - } - } - } - // Don't add anything for item or collection-item; just update scope - - return this.updateScope(); - } - - var t = this.id('tags-search').inputField; - if (t.value) { - this.setSearch(t.value, true); - } - else { - this.setSearch(false, true); - } - this._dirty = true; - - // This is a hack, but set this to run after the refresh, - // since _emptyRegular isn't set until then - this.onRefresh = function () { - // If no regular tags visible after a delete, deselect all. - // This is necessary so that a selected tag that's removed - // from its last item doesn't cause all regular tags to - // disappear without anything being visibly selected. - if ((event == 'remove' || event == 'delete') && - this._emptyRegular && this.getNumSelected()) { - Zotero.debug('No tags visible after delete -- deselecting all'); - return this.clearAll(); - } - }.bind(this); - - // If the selection changed, update the items list - if (selectionChanged && this.onchange) { - return this.onchange(); - } - - // Otherwise, just update the tag selector - return this.updateScope(); - }, this); - ]]> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { - Zotero.updateZoteroPaneProgressMeter( - Math.round(progress / progressMax * 100) - ); - } - ); - } - finally { - Zotero.hideZoteroPaneOverlays(); - } - } - }.bind(this))(); - ]]> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - = Zotero.Tags.MAX_COLORED_TAGS && !tagColors.has(io.name)) { - var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"] - .getService(Components.interfaces.nsIPromptService); - ps.alert(null, "", Zotero.getString('pane.tagSelector.maxColoredTags', Zotero.Tags.MAX_COLORED_TAGS)); - return; - } - - io.tagColors = tagColors; - - window.openDialog( - 'chrome://zotero/content/tagColorChooser.xul', - "zotero-tagSelector-colorChooser", - "chrome,modal,centerscreen", io - ); - - // Dialog cancel - if (typeof io.color == 'undefined') { - return; - } - - yield Zotero.Tags.setColor(this.libraryID, io.name, io.color, io.position); - }.bind(this)); - ]]> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/chrome/content/zotero/components/button.jsx b/chrome/content/zotero/components/button.jsx new file mode 100644 index 0000000000..93af1b1dd1 --- /dev/null +++ b/chrome/content/zotero/components/button.jsx @@ -0,0 +1,131 @@ +'use strict' + +const React = require('react') +const { PureComponent, createElement: create } = React +const { injectIntl, intlShape } = require('react-intl') +const { IconDownChevron } = require('./icons') +const cx = require('classnames') +const { + bool, element, func, node, number, oneOf, string +} = require('prop-types') + + +const ButtonGroup = ({ children }) => ( +
{children}
+) + +ButtonGroup.propTypes = { + children: node +} + +class Button extends PureComponent { + componentDidMount() { + if (!Zotero.isNode && this.title) { + // Workaround for XUL tooltips + this.container.setAttribute('tooltiptext', this.title); + } + } + + get classes() { + return ['btn', this.props.className, `btn-${this.props.size}`, { + 'btn-icon': this.props.icon != null, + 'active': this.props.isActive, + 'btn-flat': this.props.isFlat, + 'btn-menu': this.props.isMenu, + 'disabled': this.props.isDisabled, + }] + } + + get node() { + return 'button' + } + + get text() { + const { intl, text } = this.props + + return text ? + intl.formatMessage({ id: text }) : + null + } + + get title() { + const { intl, title } = this.props + + return title ? + intl.formatMessage({ id: title }) : + null + } + + get menuMarker() { + if (!Zotero.isNode && Zotero.isLinux) { + return this.props.isMenu && + } + return this.props.isMenu && + } + + get attributes() { + const attr = { + className: cx(...this.classes), + disabled: !this.props.noFocus && this.props.isDisabled, + onBlur: this.handleBlur, + onFocus: this.props.onFocus, + ref: this.setContainer, + title: this.title + } + + if (!this.props.isDisabled) { + attr.onMouseDown = this.handleMouseDown + attr.onClick = this.handleClick + } + + return attr + } + + setContainer = (container) => { + this.container = container + } + + handleClick = (event) => { + event.preventDefault() + + if (!this.props.isDisabled && this.props.onClick) { + this.props.onClick(event) + } + } + + handleMouseDown = (event) => { + event.preventDefault() + + if (!this.props.isDisabled && this.props.onMouseDown) { + this.props.onMouseDown(event) + } + } + + render() { + return create(this.node, this.attributes, this.props.icon, this.text, this.menuMarker) + } + + static propTypes = { + className: string, + icon: element, + intl: intlShape.isRequired, + isActive: bool, + isDisabled: bool, + isMenu: bool, + size: oneOf(['sm', 'md', 'lg']), + title: string, + text: string, + onClick: func, + onMouseDown: func + } + + static defaultProps = { + size: 'md' + } +} + + +module.exports = { + ButtonGroup, + Button: injectIntl(Button) +} diff --git a/chrome/content/zotero/components/form/input.jsx b/chrome/content/zotero/components/form/input.jsx new file mode 100644 index 0000000000..d70b131b56 --- /dev/null +++ b/chrome/content/zotero/components/form/input.jsx @@ -0,0 +1,155 @@ +/* eslint-disable react/no-deprecated */ +'use strict'; + +const React = require('react'); +const PropTypes = require('prop-types'); +const cx = require('classnames'); +const { noop } = () => {}; + +class Input extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + value: props.value + }; + } + + cancel(event = null) { + this.props.onCancel && this.props.onCancel(this.hasChanged, event); + this.hasBeenCancelled = true; + this.input.blur(); + } + + commit(event = null) { + this.props.onCommit && this.props.onCommit(this.state.value, this.hasChanged, event); + this.hasBeenCommitted = true; + } + + focus() { + if(this.input != null) { + this.input.focus(); + this.props.selectOnFocus && this.input.select(); + } + } + + componentWillReceiveProps({ value }) { + if (value !== this.props.value) { + this.setState({ value }); + } + } + + handleChange({ target }) { + this.setState({ value: target.value }); + this.props.onChange && this.props.onChange(target.value); + } + + handleBlur(event) { + const shouldCancel = this.props.onBlur && this.props.onBlur(event); + if (this.hasBeenCancelled || this.hasBeenCommitted) { return; } + shouldCancel ? this.cancel(event) : this.commit(event); + } + + handleFocus(event) { + this.props.selectOnFocus && event.target.select(); + this.props.onFocus && this.props.onFocus(event); + } + + handleKeyDown(event) { + switch (event.key) { + case 'Escape': + this.cancel(event); + break; + case 'Enter': + this.commit(event); + break; + default: + return; + } + } + + get hasChanged() { + return this.state.value !== this.props.value; + } + + render() { + this.hasBeenCancelled = false; + this.hasBeenCommitted = false; + const extraProps = Object.keys(this.props).reduce((aggr, key) => { + if(key.match(/^(aria-|data-).*/)) { + aggr[key] = this.props[key]; + } + return aggr; + }, {}); + const input = this.input = input } + required={ this.props.isRequired } + size={ this.props.size } + spellCheck={ this.props.spellCheck } + step={ this.props.step } + tabIndex={ this.props.tabIndex } + type={ this.props.type } + value={ this.state.value } + { ...extraProps } + />; + return input; + } + + static defaultProps = { + className: 'form-control', + onBlur: noop, + onCancel: noop, + onChange: noop, + onCommit: noop, + onFocus: noop, + tabIndex: -1, + type: 'text', + value: '', + }; + + static propTypes = { + autoFocus: PropTypes.bool, + className: PropTypes.string, + form: PropTypes.string, + id: PropTypes.string, + inputMode: PropTypes.string, + isDisabled: PropTypes.bool, + isReadOnly: PropTypes.bool, + isRequired: PropTypes.bool, + max: PropTypes.number, + maxLength: PropTypes.number, + min: PropTypes.number, + minLength: PropTypes.number, + name: PropTypes.string, + onBlur: PropTypes.func, + onCancel: PropTypes.func, + onChange: PropTypes.func, + onCommit: PropTypes.func, + onFocus: PropTypes.func, + placeholder: PropTypes.string, + selectOnFocus: PropTypes.bool, + spellCheck: PropTypes.bool, + step: PropTypes.number, + tabIndex: PropTypes.number, + type: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + }; +} + +module.exports = Input; diff --git a/chrome/content/zotero/components/icons.jsx b/chrome/content/zotero/components/icons.jsx new file mode 100644 index 0000000000..5a6bc2f31e --- /dev/null +++ b/chrome/content/zotero/components/icons.jsx @@ -0,0 +1,57 @@ +'use strict'; + +const React = require('react') +const { PureComponent } = React +const { element, string } = require('prop-types') +const cx = require('classnames') + +const Icon = ({ children, className, name }) => ( + + {children} + +) + +Icon.propTypes = { + children: element.isRequired, + className: string, + name: string.isRequired +} + +module.exports = { Icon } + + +function i(name, svgOrSrc, hasDPI=true) { + const icon = class extends PureComponent { + render() { + const { className } = this.props + + if (typeof svgOrSrc == 'string') { + if (hasDPI && window.devicePixelRatio >= 1.25) { + let parts = svgOrSrc.split('.'); + parts[parts.length-2] = parts[parts.length-2] + '@2x'; + svgOrSrc = parts.join('.') + } + return + } + + return ( + {svgOrImg} + ) + } + } + + icon.propTypes = { + className: string + } + + icon.displayName = `Icon${name}` + + module.exports[icon.displayName] = icon +} + +/* eslint-disable max-len */ + + +i('TagSelectorMenu', "chrome://zotero/skin/tag-selector-menu.png") +i('DownChevron', "chrome://zotero/skin/searchbar-dropmarker.png") + diff --git a/chrome/content/zotero/components/tag-selector.jsx b/chrome/content/zotero/components/tag-selector.jsx new file mode 100644 index 0000000000..4d2bd11f74 --- /dev/null +++ b/chrome/content/zotero/components/tag-selector.jsx @@ -0,0 +1,68 @@ +'use strict'; + +const React = require('react'); +const PropTypes = require('prop-types'); +const TagList = require('./tag-selector/tag-list'); +const Input = require('./form/input'); +const { Button } = require('./button'); +const { IconTagSelectorMenu } = require('./icons'); + +class TagSelector extends React.Component { + render() { + return ( +
+ +
+ this.focusTextbox = ref && ref.focus} + value={this.props.searchString} + onChange={this.props.onSearch} + className="tag-selector-filter" + size="1" + /> +
+
+ ); + } +} + +TagSelector.propTypes = { + tags: PropTypes.arrayOf(PropTypes.shape({ + name: PropTypes.string, + selected: PropTypes.bool, + color: PropTypes.string, + disabled: PropTypes.bool + })), + dragObserver: PropTypes.shape({ + onDragOver: PropTypes.func, + onDragExit: PropTypes.func, + onDrop: PropTypes.func + }), + searchString: PropTypes.string, + shouldFocus: PropTypes.bool, + onSelect: PropTypes.func, + onTagContext: PropTypes.func, + onSearch: PropTypes.func, + onSettings: PropTypes.func, + loaded: PropTypes.bool, +}; + +TagSelector.defaultProps = { + tags: [], + searchString: '', + shouldFocus: false, + onSelect: () => Promise.resolve(), + onTagContext: () => Promise.resolve(), + onSearch: () => Promise.resolve(), + onSettings: () => Promise.resolve() +}; + +module.exports = TagSelector; diff --git a/chrome/content/zotero/components/tag-selector/tag-list.jsx b/chrome/content/zotero/components/tag-selector/tag-list.jsx new file mode 100644 index 0000000000..ba140c85eb --- /dev/null +++ b/chrome/content/zotero/components/tag-selector/tag-list.jsx @@ -0,0 +1,77 @@ +const React = require('react'); +const { FormattedMessage } = require('react-intl'); +const PropTypes = require('prop-types'); +const cx = require('classnames'); + +class TagList extends React.PureComponent { + renderTag(index) { + const { tags } = this.props; + const tag = index < tags.length ? + tags[index] : { + tag: "", + }; + const { onDragOver, onDragExit, onDrop } = this.props.dragObserver; + + const className = cx('tag-selector-item', 'zotero-clicky', { + selected: tag.selected, + colored: tag.color, + disabled: tag.disabled + }); + + let props = { + className, + onClick: ev => !tag.disabled && this.props.onSelect(tag.name, ev), + onContextMenu: ev => this.props.onTagContext(tag, ev), + onDragOver, + onDragExit, + onDrop + }; + + if (tag.color) { + props['style'] = { + color: tag.color, + }; + } + + + return ( +
  • + {tag.name} +
  • + ); + } + + render() { + const totalTagCount = this.props.tags.length; + var tagList = ( + + ); + if (!this.props.loaded) { + tagList = ( +
    + +
    + ); + } else if (totalTagCount == 0) { + tagList = ( +
    + +
    + ); + } + return ( +
    { this.container = ref }}> + {tagList} +
    + ) + + } +} + +module.exports = TagList; diff --git a/chrome/content/zotero/containers/containers.js b/chrome/content/zotero/containers/containers.js new file mode 100644 index 0000000000..e0b8e39dd7 --- /dev/null +++ b/chrome/content/zotero/containers/containers.js @@ -0,0 +1,59 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2018 Center for History and New Media + George Mason University, Fairfax, Virginia, USA + http://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'; + +const { defineMessages } = require('react-intl'); + +ZoteroPane.Containers = { + async init() { + await this.initIntlStrings(); + }, + + loadPane() { + var tagSelector = document.getElementById('zotero-tag-selector'); + ZoteroPane.tagSelector = Zotero.TagSelector.init(tagSelector, { + onSelection: ZoteroPane.updateTagFilter.bind(ZoteroPane) + }); + }, + + async initIntlStrings() { + this.intlMessages = {}; + const intlFiles = ['zotero.dtd']; + for (let intlFile of intlFiles) { + let localeXML = await Zotero.File.getContentsFromURLAsync(`chrome://zotero/locale/${intlFile}`); + let regexp = / + + + + + + + + + + \ No newline at end of file diff --git a/chrome/content/zotero/containers/tagSelector.jsx b/chrome/content/zotero/containers/tagSelector.jsx new file mode 100644 index 0000000000..858f8ed1fb --- /dev/null +++ b/chrome/content/zotero/containers/tagSelector.jsx @@ -0,0 +1,412 @@ +/* global Zotero: false */ +'use strict'; + +(function() { + +const React = require('react'); +const ReactDOM = require('react-dom'); +const { IntlProvider } = require('react-intl'); +const TagSelector = require('components/tag-selector.js'); +const noop = Promise.resolve(); +const defaults = { + tagColors: new Map(), + tags: [], + showAutomatic: Zotero.Prefs.get('tagSelector.showAutomatic'), + searchString: '', + inScope: new Set(), + loaded: false +}; +const { Cc, Ci } = require('chrome'); + +Zotero.TagSelector = class TagSelectorContainer extends React.Component { + constructor(props) { + super(props); + this._notifierID = Zotero.Notifier.registerObserver( + this, + ['collection-item', 'item', 'item-tag', 'tag', 'setting'], + 'tagSelector' + ); + this.displayAllTags = Zotero.Prefs.get('tagSelector.displayAllTags'); + this.selectedTags = new Set(); + this.state = defaults; + } + + // Update trigger #1 (triggered by ZoteroPane) + async onItemViewChanged({collectionTreeRow, libraryID, tagsInScope}) { + this.collectionTreeRow = collectionTreeRow || this.collectionTreeRow; + + let newState = {loaded: true}; + + if (!this.state.tagColors.length && libraryID && this.libraryID != libraryID) { + newState.tagColors = Zotero.Tags.getColors(libraryID); + } + this.libraryID = libraryID; + + newState.tags = await this.getTags(tagsInScope, + this.state.tagColors.length ? this.state.tagColors : newState.tagColors); + this.setState(newState); + } + + // Update trigger #2 + async notify(event, type, ids, extraData) { + if (type === 'setting') { + if (ids.some(val => val.split('/')[1] == 'tagColors')) { + let tagColors = Zotero.Tags.getColors(this.libraryID); + this.state.tagColors = tagColors; + this.setState({tagColors, tags: await this.getTags(null, tagColors)}); + } + return; + } + + // Ignore anything other than deletes in duplicates view + if (this.collectionTreeRow.isDuplicates()) { + switch (event) { + case 'delete': + case 'trash': + break; + + default: + return; + } + } + + // Ignore item events other than 'trash' + if (type == 'item' && (event == 'trash')) { + return this.setState({tags: await this.getTags()}); + } + + // If a selected tag no longer exists, deselect it + if (type == 'item-tag') { + if (event == 'delete' || event == 'trash' || event == 'modify') { + for (let tag of this.selectedTags) { + if (tag == extraData[ids[0]].old.tag) { + this.selectedTags.delete(tag); + } + } + } + return this.setState({tags: await this.getTags()}); + } + + this.setState({tags: await this.getTags()}); + } + + async getTags(tagsInScope, tagColors) { + if (!tagsInScope) { + tagsInScope = await this.collectionTreeRow.getChildTags(); + } + this.inScope = new Set(tagsInScope.map(t => t.tag)); + let tags; + if (this.displayAllTags) { + tags = await Zotero.Tags.getAll(this.libraryID, [0, 1]); + } else { + tags = tagsInScope + } + + tagColors = tagColors || this.state.tagColors; + + // Add colored tags that aren't already real tags + let regularTags = new Set(tags.map(tag => tag.tag)); + let coloredTags = Array.from(tagColors.keys()); + + coloredTags.filter(ct => !regularTags.has(ct)).forEach(x => + tags.push(Zotero.Tags.cleanData({ tag: x })) + ); + + // Sort by name + tags.sort(function (a, b) { + let aColored = tagColors.has(a.tag), + bColored = tagColors.has(b.tag); + if (aColored && !bColored) return -1; + if (!aColored && bColored) return 1; + return Zotero.getLocaleCollation().compareString(1, a.tag, b.tag); + }); + + return tags; + } + + render() { + let tags = this.state.tags; + let tagMap = new Map(); + if (!this.showAutomatic) { + tags = tags.filter(t => t.type != 1) + } else { + // Ensure no duplicates from auto and manual tags + tags.forEach(t => !tagMap.has() && t.type != 1 && tagMap.set(t.tag, t)); + tags = Array.from(tagMap.values()); + } + if (this.state.searchString) { + tags = tags.filter(tag => !!tag.tag.match(new RegExp(this.state.searchString, 'i'))); + } + tags = tags.map(t => { + let name = t.tag; + return { + name, + selected: this.selectedTags.has(name), + color: this.state.tagColors.has(name) ? this.state.tagColors.get(name).color : '', + disabled: !this.inScope.has(name) + } + }); + return this.focusTextbox = ref && ref.focusTextbox} + searchString={this.state.searchString} + shouldFocus={this.state.shouldFocus} + dragObserver={this.dragObserver} + onSelect={this.state.viewOnly ? () => {} : this.handleTagSelected} + onTagContext={this.handleTagContext} + onSearch={this.handleSearch} + onSettings={this.handleSettings} + loaded={this.state.loaded} + />; + } + + setMode(mode) { + this.state.viewOnly != (mode == 'view') && this.setState({viewOnly: mode == 'view'}); + } + + unregister() { + ReactDOM.unmountComponentAtNode(this.domEl); + if (this._notifierID) { + Zotero.Notifier.unregisterObserver(this._notifierID); + } + } + + uninit() { + this.setState({searchString: ''}); + this.selectedTags = new Set(); + } + + handleTagContext = (tag, ev) => { + let tagContextMenu = document.getElementById('tag-menu'); + ev.preventDefault(); + tagContextMenu.openPopup(null, null, ev.clientX+2, ev.clientY+2); + this.contextTag = tag; + } + + handleSettings = (ev) => { + let settingsContextMenu = document.getElementById('tag-selector-view-settings-menu'); + ev.preventDefault(); + settingsContextMenu.openPopup(ev.target, 'end_before', 0, 0, true); + } + + handleTagSelected = (tag) => { + let selectedTags = this.selectedTags; + if(selectedTags.has(tag)) { + selectedTags.delete(tag); + } else { + selectedTags.add(tag); + } + + if (typeof(this.props.onSelection) === 'function') { + this.props.onSelection(selectedTags); + } + } + + handleSearch = Zotero.Utilities.debounce((searchString) => { + this.setState({searchString}); + }) + + dragObserver = { + onDragOver: function(event) { + if (!event.dataTransfer.getData('zotero/item')) { + return; + } + + var elem = event.target; + + // Ignore drops not on tags + if (elem.localName != 'li') { + return; + } + + // Store the event, because drop event does not have shiftKey attribute set + Zotero.DragDrop.currentEvent = event; + elem.classList.add('dragged-over'); + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }, + onDragExit: function (event) { + Zotero.DragDrop.currentEvent = null; + event.target.classList.remove('dragged-over'); + }, + onDrop: async function(event) { + var elem = event.target; + + // Ignore drops not on tags + if (elem.localName != 'li') { + return; + } + + elem.classList.remove('dragged-over'); + + var dt = event.dataTransfer; + var ids = dt.getData('zotero/item'); + if (!ids) { + return; + } + + return Zotero.DB.executeTransaction(function* () { + ids = ids.split(','); + var items = Zotero.Items.get(ids); + var value = elem.textContent; + + for (let i=0; i= Zotero.Tags.MAX_COLORED_TAGS && !tagColors.has(io.name)) { + var ps = Cc['@mozilla.org/embedcomp/prompt-service;1'] + .getService(Ci.nsIPromptService); + ps.alert(null, '', Zotero.getString('pane.tagSelector.maxColoredTags', Zotero.Tags.MAX_COLORED_TAGS)); + return; + } + + io.tagColors = tagColors; + + window.openDialog( + 'chrome://zotero/content/tagColorChooser.xul', + 'zotero-tagSelector-colorChooser', + 'chrome,modal,centerscreen', io + ); + + // Dialog cancel + if (typeof io.color == 'undefined') { + return; + } + + await Zotero.Tags.setColor(this.libraryID, io.name, io.color, io.position); + } + + async openRenamePrompt() { + var promptService = Cc['@mozilla.org/embedcomp/prompt-service;1'] + .getService(Ci.nsIPromptService); + + var newName = { value: this.contextTag.name }; + var result = promptService.prompt(window, + Zotero.getString('pane.tagSelector.rename.title'), + Zotero.getString('pane.tagSelector.rename.message'), + newName, '', {}); + + if (!result || !newName.value || this.contextTag.name == newName.value) { + return; + } + + let selectedTags = this.selectedTags; + if (selectedTags.has(this.contextTag.name)) { + var wasSelected = true; + selectedTags.delete(this.contextTag.name); + } + + if (Zotero.Tags.getID(this.contextTag.name)) { + await Zotero.Tags.rename(this.libraryID, this.contextTag.name, newName.value); + } + // Colored tags don't need to exist, so in that case + // just rename the color setting + else { + let color = Zotero.Tags.getColor(this.libraryID, this.contextTag.name); + if (!color) { + throw new Error("Can't rename missing tag"); + } + await Zotero.Tags.setColor(this.libraryID, this.contextTag.name, false); + await Zotero.Tags.setColor(this.libraryID, newName.value, color.color); + } + + if (wasSelected) { + selectedTags.add(newName.value); + } + this.setState({tags: await this.getTags()}) + } + + async openDeletePrompt() { + var promptService = Cc['@mozilla.org/embedcomp/prompt-service;1'] + .getService(Ci.nsIPromptService); + + var confirmed = promptService.confirm(window, + Zotero.getString('pane.tagSelector.delete.title'), + Zotero.getString('pane.tagSelector.delete.message')); + + if (!confirmed) { + return; + } + + var tagID = Zotero.Tags.getID(this.contextTag.name); + + if (tagID) { + await Zotero.Tags.removeFromLibrary(this.libraryID, tagID); + } + // If only a tag color setting, remove that + else { + await Zotero.Tags.setColor(this.libraryID, this.contextTag.name, false); + } + + this.setState({tags: await this.getTags()}); + } + + async toggleDisplayAllTags(newValue) { + newValue = typeof(newValue) === 'undefined' ? !this.displayAllTags : newValue; + Zotero.Prefs.set('tagSelector.displayAllTags', newValue); + this.displayAllTags = newValue; + this.setState({tags: await this.getTags()}); + } + + toggleShowAutomatic(newValue) { + newValue = typeof(newValue) === 'undefined' ? !this.showAutomatic : newValue; + Zotero.Prefs.set('tagSelector.showAutomatic', newValue); + this.setState({showAutomatic: newValue}); + } + + deselectAll() { + this.selectedTags = new Set(); + if('onSelection' in this.props && typeof(this.props.onSelection) === 'function') { + this.props.onSelection(this.selectedTags); + } + } + + get label() { + let count = this.selectedTags.size; + let mod = count === 1 ? 'singular' : count === 0 ? 'none' : 'plural'; + + return Zotero.getString('pane.tagSelector.numSelected.' + mod, [count]); + } + + get showAutomatic() { + return this.state.showAutomatic; + } + + static init(domEl, opts) { + var ref; + let elem = ( + + ref = c } {...opts} /> + + ); + ReactDOM.render(elem, domEl); + ref.domEl = domEl; + return ref; + } +} +})(); diff --git a/chrome/content/zotero/containers/tagSelector.xul b/chrome/content/zotero/containers/tagSelector.xul new file mode 100644 index 0000000000..5022281054 --- /dev/null +++ b/chrome/content/zotero/containers/tagSelector.xul @@ -0,0 +1,58 @@ + + + %globalDTD; + %zoteroDTD; +]> + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/chrome/content/zotero/include.js b/chrome/content/zotero/include.js index 45e4fd4973..fcb0858de6 100644 --- a/chrome/content/zotero/include.js +++ b/chrome/content/zotero/include.js @@ -7,4 +7,9 @@ var Zotero = Components.classes['@zotero.org/Zotero;1'] .getService(Components.interfaces.nsISupports) .wrappedJSObject; -Components.utils.import('resource://zotero/require.js'); \ No newline at end of file +// Components.utils.import('resource://zotero/require.js'); +// Not using Cu.import here since we don't want the require module to be cached +// for includes within ZoteroPane or other code, where we want the window instance available to modules. +Components.classes["@mozilla.org/moz/jssubscript-loader;1"] + .getService(Components.interfaces.mozIJSSubScriptLoader) + .loadSubScript('resource://zotero/require.js'); diff --git a/chrome/content/zotero/standalone/standalone.js b/chrome/content/zotero/standalone/standalone.js index 1b34a3acd6..d81330de88 100644 --- a/chrome/content/zotero/standalone/standalone.js +++ b/chrome/content/zotero/standalone/standalone.js @@ -47,7 +47,7 @@ const ZoteroStandalone = new function() { } return Zotero.initializationPromise; }) - .then(function () { + .then(async function () { if (Zotero.Prefs.get('devtools.errorconsole.enabled', true)) { document.getElementById('menu_errorConsole').hidden = false; } @@ -60,6 +60,7 @@ const ZoteroStandalone = new function() { ZoteroStandalone.DebugOutput.init(); Zotero.hideZoteroPaneOverlays(); + await ZoteroPane.Containers.init(); ZoteroPane.init(); ZoteroPane.makeVisible(); diff --git a/chrome/content/zotero/standalone/standalone.xul b/chrome/content/zotero/standalone/standalone.xul index dd381dc7f6..ff548bee21 100644 --- a/chrome/content/zotero/standalone/standalone.xul +++ b/chrome/content/zotero/standalone/standalone.xul @@ -46,7 +46,8 @@ windowtype="navigator:browser" title="&brandShortName;" width="1000" height="600" - persist="screenX screenY width height sizemode"> + persist="screenX screenY width height sizemode"> +