7 Commits

Author SHA1 Message Date
Eva
0d2df6db95 client/misc: add matchingNames function and fall back to partial matches 2025-04-04 11:31:43 +02:00
Eva
ffd62a936f client/css: disable color transitions in tag autocomplete 2025-04-04 11:31:43 +02:00
Eva
12a7f4f78a client/search: autocomplete tag alias that the user searched for
Instead of always using the first alias.
"Tag input" fields (edit page, bulk tagging, etc.) will display the
matched name in suggestions, and use the first alias upon selection.
2025-04-04 11:31:43 +02:00
Eva
417675cc4c client/search: make autocomplete replace current word and set cursor pos
Better behavior for autocomplete in the middle of an already typed tag.
2025-04-04 11:15:58 +02:00
Eva
f5c5b0bfb1 client/pools: don't flash progress for pool name autocomplete 2025-04-04 11:15:58 +02:00
Eva
01f7f6eabb client/tags: don't flash progress for tag autocomplete 2025-04-04 11:13:17 +02:00
Eva
7eff539df5 client/search: autocomplete negated tags in search fields
This doesn't apply to tag inputs that aren't for searching, like the tag
input box when editing a post.
2025-04-04 11:13:17 +02:00
16 changed files with 108 additions and 28 deletions

View File

@ -369,6 +369,7 @@ input[type=file]:focus+.file-dropper,
a a
display: block display: block
padding: 0.1em 0.5em padding: 0.1em 0.5em
transition: none
&.active a, a:hover &.active a, a:hover
background: $button-enabled-background-color background: $button-enabled-background-color
color: $button-enabled-text-color color: $button-enabled-text-color

View File

@ -102,7 +102,8 @@ class PoolListController {
this._ctx.parameters.query, this._ctx.parameters.query,
offset, offset,
limit, limit,
fields fields,
{}
); );
}, },
pageRenderer: (pageCtx) => { pageRenderer: (pageCtx) => {

View File

@ -78,7 +78,8 @@ class TagListController {
this._ctx.parameters.query, this._ctx.parameters.query,
offset, offset,
limit, limit,
fields fields,
{}
); );
}, },
pageRenderer: (pageCtx) => { pageRenderer: (pageCtx) => {

View File

@ -70,11 +70,18 @@ class AutoCompleteControl {
prefix = this._sourceInputNode.value.substring(0, index + 1); prefix = this._sourceInputNode.value.substring(0, index + 1);
middle = this._sourceInputNode.value.substring(index + 1); middle = this._sourceInputNode.value.substring(index + 1);
} }
suffix = spaceIndex < commaIndex ? suffix.replace(/^[^,]+/, "") : suffix.replace(/^\S+/, "");
suffix = suffix.trimLeft();
this._sourceInputNode.value = this._sourceInputNode.value =
prefix + result.toString() + delimiter + suffix.trimLeft(); prefix + result.toString() + delimiter + suffix;
if (!addSpace) { if (!addSpace) {
this._sourceInputNode.value = this._sourceInputNode.value.trim(); this._sourceInputNode.value = this._sourceInputNode.value.trimLeft();
} }
const selection = this._sourceInputNode.value.length - suffix.length;
if (!addSpace) {
this._sourceInputNode.value = this._sourceInputNode.value.trimRight();
}
this._sourceInputNode.setSelectionRange(selection, selection);
this._sourceInputNode.focus(); this._sourceInputNode.focus();
} }
@ -228,6 +235,13 @@ class AutoCompleteControl {
} }
_updateResults(textToFind) { _updateResults(textToFind) {
if (this._options.isNegationAllowed && textToFind === "-") {
this._results = [];
this._activeResult = -1;
this._refreshList();
return;
}
this._options.getMatches(textToFind).then((matches) => { this._options.getMatches(textToFind).then((matches) => {
const oldResults = this._results.slice(); const oldResults = this._results.slice();
this._results = matches.slice(0, this._options.maxResults); this._results = matches.slice(0, this._options.maxResults);

View File

@ -4,18 +4,19 @@ const misc = require("../util/misc.js");
const PoolList = require("../models/pool_list.js"); const PoolList = require("../models/pool_list.js");
const AutoCompleteControl = require("./auto_complete_control.js"); const AutoCompleteControl = require("./auto_complete_control.js");
function _poolListToMatches(pools, options) { function _poolListToMatches(text, pools, options) {
return [...pools] return [...pools]
.sort((pool1, pool2) => { .sort((pool1, pool2) => {
return pool2.postCount - pool1.postCount; return pool2.postCount - pool1.postCount;
}) })
.map((pool) => { .map((pool) => {
pool.matchingNames = misc.matchingNames(text, pool.names);
let cssName = misc.makeCssName(pool.category, "pool"); let cssName = misc.makeCssName(pool.category, "pool");
const caption = const caption =
'<span class="' + '<span class="' +
cssName + cssName +
'">' + '">' +
misc.escapeHtml(pool.names[0] + " (" + pool.postCount + ")") + misc.escapeHtml(pool.matchingNames[0] + " (" + pool.postCount + ")") +
"</span>"; "</span>";
return { return {
caption: caption, caption: caption,
@ -31,9 +32,9 @@ class PoolAutoCompleteControl extends AutoCompleteControl {
options.getMatches = (text) => { options.getMatches = (text) => {
const term = misc.escapeSearchTerm(text); const term = misc.escapeSearchTerm(text);
const query = const query =
(text.length < minLengthForPartialSearch (text.length >= minLengthForPartialSearch
? term + "*" ? "*" + term + "*"
: "*" + term + "*") + " sort:post-count"; : term + "*") + " sort:post-count";
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
PoolList.search(query, 0, this._options.maxResults, [ PoolList.search(query, 0, this._options.maxResults, [
@ -42,10 +43,11 @@ class PoolAutoCompleteControl extends AutoCompleteControl {
"category", "category",
"postCount", "postCount",
"version", "version",
]).then( ],
{ noProgress: true }).then(
(response) => (response) =>
resolve( resolve(
_poolListToMatches(response.results, this._options) _poolListToMatches(text, response.results, this._options)
), ),
reject reject
); );
@ -54,6 +56,16 @@ class PoolAutoCompleteControl extends AutoCompleteControl {
super(input, options); super(input, options);
} }
_getActiveSuggestion() {
if (this._activeResult === -1) {
return null;
}
const result = this._results[this._activeResult].value;
const textToFind = this._options.getTextToFind();
result.matchingNames = misc.matchingNames(textToFind, result.names);
return result;
}
} }
module.exports = PoolAutoCompleteControl; module.exports = PoolAutoCompleteControl;

View File

@ -5,21 +5,26 @@ const views = require("../util/views.js");
const TagList = require("../models/tag_list.js"); const TagList = require("../models/tag_list.js");
const AutoCompleteControl = require("./auto_complete_control.js"); const AutoCompleteControl = require("./auto_complete_control.js");
function _tagListToMatches(tags, options) { function _tagListToMatches(text, tags, options, negated) {
return [...tags] return [...tags]
.sort((tag1, tag2) => { .sort((tag1, tag2) => {
return tag2.usages - tag1.usages; return tag2.usages - tag1.usages;
}) })
.map((tag) => { .map((tag) => {
tag.matchingNames = misc.matchingNames(text, tag.names);
let cssName = misc.makeCssName(tag.category, "tag"); let cssName = misc.makeCssName(tag.category, "tag");
if (options.isTaggedWith(tag.names[0])) { if (options.isTaggedWith(tag.names[0])) {
cssName += " disabled"; cssName += " disabled";
} }
if (negated) {
tag.names = tag.names.map((tagName) => "-"+tagName);
tag.matchingNames = tag.matchingNames.map((tagName) => "-"+tagName);
}
const caption = const caption =
'<span class="' + '<span class="' +
cssName + cssName +
'">' + '">' +
misc.escapeHtml(tag.names[0] + " (" + tag.postCount + ")") + misc.escapeHtml(tag.matchingNames[0] + " (" + tag.postCount + ")") +
"</span>"; "</span>";
return { return {
caption: caption, caption: caption,
@ -35,26 +40,37 @@ class TagAutoCompleteControl extends AutoCompleteControl {
options = Object.assign( options = Object.assign(
{ {
isTaggedWith: (tag) => false, isTaggedWith: (tag) => false,
isNegationAllowed: false,
}, },
options options
); );
options.getMatches = (text) => { options.getMatches = (text) => {
const negated = options.isNegationAllowed && text[0] === "-";
if (negated) text = text.substring(1);
if (!text) {
return new Promise((resolve, reject) => {
(response) => resolve(null),
reject
});
}
const term = misc.escapeSearchTerm(text); const term = misc.escapeSearchTerm(text);
const query = const query =
(text.length < minLengthForPartialSearch (text.length >= minLengthForPartialSearch || (!options.isNegationAllowed && text[0] === "-")
? term + "*" ? "*" + term + "*"
: "*" + term + "*") + " sort:usages"; : term + "*") + " sort:usages";
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
TagList.search(query, 0, this._options.maxResults, [ TagList.search(query, 0, this._options.maxResults, [
"names", "names",
"category", "category",
"usages", "usages",
]).then( ],
{ noProgress: true }).then(
(response) => (response) =>
resolve( resolve(
_tagListToMatches(response.results, this._options) _tagListToMatches(text, response.results, this._options, negated)
), ),
reject reject
); );
@ -63,6 +79,16 @@ class TagAutoCompleteControl extends AutoCompleteControl {
super(input, options); super(input, options);
} }
_getActiveSuggestion() {
if (this._activeResult === -1) {
return null;
}
const result = this._results[this._activeResult].value;
const textToFind = this._options.getTextToFind();
result.matchingNames = misc.matchingNames(textToFind, result.names);
return result;
}
} }
module.exports = TagAutoCompleteControl; module.exports = TagAutoCompleteControl;

View File

@ -114,6 +114,7 @@ class TagInputControl extends events.EventTarget {
}, },
verticalShift: -2, verticalShift: -2,
isTaggedWith: (tagName) => this.tags.isTaggedWith(tagName), isTaggedWith: (tagName) => this.tags.isTaggedWith(tagName),
isNegationAllowed: false,
} }
); );

View File

@ -6,7 +6,7 @@ const AbstractList = require("./abstract_list.js");
const Pool = require("./pool.js"); const Pool = require("./pool.js");
class PoolList extends AbstractList { class PoolList extends AbstractList {
static search(text, offset, limit, fields) { static search(text, offset, limit, fields, options) {
return api return api
.get( .get(
uri.formatApiLink("pools", { uri.formatApiLink("pools", {
@ -14,7 +14,8 @@ class PoolList extends AbstractList {
offset: offset, offset: offset,
limit: limit, limit: limit,
fields: fields.join(","), fields: fields.join(","),
}) }),
options
) )
.then((response) => { .then((response) => {
return Promise.resolve( return Promise.resolve(

View File

@ -6,7 +6,7 @@ const AbstractList = require("./abstract_list.js");
const Tag = require("./tag.js"); const Tag = require("./tag.js");
class TagList extends AbstractList { class TagList extends AbstractList {
static search(text, offset, limit, fields) { static search(text, offset, limit, fields, options) {
return api return api
.get( .get(
uri.formatApiLink("tags", { uri.formatApiLink("tags", {
@ -14,7 +14,8 @@ class TagList extends AbstractList {
offset: offset, offset: offset,
limit: limit, limit: limit,
fields: fields.join(","), fields: fields.join(","),
}) }),
options
) )
.then((response) => { .then((response) => {
return Promise.resolve( return Promise.resolve(

View File

@ -211,6 +211,24 @@ function getPrettyName(tag) {
return tag; return tag;
} }
function wildcardMatch(pattern, str, sensitive = false) {
let w = pattern.replace(/[.+^${}()|[\]\\?]/g, "\\$&");
const re = new RegExp(`^${w.replace(/\(--wildcard--\)|\*/g, ".*")}$`, sensitive ? "" : "i");
return re.test(str);
}
function matchingNames(text, names) {
const minLengthForPartialSearch = 3;
let matches = names.filter((name) => wildcardMatch(text + "*", name, false));
if (!matches.length && text.length >= minLengthForPartialSearch) {
matches = names.filter((name) => wildcardMatch("*" + text + "*", name, false));
}
matches = matches.length ? matches : names;
return matches;
}
module.exports = { module.exports = {
range: range, range: range,
formatRelativeTime: formatRelativeTime, formatRelativeTime: formatRelativeTime,
@ -229,4 +247,6 @@ module.exports = {
escapeSearchTerm: escapeSearchTerm, escapeSearchTerm: escapeSearchTerm,
dataURItoBlob: dataURItoBlob, dataURItoBlob: dataURItoBlob,
getPrettyName: getPrettyName, getPrettyName: getPrettyName,
wildcardMatch: wildcardMatch,
matchingNames: matchingNames,
}; };

View File

@ -27,9 +27,10 @@ class HomeView {
{ {
confirm: (tag) => confirm: (tag) =>
this._autoCompleteControl.replaceSelectedText( this._autoCompleteControl.replaceSelectedText(
misc.escapeSearchTerm(tag.names[0]), misc.escapeSearchTerm(tag.matchingNames[0]),
true true
), ),
isNegationAllowed: true,
} }
); );
this._formNode.addEventListener("submit", (e) => this._formNode.addEventListener("submit", (e) =>

View File

@ -25,7 +25,7 @@ class PoolMergeView extends events.EventTarget {
confirm: (pool) => { confirm: (pool) => {
this._targetPoolId = pool.id; this._targetPoolId = pool.id;
this._autoCompleteControl.replaceSelectedText( this._autoCompleteControl.replaceSelectedText(
pool.names[0], pool.matchingNames[0],
false false
); );
}, },

View File

@ -21,7 +21,7 @@ class PoolsHeaderView extends events.EventTarget {
{ {
confirm: (pool) => confirm: (pool) =>
this._autoCompleteControl.replaceSelectedText( this._autoCompleteControl.replaceSelectedText(
misc.escapeSearchTerm(pool.names[0]), misc.escapeSearchTerm(pool.matchingNames[0]),
true true
), ),
} }

View File

@ -183,9 +183,10 @@ class PostsHeaderView extends events.EventTarget {
{ {
confirm: (tag) => confirm: (tag) =>
this._autoCompleteControl.replaceSelectedText( this._autoCompleteControl.replaceSelectedText(
misc.escapeSearchTerm(tag.names[0]), misc.escapeSearchTerm(tag.matchingNames[0]),
true true
), ),
isNegationAllowed: true,
} }
); );

View File

@ -23,7 +23,7 @@ class TagMergeView extends events.EventTarget {
{ {
confirm: (tag) => confirm: (tag) =>
this._autoCompleteControl.replaceSelectedText( this._autoCompleteControl.replaceSelectedText(
tag.names[0], tag.matchingNames[0],
false false
), ),
} }

View File

@ -21,7 +21,7 @@ class TagsHeaderView extends events.EventTarget {
{ {
confirm: (tag) => confirm: (tag) =>
this._autoCompleteControl.replaceSelectedText( this._autoCompleteControl.replaceSelectedText(
misc.escapeSearchTerm(tag.names[0]), misc.escapeSearchTerm(tag.matchingNames[0]),
true true
), ),
} }