diff --git a/chrome/content/zotero/components/tagSelector.jsx b/chrome/content/zotero/components/tagSelector.jsx index a2059effec..93994fd786 100644 --- a/chrome/content/zotero/components/tagSelector.jsx +++ b/chrome/content/zotero/components/tagSelector.jsx @@ -49,6 +49,7 @@ class TagSelector extends React.PureComponent { fontSize={this.props.fontSize} lineHeight={this.props.lineHeight} uiDensity={this.props.uiDensity} + excludeTag={this.props.excludeTag} />
@@ -90,6 +91,7 @@ TagSelector.propTypes = { }), onSelect: PropTypes.func, onKeyDown: PropTypes.func, + excludeTag: PropTypes.func, onTagContext: PropTypes.func, loaded: PropTypes.bool, width: PropTypes.number.isRequired, diff --git a/chrome/content/zotero/components/tagSelector/tagSelectorList.jsx b/chrome/content/zotero/components/tagSelector/tagSelectorList.jsx index b2d9a64e60..a069b214c6 100644 --- a/chrome/content/zotero/components/tagSelector/tagSelectorList.jsx +++ b/chrome/content/zotero/components/tagSelector/tagSelectorList.jsx @@ -175,6 +175,9 @@ class TagList extends React.PureComponent { if (tag.disabled) { className += ' disabled'; } + if (tag.excluded) { + className += ' excluded'; + } if (Zotero.Utilities.Internal.containsEmoji(tag.name)) { className += ' emoji'; } @@ -199,6 +202,13 @@ class TagList extends React.PureComponent { props.role = "checkbox"; props['aria-checked'] = tag.selected; props['aria-disabled'] = tag.disabled; + // Add a note to excluded tags to differentiate them from just selected + if (tag.excluded) { + props['data-l10n-id'] = "tag-excluded"; + } + else { + props['aria-description'] = ''; + } // Don't specify explicit width unless we're truncating, because for some reason the width // from canvas can sometimes be slightly smaller than the actual width, resulting in an // unnecessary ellipsis. @@ -305,6 +315,11 @@ class TagList extends React.PureComponent { }; async handleKeyDown(e) { + if (e.key == "Backspace") { + let tag = e.target.textContent; + this.props.excludeTag(tag); + return; + } if (!["ArrowRight", "ArrowLeft"].includes(e.key)) return; // If the windowing kicks in, the node of the initially-focused tag may not // exist, so first we may need to scroll to it. @@ -393,6 +408,7 @@ class TagList extends React.PureComponent { onSelect: PropTypes.func, onKeyDown: PropTypes.func, onTagContext: PropTypes.func, + excludeTag: PropTypes.func, loaded: PropTypes.bool, width: PropTypes.number.isRequired, height: PropTypes.number.isRequired, diff --git a/chrome/content/zotero/containers/tagSelectorContainer.jsx b/chrome/content/zotero/containers/tagSelectorContainer.jsx index 0ebc5b575b..a4839e8e9c 100644 --- a/chrome/content/zotero/containers/tagSelectorContainer.jsx +++ b/chrome/content/zotero/containers/tagSelectorContainer.jsx @@ -65,7 +65,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent { // Not stored in state to avoid an unnecessary refresh. Instead, when a tag is selected, we // trigger the selection handler, which updates the visible items, which triggers // onItemViewChanged(), which triggers a refresh with the new tags. - this.selectedTags = new Set(); + this.selectedTags = new Map(); this.widths = new Map(); this.widthsBold = new Map(); @@ -322,7 +322,14 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent { if (this.displayAllTags) { tags = await Zotero.Tags.getAll(this.libraryID); } - + else { + // Make sure that excluded tags do not disappear + for (let [tagName, tagProps] of this.selectedTags.entries()) { + if (tagProps.excluded) { + tags.push({ tag: tagName }) + } + } + } // If tags haven't changed, return previous array without sorting again if (this.state.tags.length == tags.length) { let prevTags = new Set(this.state.tags.map(tag => tag.tag)); @@ -513,8 +520,10 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent { name, width: tag.width }; - if (this.selectedTags.has(name)) { + let selectedTag = this.selectedTags.get(name) + if (selectedTag) { tag.selected = true; + tag.excluded = selectedTag.excluded; } if (inTagColors && tagColors.has(name)) { tag.color = tagColors.get(name).color; @@ -524,7 +533,8 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent { } // If we're not displaying all tags, we only need to check the scope for colored tags, // since everything else will be in scope - if ((this.displayAllTags || inTagColors) && !this.state.scope.has(name)) { + // A tag excluded from search results is not in scope but is part of selected tags so it remains enabled + if ((this.displayAllTags || inTagColors) && !(this.state.scope.has(name) || this.selectedTags.get(name))) { tag.disabled = true; } const forceUseDOM = this.state.isHighDensity && i < FORCE_DOM_TAGS_FOR_COUNT; @@ -543,6 +553,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent { dragObserver={this.dragObserver} onSelect={this.handleTagSelected} onTagContext={this.handleTagContext} + excludeTag={this.excludeTag.bind(this)} onSearch={this.handleSearch} onSettings={this.handleSettings.bind(this)} loaded={this.state.loaded} @@ -572,6 +583,8 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent { true ); this.contextTag = tag; + // Disable "Exclude tag" for disabled tags + tagContextMenu.querySelector("#tag-menu-exclude-tag").disabled = tag.disabled; } handleSettings = (ev) => { @@ -585,7 +598,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent { if(selectedTags.has(tag)) { selectedTags.delete(tag); } else { - selectedTags.add(tag); + selectedTags.set(tag, {}); } if (typeof(this.props.onSelection) === 'function') { @@ -662,7 +675,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent { } clearTagSelection() { - this.selectedTags = new Set(); + this.selectedTags = new Map(); } async openColorPickerWindow() { @@ -749,8 +762,9 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent { let selectedTags = this.selectedTags; if (selectedTags.has(this.contextTag.name)) { + let excluded = selectedTags.get(this.contextTag.name).excluded; selectedTags.delete(this.contextTag.name); - selectedTags.add(newName.value); + selectedTags.set(newName.value, { excluded }); } if (Zotero.Tags.getID(this.contextTag.name)) { @@ -790,6 +804,17 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent { } } + // Inverse of selection for tags. Items with the specified tag do not match search query. + async excludeTag(tag) { + if (!tag) { + tag = this.contextTag.name; + } + this.selectedTags.set(tag, { excluded: true }) + if (typeof(this.props.onSelection) === 'function') { + this.props.onSelection(this.selectedTags); + } + } + async toggleDisplayAllTags(newValue) { newValue = typeof(newValue) === 'undefined' ? !this.displayAllTags : newValue; Zotero.Prefs.set('tagSelector.displayAllTags', newValue); @@ -804,7 +829,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent { } deselectAll() { - this.selectedTags = new Set(); + this.selectedTags = new Map(); if('onSelection' in this.props && typeof(this.props.onSelection) === 'function') { this.props.onSelection(this.selectedTags); } diff --git a/chrome/content/zotero/xpcom/collectionTreeRow.js b/chrome/content/zotero/xpcom/collectionTreeRow.js index 98da6b9571..07e0faaa98 100644 --- a/chrome/content/zotero/xpcom/collectionTreeRow.js +++ b/chrome/content/zotero/xpcom/collectionTreeRow.js @@ -434,8 +434,15 @@ Zotero.CollectionTreeRow.prototype.getSearchObject = Zotero.Promise.coroutine(fu } if (this.tags){ - for (let tag of this.tags) { - s2.addCondition('tag', 'is', tag); + for (let [tagName, tagProps] of this.tags.entries()) { + if (tagProps.excluded) { + s2.addCondition('tag', 'isNot', tagName); + // When an item with a tag is excluded, have its children excluded as well + s2.addCondition('includeChildren', 'true'); + } + else { + s2.addCondition('tag', 'is', tagName); + } } } diff --git a/chrome/content/zotero/zoteroPane.xhtml b/chrome/content/zotero/zoteroPane.xhtml index 08103a5c49..533eeea0e7 100644 --- a/chrome/content/zotero/zoteroPane.xhtml +++ b/chrome/content/zotero/zoteroPane.xhtml @@ -1025,6 +1025,9 @@ oncommand="ZoteroPane.tagSelector.openTagSplitterWindow(event)"/> + + { + var collection = await createDataObject('collection'); + await select(win, collection); + item1 = createUnsavedDataObject('item', { collections: [collection.id] }); + item1.setTags(['a']); + await item1.saveTx(); + item2 = createUnsavedDataObject('item', { collections: [collection.id] }); + item2.setTags(['b']); + var promise = waitForTagSelector(win); + await item2.saveTx(); + await promise; + }); + afterEach(async() => { + await item1.eraseTx(); + await item2.eraseTx(); + }); + + it("should mark excluded tags as such", async function () { + let promise = waitForTagSelector(win); + await tagSelector.excludeTag('a'); + await promise; + + var tags = [...getRegularTagElements()]; + var excludedTags = tags.filter(tag => tag.classList.contains('excluded')).map(tag => tag.textContent); + assert.sameMembers(excludedTags, ['a']); + }); + it("should filter out item with excluded tag even if its child does not have the tag", async function () { + await importPDFAttachment(item1); + let promise = waitForTagSelector(win); + await tagSelector.excludeTag('a'); + await promise; + + assert.equal(win.ZoteroPane.itemsView.rowCount, 1); + assert.equal(item2.id, win.ZoteroPane.itemsView.getRow(0).ref.id); + }); + }); describe("#displayAllTags", function () {