Add AutocomplableInput model for UI binding

This commit is contained in:
MareStare 2025-03-04 04:19:43 +00:00
parent aa2e5dd3af
commit e63899b91e

View file

@ -0,0 +1,199 @@
import store from '../utils/store';
import { getTermContexts } from '../match_query';
import { Range } from '../query/lex';
export type TextInputElement = HTMLInputElement | HTMLTextAreaElement;
/**
* Describes the term, that the cursor is currently on, which is known as "active".
* If any tag completion is accepted, this term will be overwritten in the input.
* The rest of the input will be left untouched.
*/
interface ActiveTerm {
range: Range;
/**
* The term itself. Stripped from the `prefix` if it's present, and also lowercased.
*/
term: string;
/**
* Optional `-` prefix is only relevant for the `single-tag` autocompletion type.
* This prefix is extracted automatically from the `term` value and is used to
* signal that the tag should be removed from the list.
*/
prefix: '-' | '';
}
/**
* Captures the value of the input at the time when the `AutocompletableInput` was created.
*/
interface AutocompleteInputSnapshot {
/**
* Original value of the input element at the time when it was created unmodified.
*/
origValue: string;
/**
* The value of the input element at the time when it was created, but
* trimmed from whitespace.
*/
trimmedValue: string;
/**
* Can be `null` if the input value is empty.
*/
activeTerm: ActiveTerm | null;
/**
* Cursor selection at the time when the snapshot was taken.
*/
selection: {
start: number | null;
end: number | null;
direction: TextInputElement['selectionDirection'];
};
}
/**
* The `multi-tags` autocompletion type is used to power inputs with complex
* search queries like `(tag1 OR tag2), tag3` and tag lists like `tag1, tag2, tag3`
* in the plain tag search/edit inputs.
*
* The `single-tag` autocompletion type is used to power the fancy tag editor
* that manages separate input elements for every tag. In this mode the user
* can input `-tag` prefix to remove the tag from the list. See more details
* about how it works here: https://github.com/philomena-dev/philomena/pull/383
*/
type AutocompleteInputType = 'multi-tags' | 'single-tag';
/**
* Parsed version of `TextInputElement`. Its behavior is controlled with various
* `data-autocomplete*` attributes.
*/
export class AutocompletableInput {
/**
* HTML element that autocomplete is attached to.
*/
readonly element: TextInputElement;
readonly type: AutocompleteInputType;
/**
* Captures the value of the input at the time when the `AutocompletableInput` was created.
*/
readonly snapshot: AutocompleteInputSnapshot;
/**
* Defines the name of the parameter in `localStorage` that should be read
* to conditionally enable the autocomplete feature.
*/
readonly condition?: string;
/**
* An integer that overrides the default limit of maximum suggestions to show.
*/
readonly maxSuggestions: number;
/**
* If present enables the history feature for the input element. The value
* of this property defines the key in the `localStorage` where the history
* records are stored.
*/
readonly historyId?: string;
/**
* Returns `null` only if the element is not autocomplete-capable.
*/
static fromElement(element: unknown): AutocompletableInput | null {
if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) {
return null;
}
// This attribute marks the element as autocomplete-capable. It doesn't necessarily
// mean that the autocomplete **will** show up for the element. It may be disabled
// based on the setting value from the key specified under the attribute
// `data-autocomplete-condition`.
if (!element.dataset.autocomplete) {
return null;
}
return new AutocompletableInput(element);
}
private constructor(element: TextInputElement) {
this.element = element;
this.condition = element.dataset.autocompleteCondition;
this.historyId = element.dataset.autocompleteHistoryId;
const type = element.dataset.autocomplete;
if (type !== 'multi-tags' && type !== 'single-tag') {
throw new Error(`BUG: invalid autocomplete type: ${type}`);
}
this.type = type;
this.snapshot = {
origValue: element.value,
trimmedValue: element.value.trim(),
activeTerm: findActiveTerm(type, element),
selection: {
start: element.selectionStart,
end: element.selectionEnd,
direction: element.selectionDirection,
},
};
const maxSuggestions = element.dataset.autocompleteMaxSuggestions;
this.maxSuggestions = maxSuggestions ? parseInt(maxSuggestions, 10) : 10;
}
hasHistory(): this is this & { historyId: string } {
return Boolean(this.historyId);
}
isEnabled(): boolean {
return !this.condition || store.get<boolean>(this.condition) || false;
}
}
function findActiveTerm(
autocompleteType: AutocompleteInputType,
{ value, selectionStart, selectionEnd }: TextInputElement,
): ActiveTerm | null {
if (selectionStart === null || selectionEnd === null) return null;
// Technically the user may select several characters and several terms at once,
// but we just take the first one from the selection as the "cursor" index.
const cursorIndex = Math.min(selectionStart, selectionEnd);
// Multi-line textarea elements should treat each line as different search queries.
// Here we're looking for the actively edited line and use it instead of the whole value.
const lineStart = value.lastIndexOf('\n', cursorIndex) + 1;
const lineEnd = Math.max(value.indexOf('\n', cursorIndex), value.length);
const line = value.slice(lineStart, lineEnd);
const terms = getTermContexts(line);
const searchIndex = cursorIndex - lineStart;
const term = terms.find(({ range }) => range.start <= searchIndex && range.end >= searchIndex) ?? null;
if (!term) {
return null;
}
const { range } = term;
const content = term.content.toLowerCase();
const stripDash = content.startsWith('-') && autocompleteType === 'single-tag';
return {
term: stripDash ? content.slice(1) : content,
prefix: stripDash ? '-' : '',
range: {
// Convert line-specific indexes back to absolute ones.
start: range.start + lineStart,
end: range.end + lineStart,
},
};
}