negative selection for tags

- added context menu option "Exclude tag" that will exclude
the items with the selected tag from search results
- search condition also includes includeChildren:true
so that all attachments that do not have a tag do not
appear always regarding of which tag is excluded
- Backspace on a focused tag will also mark it as excluded
- clicking on a tag removes the "excluded" status
This commit is contained in:
Bogdan Abaev 2025-01-27 22:36:39 -08:00
parent 6787dcd596
commit 7aa4fd24b4
8 changed files with 112 additions and 11 deletions

View file

@ -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}
/>
<div className="tag-selector-filter-pane">
<div className="tag-selector-filter-container">
@ -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,

View file

@ -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,

View file

@ -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);
}

View file

@ -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);
}
}
}

View file

@ -1025,6 +1025,9 @@
oncommand="ZoteroPane.tagSelector.openTagSplitterWindow(event)"/>
<menuitem label="&zotero.tagSelector.deleteTag;"
oncommand="ZoteroPane.tagSelector.openDeletePrompt(); event.stopPropagation();"/>
<menuseparator/>
<menuitem id="tag-menu-exclude-tag" data-l10n-id="tagselector-exclude-tag"
oncommand="ZoteroPane.tagSelector.excludeTag(); event.stopPropagation();"/>
</menupopup>
<menupopup id="tag-selector-view-settings-menu"
onpopupshowing="

View file

@ -541,6 +541,10 @@ tag-field =
tagselector-search =
.placeholder = Filter Tags
tagselector-exclude-tag =
.label = Exclude tag
tag-excluded =
.aria-description = This tag is excluded from item's list.
context-notes-search =
.placeholder = Search Notes

View file

@ -131,6 +131,11 @@
}
}
&.excluded {
background-color: var(--accent-red);
text-decoration: line-through;
}
&.colored {
font-weight: 600;

View file

@ -45,7 +45,7 @@ describe("Tag Selector", function () {
await clearTagColors(libraryID);
// Default "Display All Tags in This Library" off
tagSelector.displayAllTags = false;
tagSelector.selectedTags = new Set();
tagSelector.selectedTags = new Map();
tagSelector.handleSearch('');
tagSelector.onItemViewChanged({
collectionTreeRow: win.ZoteroPane.getCollectionTreeRow(),
@ -179,6 +179,45 @@ describe("Tag Selector", function () {
assert.sameMembers(tags, ['A', 'B']);
});
});
describe("#excludeTag", function () {
let item1, item2;
beforeEach(async() => {
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 () {