mirror of
https://github.com/zotero/zotero.git
synced 2026-10-09 03:18:01 +00:00
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:
parent
6787dcd596
commit
7aa4fd24b4
8 changed files with 112 additions and 11 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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="
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -131,6 +131,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
&.excluded {
|
||||
background-color: var(--accent-red);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
&.colored {
|
||||
font-weight: 600;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue