mirror of
https://github.com/philomena-dev/philomena.git
synced 2025-02-23 21:54:33 +01:00
Merge pull request #417 from MareStare/feat/small-refactoring
Refactoring of autocomplete and tag inputs
This commit is contained in:
commit
f35313ad3a
8 changed files with 106 additions and 51 deletions
|
@ -15,17 +15,24 @@ import {
|
||||||
TermSuggestion,
|
TermSuggestion,
|
||||||
} from './utils/suggestions';
|
} from './utils/suggestions';
|
||||||
|
|
||||||
type InputFieldElement = HTMLInputElement | HTMLTextAreaElement;
|
type AutocompletableInputElement = HTMLInputElement | HTMLTextAreaElement;
|
||||||
|
|
||||||
let inputField: InputFieldElement | null = null,
|
function hasAutocompleteEnabled(element: unknown): element is AutocompletableInputElement {
|
||||||
originalTerm: string | undefined,
|
return (
|
||||||
originalQuery: string | undefined,
|
(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) &&
|
||||||
selectedTerm: TermContext | null = null;
|
Boolean(element.dataset.autocomplete)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let inputField: AutocompletableInputElement | null = null;
|
||||||
|
let originalTerm: string | undefined;
|
||||||
|
let originalQuery: string | undefined;
|
||||||
|
let selectedTerm: TermContext | null = null;
|
||||||
|
|
||||||
const popup = new SuggestionsPopup();
|
const popup = new SuggestionsPopup();
|
||||||
|
|
||||||
function isSearchField(targetInput: HTMLElement): boolean {
|
function isSearchField(targetInput: HTMLElement): boolean {
|
||||||
return targetInput && targetInput.dataset.acMode === 'search';
|
return targetInput.dataset.autocompleteMode === 'search';
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreOriginalValue() {
|
function restoreOriginalValue() {
|
||||||
|
@ -113,7 +120,7 @@ function keydownHandler(event: KeyboardEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function findSelectedTerm(targetInput: InputFieldElement, searchQuery: string): TermContext | null {
|
function findSelectedTerm(targetInput: AutocompletableInputElement, searchQuery: string): TermContext | null {
|
||||||
if (targetInput.selectionStart === null || targetInput.selectionEnd === null) return null;
|
if (targetInput.selectionStart === null || targetInput.selectionEnd === null) return null;
|
||||||
|
|
||||||
const selectionIndex = Math.min(targetInput.selectionStart, targetInput.selectionEnd);
|
const selectionIndex = Math.min(targetInput.selectionStart, targetInput.selectionEnd);
|
||||||
|
@ -139,14 +146,23 @@ function findSelectedTerm(targetInput: InputFieldElement, searchQuery: string):
|
||||||
return term;
|
return term;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSearchAutocomplete() {
|
/**
|
||||||
|
* Our custom autocomplete isn't compatible with the native browser autocomplete,
|
||||||
|
* so we have to turn it off if our autocomplete is enabled, or turn it back on
|
||||||
|
* if it's disabled.
|
||||||
|
*/
|
||||||
|
function toggleSearchNativeAutocomplete() {
|
||||||
const enable = store.get('enable_search_ac');
|
const enable = store.get('enable_search_ac');
|
||||||
|
|
||||||
for (const searchField of $$<InputFieldElement>(':is(input, textarea)[data-ac-mode=search]')) {
|
const searchFields = $$<AutocompletableInputElement>(
|
||||||
|
':is(input, textarea)[data-autocomplete][data-autocomplete-mode=search]',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const searchField of searchFields) {
|
||||||
if (enable) {
|
if (enable) {
|
||||||
searchField.autocomplete = 'off';
|
searchField.autocomplete = 'off';
|
||||||
} else {
|
} else {
|
||||||
searchField.removeAttribute('data-ac');
|
searchField.removeAttribute('data-autocomplete');
|
||||||
searchField.autocomplete = 'on';
|
searchField.autocomplete = 'on';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -156,11 +172,15 @@ function trimPrefixes(targetTerm: string): string {
|
||||||
return targetTerm.trim().replace(/^-/, '');
|
return targetTerm.trim().replace(/^-/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function listenAutocomplete() {
|
/**
|
||||||
|
* We control the autocomplete with `data-autocomplete*` attributes in HTML, and subscribe
|
||||||
|
* event listeners to the `document`. This pattern is described in more detail
|
||||||
|
* here: https://javascript.info/event-delegation
|
||||||
|
*/
|
||||||
|
export function listenAutocomplete() {
|
||||||
let serverSideSuggestionsTimeout: number | undefined;
|
let serverSideSuggestionsTimeout: number | undefined;
|
||||||
|
|
||||||
let localAc: LocalAutocompleter | null = null;
|
let localAutocomplete: LocalAutocompleter | null = null;
|
||||||
let isLocalLoading = false;
|
|
||||||
|
|
||||||
document.addEventListener('focusin', loadAutocompleteFromEvent);
|
document.addEventListener('focusin', loadAutocompleteFromEvent);
|
||||||
|
|
||||||
|
@ -169,15 +189,13 @@ function listenAutocomplete() {
|
||||||
loadAutocompleteFromEvent(event);
|
loadAutocompleteFromEvent(event);
|
||||||
window.clearTimeout(serverSideSuggestionsTimeout);
|
window.clearTimeout(serverSideSuggestionsTimeout);
|
||||||
|
|
||||||
if (!(event.target instanceof HTMLInputElement) && !(event.target instanceof HTMLTextAreaElement)) return;
|
if (!hasAutocompleteEnabled(event.target)) return;
|
||||||
|
|
||||||
const targetedInput = event.target;
|
const targetedInput = event.target;
|
||||||
|
|
||||||
if (!targetedInput.dataset.ac) return;
|
|
||||||
|
|
||||||
targetedInput.addEventListener('keydown', keydownHandler as EventListener);
|
targetedInput.addEventListener('keydown', keydownHandler as EventListener);
|
||||||
|
|
||||||
if (localAc !== null) {
|
if (localAutocomplete !== null) {
|
||||||
inputField = targetedInput;
|
inputField = targetedInput;
|
||||||
let suggestionsCount = 5;
|
let suggestionsCount = 5;
|
||||||
|
|
||||||
|
@ -193,10 +211,10 @@ function listenAutocomplete() {
|
||||||
|
|
||||||
originalTerm = selectedTerm[1].toLowerCase();
|
originalTerm = selectedTerm[1].toLowerCase();
|
||||||
} else {
|
} else {
|
||||||
originalTerm = `${inputField.value}`.toLowerCase();
|
originalTerm = inputField.value.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
const suggestions = localAc
|
const suggestions = localAutocomplete
|
||||||
.matchPrefix(trimPrefixes(originalTerm), suggestionsCount)
|
.matchPrefix(trimPrefixes(originalTerm), suggestionsCount)
|
||||||
.map(formatLocalAutocompleteResult);
|
.map(formatLocalAutocompleteResult);
|
||||||
|
|
||||||
|
@ -206,7 +224,7 @@ function listenAutocomplete() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { acMinLength: minTermLength, acSource: endpointUrl } = targetedInput.dataset;
|
const { autocompleteMinLength: minTermLength, autocompleteSource: endpointUrl } = targetedInput.dataset;
|
||||||
|
|
||||||
if (!endpointUrl) return;
|
if (!endpointUrl) return;
|
||||||
|
|
||||||
|
@ -236,19 +254,19 @@ function listenAutocomplete() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function loadAutocompleteFromEvent(event: Event) {
|
// Lazy-load the local autocomplete index from the server only once.
|
||||||
if (!(event.target instanceof HTMLInputElement) && !(event.target instanceof HTMLTextAreaElement)) return;
|
let localAutocompleteFetchNeeded = true;
|
||||||
|
|
||||||
if (!isLocalLoading && event.target.dataset.ac) {
|
async function loadAutocompleteFromEvent(event: Event) {
|
||||||
isLocalLoading = true;
|
if (!localAutocompleteFetchNeeded || !hasAutocompleteEnabled(event.target)) {
|
||||||
|
return;
|
||||||
fetchLocalAutocomplete().then(autocomplete => {
|
|
||||||
localAc = autocomplete;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
localAutocompleteFetchNeeded = false;
|
||||||
|
localAutocomplete = await fetchLocalAutocomplete();
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSearchAutocomplete();
|
toggleSearchNativeAutocomplete();
|
||||||
|
|
||||||
popup.onItemSelected((event: CustomEvent<TermSuggestion>) => {
|
popup.onItemSelected((event: CustomEvent<TermSuggestion>) => {
|
||||||
if (!event.detail || !inputField) return;
|
if (!event.detail || !inputField) return;
|
||||||
|
@ -272,5 +290,3 @@ function listenAutocomplete() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { listenAutocomplete };
|
|
||||||
|
|
|
@ -47,10 +47,10 @@ describe('Local Autocompleter', () => {
|
||||||
describe('topK', () => {
|
describe('topK', () => {
|
||||||
const termStem = ['f', 'o'].join('');
|
const termStem = ['f', 'o'].join('');
|
||||||
|
|
||||||
let localAc: LocalAutocompleter;
|
let localAutocomplete: LocalAutocompleter;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
localAc = new LocalAutocompleter(mockData);
|
localAutocomplete = new LocalAutocompleter(mockData);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
@ -58,17 +58,17 @@ describe('Local Autocompleter', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return suggestions for exact tag name match', () => {
|
it('should return suggestions for exact tag name match', () => {
|
||||||
const result = localAc.matchPrefix('safe', defaultK);
|
const result = localAutocomplete.matchPrefix('safe', defaultK);
|
||||||
expect(result).toEqual([expect.objectContaining({ aliasName: 'safe', name: 'safe', imageCount: 6 })]);
|
expect(result).toEqual([expect.objectContaining({ aliasName: 'safe', name: 'safe', imageCount: 6 })]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return suggestion for original tag when passed an alias', () => {
|
it('should return suggestion for original tag when passed an alias', () => {
|
||||||
const result = localAc.matchPrefix('flowers', defaultK);
|
const result = localAutocomplete.matchPrefix('flowers', defaultK);
|
||||||
expect(result).toEqual([expect.objectContaining({ aliasName: 'flowers', name: 'flower', imageCount: 1 })]);
|
expect(result).toEqual([expect.objectContaining({ aliasName: 'flowers', name: 'flower', imageCount: 1 })]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return suggestions sorted by image count', () => {
|
it('should return suggestions sorted by image count', () => {
|
||||||
const result = localAc.matchPrefix(termStem, defaultK);
|
const result = localAutocomplete.matchPrefix(termStem, defaultK);
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
expect.objectContaining({ aliasName: 'forest', name: 'forest', imageCount: 3 }),
|
expect.objectContaining({ aliasName: 'forest', name: 'forest', imageCount: 3 }),
|
||||||
expect.objectContaining({ aliasName: 'fog', name: 'fog', imageCount: 1 }),
|
expect.objectContaining({ aliasName: 'fog', name: 'fog', imageCount: 1 }),
|
||||||
|
@ -77,25 +77,25 @@ describe('Local Autocompleter', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return namespaced suggestions without including namespace', () => {
|
it('should return namespaced suggestions without including namespace', () => {
|
||||||
const result = localAc.matchPrefix('test', defaultK);
|
const result = localAutocomplete.matchPrefix('test', defaultK);
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
expect.objectContaining({ aliasName: 'artist:test', name: 'artist:test', imageCount: 1 }),
|
expect.objectContaining({ aliasName: 'artist:test', name: 'artist:test', imageCount: 1 }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return only the required number of suggestions', () => {
|
it('should return only the required number of suggestions', () => {
|
||||||
const result = localAc.matchPrefix(termStem, 1);
|
const result = localAutocomplete.matchPrefix(termStem, 1);
|
||||||
expect(result).toEqual([expect.objectContaining({ aliasName: 'forest', name: 'forest', imageCount: 3 })]);
|
expect(result).toEqual([expect.objectContaining({ aliasName: 'forest', name: 'forest', imageCount: 3 })]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT return suggestions associated with hidden tags', () => {
|
it('should NOT return suggestions associated with hidden tags', () => {
|
||||||
window.booru.hiddenTagList = [1];
|
window.booru.hiddenTagList = [1];
|
||||||
const result = localAc.matchPrefix(termStem, defaultK);
|
const result = localAutocomplete.matchPrefix(termStem, defaultK);
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return empty array for empty prefix', () => {
|
it('should return empty array for empty prefix', () => {
|
||||||
const result = localAc.matchPrefix('', defaultK);
|
const result = localAutocomplete.matchPrefix('', defaultK);
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -75,6 +75,11 @@ export class SuggestionsPopup {
|
||||||
}
|
}
|
||||||
|
|
||||||
private watchItem(listItem: HTMLElement, suggestion: TermSuggestion) {
|
private watchItem(listItem: HTMLElement, suggestion: TermSuggestion) {
|
||||||
|
// This makes sure the item isn't selected if the mouse pointer happens to
|
||||||
|
// be right on top of the item when the list is rendered. So, the item may
|
||||||
|
// only be selected on the first `mousemove` event occurring on the element.
|
||||||
|
// See more details about this problem in the PR description:
|
||||||
|
// https://github.com/philomena-dev/philomena/pull/350
|
||||||
mouseMoveThenOver(listItem, () => this.updateSelection(listItem));
|
mouseMoveThenOver(listItem, () => this.updateSelection(listItem));
|
||||||
|
|
||||||
listItem.addEventListener('mouseout', () => this.clearSelection());
|
listItem.addEventListener('mouseout', () => this.clearSelection());
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
.field
|
.field
|
||||||
= label f, :spoilered_complex_str, "Complex Spoiler Filter"
|
= label f, :spoilered_complex_str, "Complex Spoiler Filter"
|
||||||
br
|
br
|
||||||
= textarea f, :spoilered_complex_str, class: "input input--wide", autocapitalize: "none", data: [ac: "true", ac_min_length: 3, ac_mode: "search"]
|
= textarea f, :spoilered_complex_str, class: "input input--wide", autocapitalize: "none", data: [autocomplete: "true", autocomplete_min_length: 3, autocomplete_mode: "search"]
|
||||||
br
|
br
|
||||||
= error_tag f, :spoilered_complex_str
|
= error_tag f, :spoilered_complex_str
|
||||||
.fieldlabel
|
.fieldlabel
|
||||||
|
@ -51,7 +51,7 @@
|
||||||
.field
|
.field
|
||||||
= label f, :hidden_complex_str, "Complex Hide Filter"
|
= label f, :hidden_complex_str, "Complex Hide Filter"
|
||||||
br
|
br
|
||||||
= textarea f, :hidden_complex_str, class: "input input--wide", autocapitalize: "none", data: [ac: "true", ac_min_length: 3, ac_mode: "search"]
|
= textarea f, :hidden_complex_str, class: "input input--wide", autocapitalize: "none", data: [autocomplete: "true", autocomplete_min_length: 3, autocomplete_mode: "search"]
|
||||||
br
|
br
|
||||||
= error_tag f, :hidden_complex_str
|
= error_tag f, :hidden_complex_str
|
||||||
.fieldlabel
|
.fieldlabel
|
||||||
|
|
|
@ -12,7 +12,21 @@ header.header
|
||||||
i.fa.fa-upload
|
i.fa.fa-upload
|
||||||
|
|
||||||
= form_for @conn, ~p"/search", [method: "get", class: "header__search flex flex--no-wrap flex--centered", enforce_utf8: false], fn f ->
|
= form_for @conn, ~p"/search", [method: "get", class: "header__search flex flex--no-wrap flex--centered", enforce_utf8: false], fn f ->
|
||||||
input.input.header__input.header__input--search#q name="q" title="For terms all required, separate with ',' or 'AND'; also supports 'OR' for optional terms and '-' or 'NOT' for negation. Search with a blank query for more options or click the ? for syntax help." value=@conn.params["q"] placeholder="Search" autocapitalize="none" data-ac="true" data-ac-min-length="3" data-ac-mode="search"
|
- title = \
|
||||||
|
"For terms all required, separate with ',' or 'AND'; also supports 'OR' " <> \
|
||||||
|
"for optional terms and '-' or 'NOT' for negation. Search with a blank " <> \
|
||||||
|
"query for more options or click the ? for syntax help"
|
||||||
|
|
||||||
|
input.input.header__input.header__input--search#q[
|
||||||
|
name="q"
|
||||||
|
title=title
|
||||||
|
value=@conn.params["q"]
|
||||||
|
placeholder="Search"
|
||||||
|
autocapitalize="none"
|
||||||
|
data-autocomplete="true"
|
||||||
|
data-autocomplete-min-length="3"
|
||||||
|
data-autocomplete-mode="search"
|
||||||
|
]
|
||||||
|
|
||||||
= if present?(@conn.params["sf"]) do
|
= if present?(@conn.params["sf"]) do
|
||||||
input type="hidden" name="sf" value=@conn.params["sf"]
|
input type="hidden" name="sf" value=@conn.params["sf"]
|
||||||
|
|
|
@ -7,10 +7,10 @@
|
||||||
.field
|
.field
|
||||||
p
|
p
|
||||||
label for="tag_name"
|
label for="tag_name"
|
||||||
' Artist Link validation is intended for artists. Validating your link will give you control over your content on the site, allowing you to create a
|
' Artist Link validation is intended for artists. Validating your link will give you control over your content on the site, allowing you to create a
|
||||||
a> href="/commissions" commissions
|
a> href="/commissions" commissions
|
||||||
' listing and request takedowns or DNPs. Do not request a link if the source contains no artwork which you have created.
|
' listing and request takedowns or DNPs. Do not request a link if the source contains no artwork which you have created.
|
||||||
= text_input f, :tag_name, value: assigns[:tag_name], class: "input", autocomplete: "off", placeholder: "artist:your-name", data: [ac: "true", ac_min_length: "3", ac_source: "/autocomplete/tags?term="]
|
= text_input f, :tag_name, value: assigns[:tag_name], class: "input", autocomplete: "off", placeholder: "artist:your-name", data: [autocomplete: "true", autocomplete_min_length: "3", autocomplete_source: "/autocomplete/tags?term="]
|
||||||
= error_tag f, :tag
|
= error_tag f, :tag
|
||||||
|
|
||||||
.field
|
.field
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
h1 Search
|
h1 Search
|
||||||
|
|
||||||
= form_for :search, ~p"/search", [id: "searchform", method: "get", class: "js-search-form", enforce_utf8: false], fn f ->
|
= form_for :search, ~p"/search", [id: "searchform", method: "get", class: "js-search-form", enforce_utf8: false], fn f ->
|
||||||
= text_input f, :q, class: "input input--wide js-search-field", placeholder: "Search terms are chained with commas", autocapitalize: "none", name: "q", value: @conn.params["q"], data: [ac: "true", ac_min_length: 3, ac_mode: "search"]
|
= text_input f, :q, class: "input input--wide js-search-field", placeholder: "Search terms are chained with commas", autocapitalize: "none", name: "q", value: @conn.params["q"], data: [autocomplete: "true", autocomplete_min_length: 3, autocomplete_mode: "search"]
|
||||||
|
|
||||||
.block
|
.block
|
||||||
.block__header.flex
|
.block__header.flex
|
||||||
|
|
|
@ -9,8 +9,28 @@ elixir:
|
||||||
.js-tag-block class="fancy-tag-#{@type}"
|
.js-tag-block class="fancy-tag-#{@type}"
|
||||||
= textarea @f, @name, html_options
|
= textarea @f, @name, html_options
|
||||||
.js-taginput.input.input--wide.tagsinput.hidden class="js-taginput-fancy" data-click-focus=".js-taginput-input.js-taginput-#{@name}"
|
.js-taginput.input.input--wide.tagsinput.hidden class="js-taginput-fancy" data-click-focus=".js-taginput-input.js-taginput-#{@name}"
|
||||||
input.input class="js-taginput-input js-taginput-#{@name}" id="taginput-fancy-#{@name}" type="text" placeholder="add a tag" autocomplete="off" autocapitalize="none" data-ac="true" data-ac-min-length="3" data-ac-source="/autocomplete/tags?term="
|
input.input[
|
||||||
button.button.button--state-primary.button--bold class="js-taginput-show" data-click-show=".js-taginput-fancy,.js-taginput-hide" data-click-hide=".js-taginput-plain,.js-taginput-show" data-click-focus=".js-taginput-input.js-taginput-#{@name}"
|
class="js-taginput-input js-taginput-#{@name}"
|
||||||
' Fancy Editor
|
id="taginput-fancy-#{@name}"
|
||||||
button.hidden.button.button--state-primary.button--bold class="js-taginput-hide" data-click-show=".js-taginput-plain,.js-taginput-show" data-click-hide=".js-taginput-fancy,.js-taginput-hide" data-click-focus=".js-taginput-plain.js-taginput-#{@name}"
|
type="text"
|
||||||
' Plain Editor
|
placeholder="add a tag"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="none"
|
||||||
|
data-autocomplete="true"
|
||||||
|
data-autocomplete-min-length="3"
|
||||||
|
data-autocomplete-source="/autocomplete/tags?term="
|
||||||
|
]
|
||||||
|
button.button.button--state-primary.button--bold[
|
||||||
|
class="js-taginput-show"
|
||||||
|
data-click-show=".js-taginput-fancy,.js-taginput-hide"
|
||||||
|
data-click-hide=".js-taginput-plain,.js-taginput-show"
|
||||||
|
data-click-focus=".js-taginput-input.js-taginput-#{@name}"
|
||||||
|
]
|
||||||
|
| Fancy Editor
|
||||||
|
button.hidden.button.button--state-primary.button--bold[
|
||||||
|
class="js-taginput-hide"
|
||||||
|
data-click-show=".js-taginput-plain,.js-taginput-show"
|
||||||
|
data-click-hide=".js-taginput-fancy,.js-taginput-hide"
|
||||||
|
data-click-focus=".js-taginput-plain.js-taginput-#{@name}"
|
||||||
|
]
|
||||||
|
| Plain Editor
|
||||||
|
|
Loading…
Reference in a new issue