mirror of
https://github.com/philomena-dev/philomena.git
synced 2025-03-17 17:10:03 +01:00
Merge pull request #458 from MareStare/feat/autocomplete-history-full
Full autocomplete history feature
This commit is contained in:
commit
28cd6ad987
36 changed files with 2219 additions and 822 deletions
|
@ -165,5 +165,14 @@
|
|||
--dnp-warning-hover-color: hsl(from $vote-down-color h s calc(l + 10));
|
||||
--poll-form-label-background: hsl(from $border-color h s calc(l + 8));
|
||||
--tag-dropdown-hover-background: hsl(from $meta-color h s calc(l - 4));
|
||||
|
||||
--autocomplete-history-color: var(--block-header-link-text-color);
|
||||
--autocomplete-history-match-color: hsl(from var(--block-header-link-text-color) h s calc(l + 20));
|
||||
|
||||
--autocomplete-tag-color: hsl(from var(--foreground-color) h s calc(l - 5));
|
||||
--autocomplete-tag-match-color: hsl(from var(--foreground-color) h s calc(l + 20));
|
||||
--autocomplete-tag-count-color: var(--foreground-half-color);
|
||||
|
||||
--autocomplete-match-selected-color: hsl(from var(--background-color) h s calc(l + 10));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -162,5 +162,14 @@
|
|||
--dnp-warning-hover-color: hsl(from $vote-down-color h s calc(l + 10));
|
||||
--poll-form-label-background: hsl(from $base-color h calc(s - 16) calc(l + 36));
|
||||
--tag-dropdown-hover-background: hsl(from $foreground-color h s calc(l - 10));
|
||||
|
||||
--autocomplete-history-color: var(--block-header-link-text-color);
|
||||
--autocomplete-history-match-color: hsl(from var(--block-header-link-text-color) h calc(s + 40) calc(l - 15));
|
||||
|
||||
--autocomplete-tag-color: hsl(from var(--foreground-color) h s calc(l + 20));
|
||||
--autocomplete-tag-match-color: hsl(from var(--foreground-color) h s calc(l - 20));
|
||||
--autocomplete-tag-count-color: var(--foreground-half-color);
|
||||
|
||||
--autocomplete-match-selected-color: hsl(from var(--background-color) h s calc(l + 10));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,27 +24,91 @@
|
|||
}
|
||||
|
||||
/* Autocomplete */
|
||||
.autocomplete__list {
|
||||
.autocomplete {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
z-index: 999;
|
||||
font-family: var(--font-family-monospace);
|
||||
background: var(--background-color);
|
||||
|
||||
/* Borders */
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-top-width: 0;
|
||||
border-color: var(--meta-border-color);
|
||||
|
||||
/* Poor man's hack to make sure autocomplete doesn't grow beyond the viewport */
|
||||
max-width: 70vw;
|
||||
}
|
||||
|
||||
.autocomplete__separator {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.autocomplete__item {
|
||||
background: var(--base-color);
|
||||
color: var(--link-light-color);
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.autocomplete__item--selected {
|
||||
background: var(--link-light-color);
|
||||
color: var(--base-color);
|
||||
.autocomplete__item__content {
|
||||
/* Squash overly long suggestions */
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.autocomplete__item__tag {
|
||||
color: var(--autocomplete-tag-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.autocomplete__item__history {
|
||||
color: var(--autocomplete-history-color);
|
||||
}
|
||||
|
||||
.autocomplete__item__history__icon {
|
||||
/*
|
||||
Makes the history icon aligned in width with the autocomplete__item__tag's icon.
|
||||
Yes, it's a dirty hack, don't look at me like that >_<, but turns out font-awesome
|
||||
icons aren't actually all of the same size!
|
||||
*/
|
||||
font-size: 11.38px;
|
||||
}
|
||||
|
||||
.autocomplete__item__history__match {
|
||||
font-weight: bold;
|
||||
|
||||
/* Use a lighter color to highlight the matched part of the query */
|
||||
color: var(--autocomplete-history-match-color);
|
||||
}
|
||||
|
||||
.autocomplete__item__tag__match {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.autocomplete__item__tag__match:not(.autocomplete__item--selected) {
|
||||
/* Use a lighter color to highlight the matched part of the query */
|
||||
color: var(--autocomplete-tag-match-color);
|
||||
}
|
||||
|
||||
.autocomplete__item__tag__count {
|
||||
color: var(--autocomplete-tag-count-color);
|
||||
}
|
||||
|
||||
.autocomplete__item:hover:not(.autocomplete__item--selected) {
|
||||
background: var(--autocomplete-match-selected-color);
|
||||
}
|
||||
|
||||
.autocomplete__item--selected,
|
||||
.autocomplete__item--selected .autocomplete__item__history__match,
|
||||
.autocomplete__item--selected .autocomplete__item__tag__match {
|
||||
background: var(--foreground-color);
|
||||
color: var(--background-color);
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { $, $$, hideEl } from '../utils/dom';
|
||||
import { assertNotNull } from '../utils/assert';
|
||||
import { TermSuggestion } from '../utils/suggestions';
|
||||
import { setupTagsInput, addTag, reloadTagsInput } from '../tagsinput';
|
||||
|
||||
const formData = `<form class="tags-form">
|
||||
|
@ -96,7 +95,7 @@ describe('Fancy tags input', () => {
|
|||
|
||||
it('should respond to autocomplete events', () => {
|
||||
setupTagsInput(tagBlock);
|
||||
fancyText.dispatchEvent(new CustomEvent<TermSuggestion>('autocomplete', { detail: { value: 'a', label: 'a' } }));
|
||||
fancyText.dispatchEvent(new CustomEvent<string>('autocomplete', { detail: 'a' }));
|
||||
expect($$('span.tag', fancyInput)).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
|
|
@ -1,292 +0,0 @@
|
|||
/**
|
||||
* Autocomplete.
|
||||
*/
|
||||
|
||||
import { LocalAutocompleter } from './utils/local-autocompleter';
|
||||
import { getTermContexts } from './match_query';
|
||||
import store from './utils/store';
|
||||
import { TermContext } from './query/lex';
|
||||
import { $$ } from './utils/dom';
|
||||
import {
|
||||
formatLocalAutocompleteResult,
|
||||
fetchLocalAutocomplete,
|
||||
fetchSuggestions,
|
||||
SuggestionsPopup,
|
||||
TermSuggestion,
|
||||
} from './utils/suggestions';
|
||||
|
||||
type AutocompletableInputElement = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
function hasAutocompleteEnabled(element: unknown): element is AutocompletableInputElement {
|
||||
return (
|
||||
(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) &&
|
||||
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();
|
||||
|
||||
function isSearchField(targetInput: HTMLElement): boolean {
|
||||
return targetInput.dataset.autocompleteMode === 'search';
|
||||
}
|
||||
|
||||
function restoreOriginalValue() {
|
||||
if (!inputField) return;
|
||||
|
||||
if (isSearchField(inputField) && originalQuery) {
|
||||
inputField.value = originalQuery;
|
||||
|
||||
if (selectedTerm) {
|
||||
const [, selectedTermEnd] = selectedTerm[0];
|
||||
|
||||
inputField.setSelectionRange(selectedTermEnd, selectedTermEnd);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (originalTerm) {
|
||||
inputField.value = originalTerm;
|
||||
}
|
||||
}
|
||||
|
||||
function applySelectedValue(selection: string) {
|
||||
if (!inputField) return;
|
||||
|
||||
if (!isSearchField(inputField)) {
|
||||
let resultValue = selection;
|
||||
|
||||
if (originalTerm?.startsWith('-')) {
|
||||
resultValue = `-${selection}`;
|
||||
}
|
||||
|
||||
inputField.value = resultValue;
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedTerm && originalQuery) {
|
||||
const [startIndex, endIndex] = selectedTerm[0];
|
||||
inputField.value = originalQuery.slice(0, startIndex) + selection + originalQuery.slice(endIndex);
|
||||
inputField.setSelectionRange(startIndex + selection.length, startIndex + selection.length);
|
||||
inputField.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function isSelectionOutsideCurrentTerm(): boolean {
|
||||
if (!inputField || !selectedTerm) return true;
|
||||
if (inputField.selectionStart === null || inputField.selectionEnd === null) return true;
|
||||
|
||||
const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd);
|
||||
const [startIndex, endIndex] = selectedTerm[0];
|
||||
|
||||
return startIndex > selectionIndex || endIndex < selectionIndex;
|
||||
}
|
||||
|
||||
function keydownHandler(event: KeyboardEvent) {
|
||||
if (inputField !== event.currentTarget) return;
|
||||
|
||||
if (inputField && isSearchField(inputField)) {
|
||||
// Prevent submission of the search field when Enter was hit
|
||||
if (popup.selectedTerm && event.keyCode === 13) event.preventDefault(); // Enter
|
||||
|
||||
// Close autocompletion popup when text cursor is outside current tag
|
||||
if (selectedTerm && (event.keyCode === 37 || event.keyCode === 39)) {
|
||||
// ArrowLeft || ArrowRight
|
||||
requestAnimationFrame(() => {
|
||||
if (isSelectionOutsideCurrentTerm()) popup.hide();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!popup.isActive) return;
|
||||
|
||||
if (event.keyCode === 38) popup.selectPrevious(); // ArrowUp
|
||||
if (event.keyCode === 40) popup.selectNext(); // ArrowDown
|
||||
if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) popup.hide(); // Enter || Esc || Comma
|
||||
if (event.keyCode === 38 || event.keyCode === 40) {
|
||||
// ArrowUp || ArrowDown
|
||||
if (popup.selectedTerm) {
|
||||
applySelectedValue(popup.selectedTerm);
|
||||
} else {
|
||||
restoreOriginalValue();
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function findSelectedTerm(targetInput: AutocompletableInputElement, searchQuery: string): TermContext | null {
|
||||
if (targetInput.selectionStart === null || targetInput.selectionEnd === null) return null;
|
||||
|
||||
const selectionIndex = Math.min(targetInput.selectionStart, targetInput.selectionEnd);
|
||||
|
||||
// Multi-line textarea elements should treat each line as the different search queries. Here we're looking for the
|
||||
// actively edited line and use it instead of the whole value.
|
||||
const activeLineStart = searchQuery.slice(0, selectionIndex).lastIndexOf('\n') + 1;
|
||||
const lengthAfterSelectionIndex = Math.max(searchQuery.slice(selectionIndex).indexOf('\n'), 0);
|
||||
const targetQuery = searchQuery.slice(activeLineStart, selectionIndex + lengthAfterSelectionIndex);
|
||||
|
||||
const terms = getTermContexts(targetQuery);
|
||||
const searchIndex = selectionIndex - activeLineStart;
|
||||
const term = terms.find(([range]) => range[0] < searchIndex && range[1] >= searchIndex) ?? null;
|
||||
|
||||
// Converting line-specific indexes back to absolute ones.
|
||||
if (term) {
|
||||
const [range] = term;
|
||||
|
||||
range[0] += activeLineStart;
|
||||
range[1] += activeLineStart;
|
||||
}
|
||||
|
||||
return term;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 searchFields = $$<AutocompletableInputElement>(
|
||||
'input[data-autocomplete][data-autocomplete-mode=search], textarea[data-autocomplete][data-autocomplete-mode=search]',
|
||||
);
|
||||
|
||||
for (const searchField of searchFields) {
|
||||
if (enable) {
|
||||
searchField.autocomplete = 'off';
|
||||
} else {
|
||||
searchField.removeAttribute('data-autocomplete');
|
||||
searchField.autocomplete = 'on';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function trimPrefixes(targetTerm: string): string {
|
||||
return targetTerm.trim().replace(/^-/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 localAutocomplete: LocalAutocompleter | null = null;
|
||||
|
||||
document.addEventListener('focusin', loadAutocompleteFromEvent);
|
||||
|
||||
document.addEventListener('input', event => {
|
||||
popup.hide();
|
||||
loadAutocompleteFromEvent(event);
|
||||
window.clearTimeout(serverSideSuggestionsTimeout);
|
||||
|
||||
if (!hasAutocompleteEnabled(event.target)) return;
|
||||
|
||||
const targetedInput = event.target;
|
||||
|
||||
targetedInput.addEventListener('keydown', keydownHandler as EventListener);
|
||||
|
||||
if (localAutocomplete !== null) {
|
||||
inputField = targetedInput;
|
||||
let suggestionsCount = 5;
|
||||
|
||||
if (isSearchField(inputField)) {
|
||||
originalQuery = inputField.value;
|
||||
selectedTerm = findSelectedTerm(inputField, originalQuery);
|
||||
suggestionsCount = 10;
|
||||
|
||||
// We don't need to run auto-completion if user is not selecting tag at all
|
||||
if (!selectedTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
originalTerm = selectedTerm[1].toLowerCase();
|
||||
} else {
|
||||
originalTerm = inputField.value.toLowerCase();
|
||||
}
|
||||
|
||||
const suggestions = localAutocomplete
|
||||
.matchPrefix(trimPrefixes(originalTerm), suggestionsCount)
|
||||
.map(formatLocalAutocompleteResult);
|
||||
|
||||
if (suggestions.length) {
|
||||
popup.renderSuggestions(suggestions).showForField(targetedInput);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { autocompleteMinLength: minTermLength, autocompleteSource: endpointUrl } = targetedInput.dataset;
|
||||
|
||||
if (!endpointUrl) return;
|
||||
|
||||
// Use a timeout to delay requests until the user has stopped typing
|
||||
serverSideSuggestionsTimeout = window.setTimeout(() => {
|
||||
inputField = targetedInput;
|
||||
originalTerm = inputField.value;
|
||||
|
||||
const fetchedTerm = trimPrefixes(inputField.value);
|
||||
|
||||
if (minTermLength && fetchedTerm.length < parseInt(minTermLength, 10)) return;
|
||||
|
||||
fetchSuggestions(endpointUrl, fetchedTerm).then(suggestions => {
|
||||
// inputField could get overwritten while the suggestions are being fetched - use previously targeted input
|
||||
if (fetchedTerm === trimPrefixes(targetedInput.value)) {
|
||||
popup.renderSuggestions(suggestions).showForField(targetedInput);
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// If there's a click outside the inputField, remove autocomplete
|
||||
document.addEventListener('click', event => {
|
||||
if (event.target && event.target !== inputField) popup.hide();
|
||||
if (inputField && event.target === inputField && isSearchField(inputField) && isSelectionOutsideCurrentTerm()) {
|
||||
popup.hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Lazy-load the local autocomplete index from the server only once.
|
||||
let localAutocompleteFetchNeeded = true;
|
||||
|
||||
async function loadAutocompleteFromEvent(event: Event) {
|
||||
if (!localAutocompleteFetchNeeded || !hasAutocompleteEnabled(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
localAutocompleteFetchNeeded = false;
|
||||
localAutocomplete = await fetchLocalAutocomplete();
|
||||
}
|
||||
|
||||
toggleSearchNativeAutocomplete();
|
||||
|
||||
popup.onItemSelected((event: CustomEvent<TermSuggestion>) => {
|
||||
if (!event.detail || !inputField) return;
|
||||
|
||||
const originalSuggestion = event.detail;
|
||||
applySelectedValue(originalSuggestion.value);
|
||||
|
||||
if (originalTerm?.startsWith('-')) {
|
||||
originalSuggestion.value = `-${originalSuggestion.value}`;
|
||||
}
|
||||
|
||||
inputField.dispatchEvent(
|
||||
new CustomEvent<TermSuggestion>('autocomplete', {
|
||||
detail: Object.assign(
|
||||
{
|
||||
type: 'click',
|
||||
},
|
||||
originalSuggestion,
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
269
assets/js/autocomplete/__tests__/context.ts
Normal file
269
assets/js/autocomplete/__tests__/context.ts
Normal file
|
@ -0,0 +1,269 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fetchMock } from '../../../test/fetch-mock';
|
||||
import { listenAutocomplete } from '..';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import { assertNotNull } from '../../utils/assert';
|
||||
import { TextInputElement } from '../input';
|
||||
import store from '../../utils/store';
|
||||
import { GetTagSuggestionsResponse } from 'autocomplete/client';
|
||||
|
||||
/**
|
||||
* A reusable test environment for autocompletion tests. Note that it does no
|
||||
* attempt to provide environment cleanup functionality. Yes, if you use this
|
||||
* in several tests in one file, then tests will conflict with each other.
|
||||
*
|
||||
* The main problem of implementing the cleanup here is that autocomplete code
|
||||
* adds event listeners to the `document` object. Some of them could be moved
|
||||
* to the `<body>` element, but events such as `'storage'` are only available
|
||||
* on the document object.
|
||||
*
|
||||
* Unfortunately, there isn't a good easy way to reload the DOM completely in
|
||||
* `jsdom`, so it's expected that you define a single test per file so that
|
||||
* `vitest` runs every test in an isolated process, where no cleanup is needed.
|
||||
*
|
||||
* I wish `vitest` actually did that by default, because cleanup logic and test
|
||||
* in-process test isolation is just boilerplate that we could avoid at this
|
||||
* scale at least.
|
||||
*/
|
||||
export class TestContext {
|
||||
private input: TextInputElement;
|
||||
private popup: HTMLElement;
|
||||
readonly fakeAutocompleteResponse: Response;
|
||||
|
||||
constructor(fakeAutocompleteResponse: Response) {
|
||||
this.fakeAutocompleteResponse = fakeAutocompleteResponse;
|
||||
|
||||
vi.useFakeTimers().setSystemTime(0);
|
||||
fetchMock.enableMocks();
|
||||
|
||||
// Our mock backend implementation.
|
||||
fetchMock.mockResponse(request => {
|
||||
if (request.url.includes('/autocomplete/compiled')) {
|
||||
return this.fakeAutocompleteResponse;
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.get('term')?.toLowerCase() !== 'mar') {
|
||||
const suggestions: GetTagSuggestionsResponse = { suggestions: [] };
|
||||
return JSON.stringify(suggestions);
|
||||
}
|
||||
|
||||
const suggestions: GetTagSuggestionsResponse = {
|
||||
suggestions: [
|
||||
{
|
||||
alias: 'marvelous',
|
||||
canonical: 'beautiful',
|
||||
images: 30,
|
||||
},
|
||||
{
|
||||
canonical: 'mare',
|
||||
images: 20,
|
||||
},
|
||||
{
|
||||
canonical: 'market',
|
||||
images: 10,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return JSON.stringify(suggestions);
|
||||
});
|
||||
|
||||
store.set('enable_search_ac', true);
|
||||
|
||||
document.body.innerHTML = `
|
||||
<form>
|
||||
<input
|
||||
class="test-input"
|
||||
data-autocomplete="multi-tags"
|
||||
data-autocomplete-condition="enable_search_ac"
|
||||
data-autocomplete-history-id="search-history"
|
||||
/>
|
||||
</form>
|
||||
`;
|
||||
|
||||
listenAutocomplete();
|
||||
|
||||
this.input = assertNotNull(document.querySelector('.test-input'));
|
||||
this.popup = assertNotNull(document.querySelector('.autocomplete'));
|
||||
|
||||
expect(fetch).not.toBeCalled();
|
||||
}
|
||||
|
||||
async submitForm(input?: string) {
|
||||
if (input) {
|
||||
await this.setInput(input);
|
||||
}
|
||||
|
||||
this.input.form!.submit();
|
||||
|
||||
await this.setInput('');
|
||||
}
|
||||
|
||||
async focusInput() {
|
||||
this.input.focus();
|
||||
await vi.runAllTimersAsync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the input to `value`. Allows for a special `<>` syntax. These characters
|
||||
* are removed from the input. Their position is used to set the selection.
|
||||
*
|
||||
* - `<` denotes the `selectionStart`
|
||||
* - `>` denotes the `selectionEnd`.
|
||||
*/
|
||||
async setInput(value: string) {
|
||||
if (document.activeElement !== this.input) {
|
||||
await this.focusInput();
|
||||
}
|
||||
|
||||
const valueChars = [...value];
|
||||
|
||||
const selectionStart = valueChars.indexOf('<');
|
||||
if (selectionStart >= 0) {
|
||||
valueChars.splice(selectionStart, 1);
|
||||
}
|
||||
|
||||
const selectionEnd = valueChars.indexOf('>');
|
||||
if (selectionEnd >= 0) {
|
||||
valueChars.splice(selectionEnd, 1);
|
||||
}
|
||||
|
||||
this.input.value = valueChars.join('');
|
||||
if (selectionStart >= 0) {
|
||||
this.input.selectionStart = selectionStart;
|
||||
}
|
||||
if (selectionEnd >= 0) {
|
||||
this.input.selectionEnd = selectionEnd;
|
||||
}
|
||||
|
||||
fireEvent.input(this.input, { target: { value: this.input.value } });
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
}
|
||||
|
||||
async keyDown(code: string, params?: { ctrlKey?: boolean }) {
|
||||
fireEvent.keyDown(this.input, { code, ...(params ?? {}) });
|
||||
await vi.runAllTimersAsync();
|
||||
}
|
||||
|
||||
expectRequests() {
|
||||
const snapshot = vi.mocked(fetch).mock.calls.map(([input]) => {
|
||||
const request = input as unknown as Request;
|
||||
const meta: Record<string, unknown> = {};
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
const methodAndUrl = `${request.method} ${url}`;
|
||||
|
||||
if (request.credentials !== 'same-origin') {
|
||||
meta.credentials = request.credentials;
|
||||
}
|
||||
|
||||
if (request.cache !== 'default') {
|
||||
meta.cache = request.cache;
|
||||
}
|
||||
|
||||
if (Object.getOwnPropertyNames(meta).length === 0) {
|
||||
return methodAndUrl;
|
||||
}
|
||||
|
||||
return {
|
||||
dest: methodAndUrl,
|
||||
meta,
|
||||
};
|
||||
});
|
||||
|
||||
return expect(snapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* The snapshot of the UI uses some special syntax like `<>` to denote the
|
||||
* selection start (`<`) and end (`>`), as well as some markers for the
|
||||
* currently selected item and history suggestions.
|
||||
*/
|
||||
expectUi() {
|
||||
const input = this.inputSnapshot();
|
||||
const suggestions = this.suggestionsSnapshot();
|
||||
|
||||
return expect({ input, suggestions });
|
||||
}
|
||||
|
||||
suggestionsSnapshot() {
|
||||
const { popup } = this;
|
||||
|
||||
if (popup.classList.contains('hidden')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...popup.children].map(el => {
|
||||
if (el.tagName === 'HR') {
|
||||
return '-----------';
|
||||
}
|
||||
|
||||
let content = el.textContent!.trim();
|
||||
|
||||
if (el.classList.contains('autocomplete__item__history')) {
|
||||
content = `(history) ${content}`;
|
||||
}
|
||||
|
||||
if (el.classList.contains('autocomplete__item--selected')) {
|
||||
return `👉 ${content}`;
|
||||
}
|
||||
return content;
|
||||
});
|
||||
}
|
||||
|
||||
inputSnapshot() {
|
||||
const { input } = this;
|
||||
|
||||
const value = [...input.value];
|
||||
|
||||
if (input.selectionStart) {
|
||||
value.splice(input.selectionStart, 0, '<');
|
||||
}
|
||||
|
||||
if (input.selectionEnd) {
|
||||
const shift = input.selectionStart && input.selectionStart <= input.selectionEnd ? 1 : 0;
|
||||
|
||||
value.splice(input.selectionEnd + shift, 0, '>');
|
||||
}
|
||||
|
||||
return value.join('');
|
||||
}
|
||||
}
|
||||
|
||||
export async function init(): Promise<TestContext> {
|
||||
const fakeAutocompleteBuffer = await fs.promises
|
||||
.readFile(path.join(__dirname, '../../utils/__tests__/autocomplete-compiled-v2.bin'))
|
||||
.then(({ buffer }) => new Response(buffer));
|
||||
|
||||
const ctx = new TestContext(fakeAutocompleteBuffer);
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
|
||||
// Initialize the lazy autocomplete index cache
|
||||
await ctx.focusInput();
|
||||
|
||||
ctx.expectRequests().toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"dest": "GET http://localhost:3000/autocomplete/compiled?vsn=2&key=1970-0-1",
|
||||
"meta": {
|
||||
"cache": "force-cache",
|
||||
"credentials": "omit",
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "",
|
||||
"suggestions": [],
|
||||
}
|
||||
`);
|
||||
|
||||
return ctx;
|
||||
}
|
102
assets/js/autocomplete/__tests__/history.spec.ts
Normal file
102
assets/js/autocomplete/__tests__/history.spec.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
import { init } from './context';
|
||||
|
||||
it('records search history', async () => {
|
||||
const ctx = await init();
|
||||
|
||||
await ctx.submitForm('foo1');
|
||||
|
||||
// Empty input should show all latest history items
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "",
|
||||
"suggestions": [
|
||||
"(history) foo1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.submitForm('foo2');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "",
|
||||
"suggestions": [
|
||||
"(history) foo2",
|
||||
"(history) foo1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.submitForm('a complex OR (query AND bar)');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "",
|
||||
"suggestions": [
|
||||
"(history) a complex OR (query AND bar)",
|
||||
"(history) foo2",
|
||||
"(history) foo1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
// Last recently used item should be on top
|
||||
await ctx.submitForm('foo2');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "",
|
||||
"suggestions": [
|
||||
"(history) foo2",
|
||||
"(history) a complex OR (query AND bar)",
|
||||
"(history) foo1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.setInput('a com');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "a com<>",
|
||||
"suggestions": [
|
||||
"(history) a complex OR (query AND bar)",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.setInput('f');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "f<>",
|
||||
"suggestions": [
|
||||
"(history) foo2",
|
||||
"(history) foo1",
|
||||
"-----------",
|
||||
"forest 3",
|
||||
"fog 1",
|
||||
"force field 1",
|
||||
"flower 1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
// History items must be selectable
|
||||
await ctx.keyDown('ArrowDown');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "foo2<>",
|
||||
"suggestions": [
|
||||
"👉 (history) foo2",
|
||||
"(history) foo1",
|
||||
"-----------",
|
||||
"forest 3",
|
||||
"fog 1",
|
||||
"force field 1",
|
||||
"flower 1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
114
assets/js/autocomplete/__tests__/keyboard.spec.ts
Normal file
114
assets/js/autocomplete/__tests__/keyboard.spec.ts
Normal file
|
@ -0,0 +1,114 @@
|
|||
import { init } from './context';
|
||||
|
||||
it('supports navigation via keyboard', async () => {
|
||||
const ctx = await init();
|
||||
|
||||
await ctx.setInput('f');
|
||||
|
||||
await ctx.keyDown('ArrowDown');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "forest<>",
|
||||
"suggestions": [
|
||||
"👉 forest 3",
|
||||
"fog 1",
|
||||
"force field 1",
|
||||
"flower 1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.keyDown('ArrowDown');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "fog<>",
|
||||
"suggestions": [
|
||||
"forest 3",
|
||||
"👉 fog 1",
|
||||
"force field 1",
|
||||
"flower 1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.keyDown('ArrowDown', { ctrlKey: true });
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "flower<>",
|
||||
"suggestions": [
|
||||
"forest 3",
|
||||
"fog 1",
|
||||
"force field 1",
|
||||
"👉 flower 1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.keyDown('ArrowUp', { ctrlKey: true });
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "forest<>",
|
||||
"suggestions": [
|
||||
"👉 forest 3",
|
||||
"fog 1",
|
||||
"force field 1",
|
||||
"flower 1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.keyDown('Enter');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "forest<>",
|
||||
"suggestions": [],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.setInput('forest, t<>, safe');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "forest, t<>, safe",
|
||||
"suggestions": [
|
||||
"artist:test 1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.keyDown('ArrowDown');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "forest, artist:test<>, safe",
|
||||
"suggestions": [
|
||||
"👉 artist:test 1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.keyDown('Escape');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "forest, artist:test<>, safe",
|
||||
"suggestions": [],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.setInput('forest, t<>, safe');
|
||||
await ctx.keyDown('ArrowDown');
|
||||
await ctx.keyDown('Enter');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "forest, artist:test<>, safe",
|
||||
"suggestions": [],
|
||||
}
|
||||
`);
|
||||
});
|
|
@ -0,0 +1,48 @@
|
|||
import { init } from './context';
|
||||
|
||||
it('requests server-side autocomplete if local autocomplete returns no results', async () => {
|
||||
const ctx = await init();
|
||||
|
||||
await ctx.setInput('mar');
|
||||
|
||||
// 1. Request the local autocomplete index.
|
||||
// 2. Request the server-side suggestions.
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "mar<>",
|
||||
"suggestions": [
|
||||
"marvelous → beautiful 30",
|
||||
"mare 20",
|
||||
"market 10",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.setInput('');
|
||||
|
||||
// Make sure the response caching is insensitive to term case and leading whitespace.
|
||||
await ctx.setInput('mar');
|
||||
await ctx.setInput(' mar');
|
||||
await ctx.setInput(' Mar');
|
||||
await ctx.setInput(' MAR');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": " MAR<>",
|
||||
"suggestions": [
|
||||
"marvelous → beautiful 30",
|
||||
"mare 20",
|
||||
"market 10",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Trailing whitespace is still significant because terms may have internal spaces.
|
||||
await ctx.setInput('mar ');
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
});
|
73
assets/js/autocomplete/client.ts
Normal file
73
assets/js/autocomplete/client.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import { HttpClient } from '../utils/http-client.ts';
|
||||
|
||||
export interface TagSuggestion {
|
||||
/**
|
||||
* If present, then this suggestion is for a tag alias.
|
||||
* If absent, then this suggestion is for the `canonical` tag name.
|
||||
*/
|
||||
alias?: null | string;
|
||||
|
||||
/**
|
||||
* The canonical name of the tag (non-alias).
|
||||
*/
|
||||
canonical: string;
|
||||
|
||||
/**
|
||||
* Number of images tagged with this tag.
|
||||
*/
|
||||
images: number;
|
||||
}
|
||||
|
||||
export interface GetTagSuggestionsResponse {
|
||||
suggestions: TagSuggestion[];
|
||||
}
|
||||
|
||||
export interface GetTagSuggestionsRequest {
|
||||
/**
|
||||
* Term to complete.
|
||||
*/
|
||||
term: string;
|
||||
|
||||
/**
|
||||
* Maximum number of suggestions to return.
|
||||
*/
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Autocomplete API client for Philomena backend.
|
||||
*/
|
||||
export class AutocompleteClient {
|
||||
private http: HttpClient = new HttpClient();
|
||||
|
||||
/**
|
||||
* Fetches server-side tag suggestions for the given term. The provided incomplete
|
||||
* term is expected to be normalized by the caller (i.e. lowercased and trimmed).
|
||||
* This is because the caller is responsible for caching the normalized term.
|
||||
*/
|
||||
async getTagSuggestions(request: GetTagSuggestionsRequest): Promise<GetTagSuggestionsResponse> {
|
||||
return this.http.fetchJson('/autocomplete/tags', {
|
||||
query: {
|
||||
vsn: '2',
|
||||
term: request.term,
|
||||
limit: request.limit.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues a GET request to fetch the compiled autocomplete index.
|
||||
*/
|
||||
async getCompiledAutocomplete(): Promise<ArrayBuffer> {
|
||||
const now = new Date();
|
||||
const key = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`;
|
||||
|
||||
const response = await this.http.fetch(`/autocomplete/compiled`, {
|
||||
query: { vsn: '2', key },
|
||||
credentials: 'omit',
|
||||
cache: 'force-cache',
|
||||
});
|
||||
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
}
|
76
assets/js/autocomplete/history/history.ts
Normal file
76
assets/js/autocomplete/history/history.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { HistoryStore } from './store';
|
||||
|
||||
/**
|
||||
* Maximum number of records we keep in the history. If the limit is reached,
|
||||
* the least popular records will be removed to make space for new ones.
|
||||
*/
|
||||
const maxRecords = 1000;
|
||||
|
||||
/**
|
||||
* Maximum length of the input content we store in the history. If the input
|
||||
* exceeds this value it won't be saved in the history.
|
||||
*/
|
||||
const maxInputLength = 256;
|
||||
|
||||
/**
|
||||
* Input history is a mini DB limited in size and stored in the `localStorage`.
|
||||
* It provides a simple CRUD API for the search history data.
|
||||
*
|
||||
* Note that `localStorage` is not transactional. Other browser tabs may modify
|
||||
* it concurrently, which may lead to version mismatches and potential TOCTOU
|
||||
* issues. However, search history data is not critical, and the probability of
|
||||
* concurrent usage patterns is almost 0. The worst thing that can happen in
|
||||
* such a rare scenario is that a search query may not be saved to the storage
|
||||
* or the search history may be temporarily disabled for the current session
|
||||
* until the page is reloaded with a newer version of the frontend code.
|
||||
*/
|
||||
export class InputHistory {
|
||||
private readonly store: HistoryStore;
|
||||
|
||||
/**
|
||||
* The list of history records sorted from the last recently used to the oldest unused.
|
||||
*/
|
||||
private records: string[];
|
||||
|
||||
constructor(store: HistoryStore) {
|
||||
this.store = store;
|
||||
|
||||
const parsing = performance.now();
|
||||
this.records = store.read();
|
||||
|
||||
const end = performance.now();
|
||||
console.debug(`Loading input history took ${end - parsing}ms. Records: ${this.records.length}.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the input into the history and commit it to the `localStorage`.
|
||||
* Expects a value trimmed from whitespace by the caller.
|
||||
*/
|
||||
write(input: string) {
|
||||
if (input === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.length > maxInputLength) {
|
||||
console.warn(`The input is too long to be saved in the search history (length: ${input.length}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const index = this.records.findIndex(historyRecord => historyRecord === input);
|
||||
|
||||
if (index >= 0) {
|
||||
this.records.splice(index, 1);
|
||||
} else if (this.records.length >= maxRecords) {
|
||||
this.records.pop();
|
||||
}
|
||||
|
||||
// Put the record on the top of the list as the last recently used.
|
||||
this.records.unshift(input);
|
||||
|
||||
this.store.write(this.records);
|
||||
}
|
||||
|
||||
listSuggestions(query: string, limit: number): string[] {
|
||||
return this.records.filter(record => record.startsWith(query)).slice(0, limit);
|
||||
}
|
||||
}
|
74
assets/js/autocomplete/history/index.ts
Normal file
74
assets/js/autocomplete/history/index.ts
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { HistorySuggestion } from '../../utils/suggestions';
|
||||
import { InputHistory } from './history';
|
||||
import { HistoryStore } from './store';
|
||||
import { AutocompletableInput } from '../input';
|
||||
|
||||
/**
|
||||
* Stores a set of histories identified by their unique IDs.
|
||||
*/
|
||||
class InputHistoriesPool {
|
||||
private histories = new Map<string, InputHistory>();
|
||||
|
||||
load(historyId: string): InputHistory {
|
||||
const existing = this.histories.get(historyId);
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const store = new HistoryStore(historyId);
|
||||
const newHistory = new InputHistory(store);
|
||||
this.histories.set(historyId, newHistory);
|
||||
|
||||
return newHistory;
|
||||
}
|
||||
}
|
||||
|
||||
const histories = new InputHistoriesPool();
|
||||
|
||||
export function listen() {
|
||||
// Only load the history for the input element when it gets focused.
|
||||
document.addEventListener('focusin', event => {
|
||||
const input = AutocompletableInput.fromElement(event.target);
|
||||
|
||||
if (!input?.historyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
histories.load(input.historyId);
|
||||
});
|
||||
|
||||
document.addEventListener('submit', event => {
|
||||
if (!(event.target instanceof HTMLFormElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = [...event.target.elements]
|
||||
.map(elem => AutocompletableInput.fromElement(elem))
|
||||
.find(it => it !== null && it.hasHistory());
|
||||
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
histories.load(input.historyId).write(input.snapshot.trimmedValue);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns suggestions based on history for the input. Unless the `limit` is
|
||||
* specified as an argument, it will return the maximum number of suggestions
|
||||
* allowed by the input.
|
||||
*/
|
||||
export function listSuggestions(input: AutocompletableInput, limit?: number): HistorySuggestion[] {
|
||||
if (!input.hasHistory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const value = input.snapshot.trimmedValue.toLowerCase();
|
||||
|
||||
return histories
|
||||
.load(input.historyId)
|
||||
.listSuggestions(value, limit ?? input.maxSuggestions)
|
||||
.map(content => new HistorySuggestion(content, value.length));
|
||||
}
|
96
assets/js/autocomplete/history/store.ts
Normal file
96
assets/js/autocomplete/history/store.ts
Normal file
|
@ -0,0 +1,96 @@
|
|||
import store from '../../utils/store';
|
||||
|
||||
/**
|
||||
* The root JSON object that contains the history records and is persisted to disk.
|
||||
*/
|
||||
interface History {
|
||||
/**
|
||||
* Used to track the version of the schema layout just in case we do any
|
||||
* breaking changes to this schema so that we can properly migrate old
|
||||
* search history data. It's also used to prevent older versions of
|
||||
* the frontend code from trying to use the newer incompatible schema they
|
||||
* know nothing about (extremely improbable, but just in case).
|
||||
*/
|
||||
schemaVersion: 1;
|
||||
|
||||
/**
|
||||
* The list of history records sorted from the last recently used to the oldest unused.
|
||||
*/
|
||||
records: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* History store backend is responsible for parsing and serializing the data
|
||||
* to/from `localStorage`. It handles versioning of the schema, and transparently
|
||||
* disables writing to the storage if the schema version is unknown to prevent
|
||||
* data loss (extremely improbable, but just in case).
|
||||
*/
|
||||
export class HistoryStore {
|
||||
private writable: boolean = true;
|
||||
private readonly key: string;
|
||||
|
||||
constructor(key: string) {
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
read(): string[] {
|
||||
return this.extractRecords(store.get<History>(this.key));
|
||||
}
|
||||
|
||||
write(records: string[]): void {
|
||||
if (!this.writable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const history: History = {
|
||||
schemaVersion: 1,
|
||||
records,
|
||||
};
|
||||
|
||||
const start = performance.now();
|
||||
store.set(this.key, history);
|
||||
|
||||
const end = performance.now();
|
||||
console.debug(
|
||||
`Writing ${records.length} history records to the localStorage took ${end - start}ms. ` +
|
||||
`Records: ${records.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the records from the history. To do this, we first need to migrate
|
||||
* the history object to the latest schema version if necessary.
|
||||
*/
|
||||
private extractRecords(history: History | null): string[] {
|
||||
// `null` here means we are starting from the initial state (empty list of records).
|
||||
if (history === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// We have only one version at the time of this writing, so we don't need
|
||||
// to do any migration yet. Hopefully we never need to do a breaking change
|
||||
// and this stays at version `1` forever.
|
||||
const latestSchemaVersion = 1;
|
||||
|
||||
switch (history.schemaVersion) {
|
||||
case latestSchemaVersion:
|
||||
return history.records;
|
||||
default:
|
||||
// It's very unlikely that we ever hit this branch.
|
||||
console.warn(
|
||||
`Unknown search history schema version: '${history.schemaVersion}'. ` +
|
||||
`This frontend code was built with the maximum supported schema version ` +
|
||||
`'${latestSchemaVersion}'. The search history will be disabled for this ` +
|
||||
`session to prevent potential history data loss. The cause of the version ` +
|
||||
`mismatch may be that a newer version of the frontend code is running in a ` +
|
||||
`separate tab, or you were mistakenly served with an older version of the ` +
|
||||
`frontend code.`,
|
||||
);
|
||||
|
||||
// Disallow writing to the storage to prevent data loss.
|
||||
this.writable = false;
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
410
assets/js/autocomplete/index.ts
Normal file
410
assets/js/autocomplete/index.ts
Normal file
|
@ -0,0 +1,410 @@
|
|||
import { LocalAutocompleter } from '../utils/local-autocompleter';
|
||||
import * as history from './history';
|
||||
import { AutocompletableInput, TextInputElement } from './input';
|
||||
import {
|
||||
SuggestionsPopup,
|
||||
Suggestions,
|
||||
TagSuggestion,
|
||||
Suggestion,
|
||||
HistorySuggestion,
|
||||
ItemSelectedEvent,
|
||||
} from '../utils/suggestions';
|
||||
import { $$ } from '../utils/dom';
|
||||
import { AutocompleteClient, GetTagSuggestionsRequest } from './client';
|
||||
import { DebouncedCache } from '../utils/debounced-cache';
|
||||
import store from '../utils/store';
|
||||
|
||||
// This lint is dumb, especially in this case because this type alias depends on
|
||||
// the `Autocomplete` symbol, and methods on the `Autocomplete` class depend on
|
||||
// this type alias, so either way there is a circular dependency in type annotations
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
type ActiveAutocomplete = Autocomplete & { input: AutocompletableInput };
|
||||
|
||||
function readHistoryConfig() {
|
||||
if (store.get<boolean>('autocomplete_search_history_hidden')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
maxSuggestionsWhenTyping: store.get<number>('autocomplete_search_history_max_suggestions_when_typing') ?? 3,
|
||||
};
|
||||
}
|
||||
|
||||
class Autocomplete {
|
||||
index: null | 'fetching' | 'unavailable' | LocalAutocompleter = null;
|
||||
input: AutocompletableInput | null = null;
|
||||
popup = new SuggestionsPopup();
|
||||
client = new AutocompleteClient();
|
||||
serverSideTagSuggestions = new DebouncedCache(this.client.getTagSuggestions.bind(this.client));
|
||||
|
||||
constructor() {
|
||||
this.popup.onItemSelected(this.confirmSuggestion.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-load the local autocomplete data.
|
||||
*/
|
||||
async fetchLocalAutocomplete() {
|
||||
if (this.index) {
|
||||
// The index is already either fetching or initialized/unavailable, so nothing to do.
|
||||
return;
|
||||
}
|
||||
|
||||
// Indicate that the index is in the process of fetching so that
|
||||
// we don't try to fetch it again while it's still loading.
|
||||
this.index = 'fetching';
|
||||
|
||||
try {
|
||||
const index = await this.client.getCompiledAutocomplete();
|
||||
this.index = new LocalAutocompleter(index);
|
||||
this.refresh();
|
||||
} catch (error) {
|
||||
this.index = 'unavailable';
|
||||
console.error('Failed to fetch local autocomplete data', error);
|
||||
}
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.serverSideTagSuggestions.abortLastSchedule('[Autocomplete] A new user input was received');
|
||||
|
||||
this.input = AutocompletableInput.fromElement(document.activeElement);
|
||||
if (!this.isActive()) {
|
||||
this.popup.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const { input } = this;
|
||||
|
||||
// Initiate the lazy local autocomplete fetch on background if it hasn't been done yet.
|
||||
this.fetchLocalAutocomplete();
|
||||
|
||||
const historyConfig = readHistoryConfig();
|
||||
|
||||
// Show all history suggestions if the input is empty.
|
||||
if (historyConfig && input.snapshot.trimmedValue === '') {
|
||||
this.showSuggestions({
|
||||
history: history.listSuggestions(input),
|
||||
tags: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// When the input is not empty the history suggestions take up
|
||||
// only a small portion of the suggestions.
|
||||
const suggestions: Suggestions = {
|
||||
history: historyConfig ? history.listSuggestions(input, historyConfig.maxSuggestionsWhenTyping) : [],
|
||||
tags: [],
|
||||
};
|
||||
|
||||
// There are several scenarios where we don't try to fetch server-side suggestions,
|
||||
// even if we could.
|
||||
//
|
||||
// 1. The `index` is still `fetching`.
|
||||
// We should wait until it's done. Doing concurrent server-side suggestions
|
||||
// request in this case would be optimistically wasteful.
|
||||
//
|
||||
// 2. The `index` is `unavailable`.
|
||||
// We shouldn't fetch server suggestions either because there may be something
|
||||
// horribly wrong on the backend, so we don't want to spam it with even more
|
||||
// requests. This scenario should be extremely rare though.
|
||||
if (
|
||||
!input.snapshot.activeTerm ||
|
||||
!(this.index instanceof LocalAutocompleter) ||
|
||||
suggestions.history.length === this.input.maxSuggestions
|
||||
) {
|
||||
this.showSuggestions(suggestions);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeTerm = input.snapshot.activeTerm.term;
|
||||
|
||||
suggestions.tags = this.index
|
||||
.matchPrefix(activeTerm, input.maxSuggestions - suggestions.history.length)
|
||||
.map(result => new TagSuggestion({ ...result, matchLength: activeTerm.length }));
|
||||
|
||||
// Used for debugging server-side completions, to ensure local autocomplete
|
||||
// doesn't prevent sever-side completions from being shown. Use these console
|
||||
// commands to enable/disable server-side completions:
|
||||
// ```js
|
||||
// localStorage.setItem('SERVER_SIDE_COMPLETIONS_ONLY', true)
|
||||
// localStorage.removeItem('SERVER_SIDE_COMPLETIONS_ONLY')
|
||||
// ```
|
||||
if (store.get('SERVER_SIDE_COMPLETIONS_ONLY')) {
|
||||
suggestions.tags = [];
|
||||
}
|
||||
|
||||
// Show suggestions that we already have early without waiting for a potential
|
||||
// server-side suggestions request.
|
||||
this.showSuggestions(suggestions);
|
||||
|
||||
// Only if the index had its chance to provide suggestions
|
||||
// and produced nothing, do we try to fetch server-side suggestions.
|
||||
if (suggestions.tags.length > 0 || activeTerm.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleServerSideSuggestions(activeTerm, suggestions.history);
|
||||
}
|
||||
|
||||
scheduleServerSideSuggestions(this: ActiveAutocomplete, term: string, historySuggestions: HistorySuggestion[]) {
|
||||
const request: GetTagSuggestionsRequest = {
|
||||
term,
|
||||
|
||||
// We always use the `maxSuggestions` value for the limit, because it's a
|
||||
// reasonably small and limited value. Yes, we may overfetch in some cases,
|
||||
// but otherwise the cache hits rate of `DebouncedCache` also increases due
|
||||
// to the less variation in the cache key (request params).
|
||||
limit: this.input.maxSuggestions,
|
||||
};
|
||||
|
||||
this.serverSideTagSuggestions.schedule(request, response => {
|
||||
if (!this.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Truncate the suggestions to the leftover space shared with history suggestions.
|
||||
const maxTags = this.input.maxSuggestions - historySuggestions.length;
|
||||
|
||||
const tags = response.suggestions.slice(0, maxTags).map(
|
||||
suggestion =>
|
||||
new TagSuggestion({
|
||||
...suggestion,
|
||||
matchLength: term.length,
|
||||
}),
|
||||
);
|
||||
|
||||
this.showSuggestions({
|
||||
history: historySuggestions,
|
||||
tags,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
showSuggestions(this: ActiveAutocomplete, suggestions: Suggestions) {
|
||||
this.popup.setSuggestions(suggestions).showForElement(this.input.element);
|
||||
}
|
||||
|
||||
onFocusIn() {
|
||||
// The purpose of `focusin` subscription is to bring up the popup with the
|
||||
// initial history suggestions if there is no popup yet. If there is a popup
|
||||
// already, e.g. when we are re-focusing back to the input after the user
|
||||
// selected some suggestion then there is no need to refresh the popup.
|
||||
if (!this.popup.isHidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The event we are processing comes before the input's selection is updated.
|
||||
// Defer the refresh to the next frame to get the updated selection.
|
||||
requestAnimationFrame(() => {
|
||||
// Double-check the popup is still hidden on a new spin of the event loop.
|
||||
// Just in case =)
|
||||
if (!this.popup.isHidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
onClick(event: MouseEvent) {
|
||||
if (this.input?.isEnabled() && this.input.element !== event.target) {
|
||||
// We lost focus. Hide the popup.
|
||||
// We use this method instead of the `focusout` event because this way it's
|
||||
// easier to work in the developer tools when you want to inspect the element.
|
||||
// When you inspect it, a `focusout` happens.
|
||||
this.popup.hide();
|
||||
this.input = null;
|
||||
}
|
||||
}
|
||||
|
||||
onKeyDown(event: KeyboardEvent) {
|
||||
if (!this.isActive() || this.input.element !== event.target) {
|
||||
return;
|
||||
}
|
||||
if ((event.key === ',' || event.code === 'Enter') && this.input.type === 'single-tag') {
|
||||
// Coma means the end of input for the current tag in single-tag mode.
|
||||
this.popup.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.code) {
|
||||
case 'Enter': {
|
||||
const { selectedSuggestion } = this.popup;
|
||||
if (!selectedSuggestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent submission of the form when Enter was hit.
|
||||
// Note, however, that `confirmSuggestion` may still submit the form
|
||||
// manually if the selected suggestion is a history suggestion and
|
||||
// no `Shift` key was pressed.
|
||||
event.preventDefault();
|
||||
|
||||
this.confirmSuggestion({
|
||||
suggestion: selectedSuggestion,
|
||||
shiftKey: event.shiftKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'Escape': {
|
||||
this.popup.hide();
|
||||
return;
|
||||
}
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight': {
|
||||
// The event we are processing comes before the input's selection is updated.
|
||||
// Defer the refresh to the next frame to get the updated selection.
|
||||
requestAnimationFrame(() => this.refresh());
|
||||
return;
|
||||
}
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown': {
|
||||
if (event.code === 'ArrowUp') {
|
||||
if (event.ctrlKey) {
|
||||
this.popup.selectCtrlUp();
|
||||
} else {
|
||||
this.popup.selectUp();
|
||||
}
|
||||
} else {
|
||||
if (event.ctrlKey) {
|
||||
this.popup.selectCtrlDown();
|
||||
} else {
|
||||
this.popup.selectDown();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.popup.selectedSuggestion) {
|
||||
this.updateInputWithSelectedValue(this.popup.selectedSuggestion);
|
||||
} else {
|
||||
this.updateInputWithOriginalValue();
|
||||
}
|
||||
|
||||
// Prevent the cursor from moving to the start or end of the input field,
|
||||
// which is the default behavior of the arrow keys are used in a text input.
|
||||
event.preventDefault();
|
||||
|
||||
return;
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
updateInputWithOriginalValue(this: ActiveAutocomplete) {
|
||||
const { element, snapshot } = this.input;
|
||||
const { selection } = snapshot;
|
||||
element.value = snapshot.origValue;
|
||||
element.setSelectionRange(selection.start, selection.end, selection.direction ?? undefined);
|
||||
}
|
||||
|
||||
confirmSuggestion({ suggestion, shiftKey, ctrlKey }: ItemSelectedEvent) {
|
||||
this.assertActive();
|
||||
|
||||
this.updateInputWithSelectedValue(suggestion);
|
||||
|
||||
const prefix = this.input.snapshot.activeTerm?.prefix ?? '';
|
||||
|
||||
const detail = `${prefix}${suggestion.value()}`;
|
||||
|
||||
const newEvent = new CustomEvent<string>('autocomplete', { detail });
|
||||
|
||||
this.input.element.dispatchEvent(newEvent);
|
||||
|
||||
if (ctrlKey || (suggestion instanceof HistorySuggestion && !shiftKey)) {
|
||||
// We use `requestSubmit()` instead of `submit()` because it triggers the
|
||||
// 'submit' event on the form. We have a handler subscribed to that event
|
||||
// that records the input's value for history tracking.
|
||||
this.input.element.form?.requestSubmit();
|
||||
}
|
||||
|
||||
// XXX: it's important to focus the input element first before hiding the popup,
|
||||
// because if we do it the other way around our `onFocusIn` handler will refresh
|
||||
// the popup and bring it back up, which is not what we want. We want to give a
|
||||
// brief moment of silence for the user without the popup before they type
|
||||
// something else, otherwise we'd show some more completions for the current term.
|
||||
this.input.element.focus();
|
||||
this.popup.hide();
|
||||
}
|
||||
|
||||
updateInputWithSelectedValue(this: ActiveAutocomplete, suggestion: Suggestion) {
|
||||
const {
|
||||
element,
|
||||
snapshot: { activeTerm, origValue },
|
||||
} = this.input;
|
||||
|
||||
const value = suggestion.value();
|
||||
|
||||
if (!activeTerm || suggestion instanceof HistorySuggestion) {
|
||||
element.value = value;
|
||||
return;
|
||||
}
|
||||
|
||||
const { range, prefix } = activeTerm;
|
||||
|
||||
element.value = origValue.slice(0, range.start) + prefix + value + origValue.slice(range.end);
|
||||
|
||||
const newCursorIndex = range.start + value.length;
|
||||
element.setSelectionRange(newCursorIndex, newCursorIndex);
|
||||
}
|
||||
|
||||
isActive(): this is ActiveAutocomplete {
|
||||
return Boolean(this.input?.isEnabled());
|
||||
}
|
||||
|
||||
assertActive(): asserts this is ActiveAutocomplete {
|
||||
if (this.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('Current input when the error happened', this.input);
|
||||
throw new Error(`BUG: expected autocomplete to be active, but it isn't`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 refreshNativeAutocomplete() {
|
||||
const elements = $$<TextInputElement>(
|
||||
'input[data-autocomplete][data-autocomplete-condition], ' +
|
||||
'textarea[data-autocomplete][data-autocomplete-condition]',
|
||||
);
|
||||
|
||||
for (const element of elements) {
|
||||
const input = AutocompletableInput.fromElement(element);
|
||||
if (!input) {
|
||||
continue;
|
||||
}
|
||||
|
||||
element.autocomplete = input.isEnabled() ? 'off' : 'on';
|
||||
}
|
||||
}
|
||||
|
||||
export function listenAutocomplete() {
|
||||
history.listen();
|
||||
|
||||
const autocomplete = new Autocomplete();
|
||||
|
||||
// Refresh all the state in case any autocomplete settings change.
|
||||
store.watchAll(key => {
|
||||
if (key && key !== 'enable_search_ac' && !key.startsWith('autocomplete')) {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshNativeAutocomplete();
|
||||
autocomplete.refresh();
|
||||
});
|
||||
|
||||
refreshNativeAutocomplete();
|
||||
|
||||
// By the time this script loads, the input elements may already be focused,
|
||||
// so we refresh the autocomplete state immediately to trigger the initial completions.
|
||||
autocomplete.refresh();
|
||||
|
||||
document.addEventListener('focusin', autocomplete.onFocusIn.bind(autocomplete));
|
||||
document.addEventListener('input', autocomplete.refresh.bind(autocomplete));
|
||||
document.addEventListener('click', autocomplete.onClick.bind(autocomplete));
|
||||
document.addEventListener('keydown', autocomplete.onKeyDown.bind(autocomplete));
|
||||
}
|
199
assets/js/autocomplete/input.ts
Normal file
199
assets/js/autocomplete/input.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -22,8 +22,15 @@ const tokenList: Token[] = [
|
|||
|
||||
export type ParseTerm = (term: string, fuzz: number, boost: number) => AstMatcher;
|
||||
|
||||
export type Range = [number, number];
|
||||
export type TermContext = [Range, string];
|
||||
export interface Range {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface TermContext {
|
||||
range: Range;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface LexResult {
|
||||
tokenList: TokenList;
|
||||
|
@ -61,7 +68,11 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR
|
|||
if (searchTerm !== null) {
|
||||
// Push to stack.
|
||||
ret.tokenList.push(parseTerm(searchTerm, fuzz, boost));
|
||||
ret.termContexts.push([[termIndex, termIndex + searchTerm.length], searchTerm]);
|
||||
|
||||
ret.termContexts.push({
|
||||
range: { start: termIndex, end: termIndex + searchTerm.length },
|
||||
content: searchTerm,
|
||||
});
|
||||
// Reset term and options data.
|
||||
boost = 1;
|
||||
fuzz = 0;
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
|
||||
import { assertNotNull, assertNotUndefined } from './utils/assert';
|
||||
import { $, $$ } from './utils/dom';
|
||||
import { $, $$, hideIf } from './utils/dom';
|
||||
import store from './utils/store';
|
||||
|
||||
function setupThemeSettings() {
|
||||
|
@ -28,17 +28,34 @@ function setupThemeSettings() {
|
|||
themeColorSelect.addEventListener('change', themePreviewCallback);
|
||||
}
|
||||
|
||||
function setupAutocompleteSettings() {
|
||||
const autocompleteSettings = assertNotNull($<HTMLElement>('.autocomplete-settings'));
|
||||
const autocompleteSearchHistorySettings = assertNotNull($<HTMLElement>('.autocomplete-search-history-settings'));
|
||||
const enableSearchAutocomplete = assertNotNull($<HTMLInputElement>('#user_enable_search_ac'));
|
||||
const userSearchHistoryHidden = assertNotNull($<HTMLInputElement>('#user_autocomplete_search_history_hidden'));
|
||||
|
||||
// Don't show search history settings if autocomplete is entirely disabled.
|
||||
enableSearchAutocomplete.addEventListener('change', () => {
|
||||
hideIf(!enableSearchAutocomplete.checked, autocompleteSettings);
|
||||
});
|
||||
|
||||
userSearchHistoryHidden.addEventListener('change', () => {
|
||||
hideIf(userSearchHistoryHidden.checked, autocompleteSearchHistorySettings);
|
||||
});
|
||||
}
|
||||
|
||||
export function setupSettings() {
|
||||
if (!$('#js-setting-table')) return;
|
||||
|
||||
const localCheckboxes = $$<HTMLInputElement>('[data-tab="local"] input[type="checkbox"]');
|
||||
|
||||
// Local settings
|
||||
localCheckboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', () => {
|
||||
store.set(checkbox.id.replace('user_', ''), checkbox.checked);
|
||||
});
|
||||
for (const input of $$<HTMLInputElement>('[data-tab="local"] input')) {
|
||||
input.addEventListener('change', () => {
|
||||
const newValue = input.type === 'checkbox' ? input.checked : input.value;
|
||||
|
||||
store.set(input.id.replace('user_', ''), newValue);
|
||||
});
|
||||
}
|
||||
|
||||
setupThemeSettings();
|
||||
setupAutocompleteSettings();
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
import { assertNotNull, assertType } from './utils/assert';
|
||||
import { $, $$, clearEl, removeEl, showEl, hideEl, escapeCss, escapeHtml } from './utils/dom';
|
||||
import { TermSuggestion } from './utils/suggestions';
|
||||
|
||||
export function setupTagsInput(tagBlock: HTMLDivElement) {
|
||||
const form = assertNotNull(tagBlock.closest('form'));
|
||||
|
@ -48,8 +47,8 @@ export function setupTagsInput(tagBlock: HTMLDivElement) {
|
|||
importTags();
|
||||
}
|
||||
|
||||
function handleAutocomplete(event: CustomEvent<TermSuggestion>) {
|
||||
insertTag(event.detail.value);
|
||||
function handleAutocomplete(event: CustomEvent<string>) {
|
||||
insertTag(event.detail);
|
||||
inputField.focus();
|
||||
}
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
findFirstTextNode,
|
||||
disableEl,
|
||||
enableEl,
|
||||
hideIf,
|
||||
} from '../dom';
|
||||
import { getRandomArrayItem, getRandomIntBetween } from '../../../test/randomness';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
|
@ -444,4 +445,18 @@ describe('DOM Utilities', () => {
|
|||
expect(result).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hideIf', () => {
|
||||
it('should add "hidden" class if condition is true', () => {
|
||||
const element = document.createElement('div');
|
||||
hideIf(true, element);
|
||||
expect(element).toHaveClass('hidden');
|
||||
});
|
||||
it('should remove "hidden" class if condition is false', () => {
|
||||
const element = document.createElement('div');
|
||||
element.classList.add('hidden');
|
||||
hideIf(false, element);
|
||||
expect(element).not.toHaveClass('hidden');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,12 +1,4 @@
|
|||
import {
|
||||
delegate,
|
||||
fire,
|
||||
mouseMoveThenOver,
|
||||
leftClick,
|
||||
on,
|
||||
PhilomenaAvailableEventsMap,
|
||||
oncePersistedPageShown,
|
||||
} from '../events';
|
||||
import { delegate, fire, leftClick, on, PhilomenaAvailableEventsMap, oncePersistedPageShown } from '../events';
|
||||
import { getRandomArrayItem } from '../../../test/randomness';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
|
||||
|
@ -88,55 +80,6 @@ describe('Event utils', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('mouseMoveThenOver', () => {
|
||||
it('should NOT fire on first mouseover', () => {
|
||||
const mockButton = document.createElement('button');
|
||||
const mockHandler = vi.fn();
|
||||
|
||||
mouseMoveThenOver(mockButton, mockHandler);
|
||||
|
||||
fireEvent.mouseOver(mockButton);
|
||||
|
||||
expect(mockHandler).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should fire on the first mousemove', () => {
|
||||
const mockButton = document.createElement('button');
|
||||
const mockHandler = vi.fn();
|
||||
|
||||
mouseMoveThenOver(mockButton, mockHandler);
|
||||
|
||||
fireEvent.mouseMove(mockButton);
|
||||
|
||||
expect(mockHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should fire on subsequent mouseover', () => {
|
||||
const mockButton = document.createElement('button');
|
||||
const mockHandler = vi.fn();
|
||||
|
||||
mouseMoveThenOver(mockButton, mockHandler);
|
||||
|
||||
fireEvent.mouseMove(mockButton);
|
||||
fireEvent.mouseOver(mockButton);
|
||||
|
||||
expect(mockHandler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should NOT fire on subsequent mousemove', () => {
|
||||
const mockButton = document.createElement('button');
|
||||
const mockHandler = vi.fn();
|
||||
|
||||
mouseMoveThenOver(mockButton, mockHandler);
|
||||
|
||||
fireEvent.mouseMove(mockButton);
|
||||
fireEvent.mouseOver(mockButton);
|
||||
fireEvent.mouseMove(mockButton);
|
||||
|
||||
expect(mockHandler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('oncePersistedPageShown', () => {
|
||||
it('should NOT fire on usual page show', () => {
|
||||
const mockHandler = vi.fn();
|
||||
|
|
|
@ -5,7 +5,6 @@ import { TextDecoder } from 'util';
|
|||
|
||||
describe('LocalAutocompleter', () => {
|
||||
let mockData: ArrayBuffer;
|
||||
const defaultK = 5;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mockDataPath = join(__dirname, 'autocomplete-compiled-v2.bin');
|
||||
|
@ -44,59 +43,81 @@ describe('Local Autocompleter', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('topK', () => {
|
||||
describe('matchPrefix', () => {
|
||||
const termStem = ['f', 'o'].join('');
|
||||
|
||||
let localAutocomplete: LocalAutocompleter;
|
||||
|
||||
beforeAll(() => {
|
||||
localAutocomplete = new LocalAutocompleter(mockData);
|
||||
function expectLocalAutocomplete(term: string, topK = 5) {
|
||||
const localAutocomplete = new LocalAutocompleter(mockData);
|
||||
const results = localAutocomplete.matchPrefix(term, topK);
|
||||
const actual = results.map(result => {
|
||||
const canonical = `${result.canonical} (${result.images})`;
|
||||
return result.alias ? `${result.alias} -> ${canonical}` : canonical;
|
||||
});
|
||||
|
||||
return expect(actual);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.booru.hiddenTagList = [];
|
||||
});
|
||||
|
||||
it('should return suggestions for exact tag name match', () => {
|
||||
const result = localAutocomplete.matchPrefix('safe', defaultK);
|
||||
expect(result).toEqual([expect.objectContaining({ aliasName: 'safe', name: 'safe', imageCount: 6 })]);
|
||||
expectLocalAutocomplete('safe').toMatchInlineSnapshot(`
|
||||
[
|
||||
"safe (6)",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should return suggestion for original tag when passed an alias', () => {
|
||||
const result = localAutocomplete.matchPrefix('flowers', defaultK);
|
||||
expect(result).toEqual([expect.objectContaining({ aliasName: 'flowers', name: 'flower', imageCount: 1 })]);
|
||||
it('should return suggestion for an alias', () => {
|
||||
expectLocalAutocomplete('flowers').toMatchInlineSnapshot(`
|
||||
[
|
||||
"flowers -> flower (1)",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should prefer canonical tag over an alias when both match', () => {
|
||||
expectLocalAutocomplete('flo').toMatchInlineSnapshot(`
|
||||
[
|
||||
"flower (1)",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should return suggestions sorted by image count', () => {
|
||||
const result = localAutocomplete.matchPrefix(termStem, defaultK);
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({ aliasName: 'forest', name: 'forest', imageCount: 3 }),
|
||||
expect.objectContaining({ aliasName: 'fog', name: 'fog', imageCount: 1 }),
|
||||
expect.objectContaining({ aliasName: 'force field', name: 'force field', imageCount: 1 }),
|
||||
]);
|
||||
expectLocalAutocomplete(termStem).toMatchInlineSnapshot(`
|
||||
[
|
||||
"forest (3)",
|
||||
"fog (1)",
|
||||
"force field (1)",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should return namespaced suggestions without including namespace', () => {
|
||||
const result = localAutocomplete.matchPrefix('test', defaultK);
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({ aliasName: 'artist:test', name: 'artist:test', imageCount: 1 }),
|
||||
]);
|
||||
expectLocalAutocomplete('test').toMatchInlineSnapshot(`
|
||||
[
|
||||
"artist:test (1)",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should return only the required number of suggestions', () => {
|
||||
const result = localAutocomplete.matchPrefix(termStem, 1);
|
||||
expect(result).toEqual([expect.objectContaining({ aliasName: 'forest', name: 'forest', imageCount: 3 })]);
|
||||
expectLocalAutocomplete(termStem, 1).toMatchInlineSnapshot(`
|
||||
[
|
||||
"forest (3)",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should NOT return suggestions associated with hidden tags', () => {
|
||||
window.booru.hiddenTagList = [1];
|
||||
const result = localAutocomplete.matchPrefix(termStem, defaultK);
|
||||
expect(result).toEqual([]);
|
||||
expectLocalAutocomplete(termStem).toMatchInlineSnapshot(`[]`);
|
||||
});
|
||||
|
||||
it('should return empty array for empty prefix', () => {
|
||||
const result = localAutocomplete.matchPrefix('', defaultK);
|
||||
expect(result).toEqual([]);
|
||||
expectLocalAutocomplete('').toMatchInlineSnapshot(`[]`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,37 +1,35 @@
|
|||
import { fetchMock } from '../../../test/fetch-mock.ts';
|
||||
import {
|
||||
fetchLocalAutocomplete,
|
||||
fetchSuggestions,
|
||||
formatLocalAutocompleteResult,
|
||||
purgeSuggestionsCache,
|
||||
SuggestionsPopup,
|
||||
TermSuggestion,
|
||||
TagSuggestion,
|
||||
TagSuggestionParams,
|
||||
Suggestions,
|
||||
HistorySuggestion,
|
||||
ItemSelectedEvent,
|
||||
} from '../suggestions.ts';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { LocalAutocompleter } from '../local-autocompleter.ts';
|
||||
import { afterEach } from 'vitest';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import { getRandomIntBetween } from '../../../test/randomness.ts';
|
||||
import { assertNotNull } from '../assert.ts';
|
||||
|
||||
const mockedSuggestionsEndpoint = '/endpoint?term=';
|
||||
const mockedSuggestionsResponse = [
|
||||
{ label: 'artist:assasinmonkey (1)', value: 'artist:assasinmonkey' },
|
||||
{ label: 'artist:hydrusbeta (1)', value: 'artist:hydrusbeta' },
|
||||
{ label: 'artist:the sexy assistant (1)', value: 'artist:the sexy assistant' },
|
||||
{ label: 'artist:devinian (1)', value: 'artist:devinian' },
|
||||
{ label: 'artist:moe (1)', value: 'artist:moe' },
|
||||
];
|
||||
const mockedSuggestions: Suggestions = {
|
||||
history: ['foo bar', 'bar baz', 'baz qux'].map(content => new HistorySuggestion(content, 0)),
|
||||
tags: [
|
||||
{ images: 10, canonical: 'artist:assasinmonkey' },
|
||||
{ images: 10, canonical: 'artist:hydrusbeta' },
|
||||
{ images: 10, canonical: 'artist:the sexy assistant' },
|
||||
{ images: 10, canonical: 'artist:devinian' },
|
||||
{ images: 10, canonical: 'artist:moe' },
|
||||
].map(tags => new TagSuggestion({ ...tags, matchLength: 0 })),
|
||||
};
|
||||
|
||||
function mockBaseSuggestionsPopup(includeMockedSuggestions: boolean = false): [SuggestionsPopup, HTMLInputElement] {
|
||||
const input = document.createElement('input');
|
||||
const popup = new SuggestionsPopup();
|
||||
|
||||
document.body.append(input);
|
||||
popup.showForField(input);
|
||||
popup.showForElement(input);
|
||||
|
||||
if (includeMockedSuggestions) {
|
||||
popup.renderSuggestions(mockedSuggestionsResponse);
|
||||
popup.setSuggestions(mockedSuggestions);
|
||||
}
|
||||
|
||||
return [popup, input];
|
||||
|
@ -40,27 +38,9 @@ function mockBaseSuggestionsPopup(includeMockedSuggestions: boolean = false): [S
|
|||
const selectedItemClassName = 'autocomplete__item--selected';
|
||||
|
||||
describe('Suggestions', () => {
|
||||
let mockedAutocompleteBuffer: ArrayBuffer;
|
||||
let popup: SuggestionsPopup | undefined;
|
||||
let input: HTMLInputElement | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
fetchMock.enableMocks();
|
||||
|
||||
mockedAutocompleteBuffer = await fs.promises
|
||||
.readFile(path.join(__dirname, 'autocomplete-compiled-v2.bin'))
|
||||
.then(fileBuffer => fileBuffer.buffer);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fetchMock.disableMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
purgeSuggestionsCache();
|
||||
fetchMock.resetMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (input) {
|
||||
input.remove();
|
||||
|
@ -69,6 +49,7 @@ describe('Suggestions', () => {
|
|||
|
||||
if (popup) {
|
||||
popup.hide();
|
||||
popup.setSuggestions({ history: [], tags: [] });
|
||||
popup = undefined;
|
||||
}
|
||||
});
|
||||
|
@ -78,113 +59,113 @@ describe('Suggestions', () => {
|
|||
[popup, input] = mockBaseSuggestionsPopup();
|
||||
|
||||
expect(document.querySelector('.autocomplete')).toBeInstanceOf(HTMLElement);
|
||||
expect(popup.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('should be removed when hidden', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup();
|
||||
|
||||
popup.hide();
|
||||
|
||||
expect(document.querySelector('.autocomplete')).not.toBeInstanceOf(HTMLElement);
|
||||
expect(popup.isActive).toBe(false);
|
||||
expect(popup.isHidden).toBe(false);
|
||||
});
|
||||
|
||||
it('should render suggestions', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||
|
||||
expect(document.querySelectorAll('.autocomplete__item').length).toBe(mockedSuggestionsResponse.length);
|
||||
expect(document.querySelectorAll('.autocomplete__item').length).toBe(
|
||||
mockedSuggestions.history.length + mockedSuggestions.tags.length,
|
||||
);
|
||||
});
|
||||
|
||||
it('should initially select first element when selectNext called', () => {
|
||||
it('should initially select first element when selectDown is called', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||
|
||||
popup.selectNext();
|
||||
popup.selectDown();
|
||||
|
||||
expect(document.querySelector('.autocomplete__item:first-child')).toHaveClass(selectedItemClassName);
|
||||
});
|
||||
|
||||
it('should initially select last element when selectPrevious called', () => {
|
||||
it('should initially select last element when selectUp is called', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||
|
||||
popup.selectPrevious();
|
||||
popup.selectUp();
|
||||
|
||||
expect(document.querySelector('.autocomplete__item:last-child')).toHaveClass(selectedItemClassName);
|
||||
});
|
||||
|
||||
it('should select and de-select items when hovering items over', () => {
|
||||
it('should jump to the next lower block when selectCtrlDown is called', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||
|
||||
const firstItem = document.querySelector('.autocomplete__item:first-child');
|
||||
const lastItem = document.querySelector('.autocomplete__item:last-child');
|
||||
popup.selectCtrlDown();
|
||||
|
||||
if (firstItem) {
|
||||
fireEvent.mouseOver(firstItem);
|
||||
fireEvent.mouseMove(firstItem);
|
||||
}
|
||||
expect(popup.selectedSuggestion).toBe(mockedSuggestions.tags[0]);
|
||||
expect(document.querySelector('.autocomplete__item__tag')).toHaveClass(selectedItemClassName);
|
||||
|
||||
expect(firstItem).toHaveClass(selectedItemClassName);
|
||||
popup.selectCtrlDown();
|
||||
|
||||
if (lastItem) {
|
||||
fireEvent.mouseOver(lastItem);
|
||||
fireEvent.mouseMove(lastItem);
|
||||
}
|
||||
expect(popup.selectedSuggestion).toBe(mockedSuggestions.tags.at(-1));
|
||||
expect(document.querySelector('.autocomplete__item__tag:last-child')).toHaveClass(selectedItemClassName);
|
||||
|
||||
expect(firstItem).not.toHaveClass(selectedItemClassName);
|
||||
expect(lastItem).toHaveClass(selectedItemClassName);
|
||||
|
||||
if (lastItem) {
|
||||
fireEvent.mouseOut(lastItem);
|
||||
}
|
||||
|
||||
expect(lastItem).not.toHaveClass(selectedItemClassName);
|
||||
// Should loop around
|
||||
popup.selectCtrlDown();
|
||||
expect(popup.selectedSuggestion).toBe(mockedSuggestions.history[0]);
|
||||
expect(document.querySelector('.autocomplete__item:first-child')).toHaveClass(selectedItemClassName);
|
||||
});
|
||||
|
||||
it('should allow switching between mouse and selection', () => {
|
||||
it('should jump to the next upper block when selectCtrlUp is called', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||
|
||||
const secondItem = document.querySelector('.autocomplete__item:nth-child(2)');
|
||||
const thirdItem = document.querySelector('.autocomplete__item:nth-child(3)');
|
||||
popup.selectCtrlUp();
|
||||
|
||||
if (secondItem) {
|
||||
fireEvent.mouseOver(secondItem);
|
||||
fireEvent.mouseMove(secondItem);
|
||||
}
|
||||
expect(popup.selectedSuggestion).toBe(mockedSuggestions.tags.at(-1));
|
||||
expect(document.querySelector('.autocomplete__item__tag:last-child')).toHaveClass(selectedItemClassName);
|
||||
|
||||
expect(secondItem).toHaveClass(selectedItemClassName);
|
||||
popup.selectCtrlUp();
|
||||
|
||||
popup.selectNext();
|
||||
expect(popup.selectedSuggestion).toBe(mockedSuggestions.history.at(-1));
|
||||
expect(
|
||||
document.querySelector(`.autocomplete__item__history:nth-child(${mockedSuggestions.history.length})`),
|
||||
).toHaveClass(selectedItemClassName);
|
||||
|
||||
expect(secondItem).not.toHaveClass(selectedItemClassName);
|
||||
expect(thirdItem).toHaveClass(selectedItemClassName);
|
||||
popup.selectCtrlUp();
|
||||
|
||||
expect(popup.selectedSuggestion).toBe(mockedSuggestions.history[0]);
|
||||
expect(document.querySelector('.autocomplete__item:first-child')).toHaveClass(selectedItemClassName);
|
||||
|
||||
// Should loop around
|
||||
popup.selectCtrlUp();
|
||||
|
||||
expect(popup.selectedSuggestion).toBe(mockedSuggestions.tags.at(-1));
|
||||
expect(document.querySelector('.autocomplete__item__tag:last-child')).toHaveClass(selectedItemClassName);
|
||||
});
|
||||
|
||||
it('should do nothing on selection changes when empty', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup();
|
||||
|
||||
popup.selectDown();
|
||||
popup.selectUp();
|
||||
popup.selectCtrlDown();
|
||||
popup.selectCtrlUp();
|
||||
|
||||
expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('should loop around when selecting next on last and previous on first', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||
|
||||
const firstItem = document.querySelector('.autocomplete__item:first-child');
|
||||
const lastItem = document.querySelector('.autocomplete__item:last-child');
|
||||
const firstItem = assertNotNull(document.querySelector('.autocomplete__item:first-child'));
|
||||
const lastItem = assertNotNull(document.querySelector('.autocomplete__item:last-child'));
|
||||
|
||||
if (lastItem) {
|
||||
fireEvent.mouseOver(lastItem);
|
||||
fireEvent.mouseMove(lastItem);
|
||||
}
|
||||
popup.selectUp();
|
||||
|
||||
expect(lastItem).toHaveClass(selectedItemClassName);
|
||||
|
||||
popup.selectNext();
|
||||
popup.selectDown();
|
||||
|
||||
expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull();
|
||||
|
||||
popup.selectNext();
|
||||
popup.selectDown();
|
||||
|
||||
expect(firstItem).toHaveClass(selectedItemClassName);
|
||||
|
||||
popup.selectPrevious();
|
||||
popup.selectUp();
|
||||
|
||||
expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull();
|
||||
|
||||
popup.selectPrevious();
|
||||
popup.selectUp();
|
||||
|
||||
expect(lastItem).toHaveClass(selectedItemClassName);
|
||||
});
|
||||
|
@ -192,176 +173,112 @@ describe('Suggestions', () => {
|
|||
it('should return selected item value', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||
|
||||
expect(popup.selectedTerm).toBe(null);
|
||||
expect(popup.selectedSuggestion).toBe(null);
|
||||
|
||||
popup.selectNext();
|
||||
popup.selectDown();
|
||||
|
||||
expect(popup.selectedTerm).toBe(mockedSuggestionsResponse[0].value);
|
||||
expect(popup.selectedSuggestion).toBe(mockedSuggestions.history[0]);
|
||||
});
|
||||
|
||||
it('should emit an event when item was clicked with mouse', () => {
|
||||
it('should emit an event when an item was clicked with a mouse', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||
|
||||
let clickEvent: CustomEvent<TermSuggestion> | undefined;
|
||||
|
||||
const itemSelectedHandler = vi.fn((event: CustomEvent<TermSuggestion>) => {
|
||||
clickEvent = event;
|
||||
});
|
||||
const itemSelectedHandler = vi.fn<(event: ItemSelectedEvent) => void>();
|
||||
|
||||
popup.onItemSelected(itemSelectedHandler);
|
||||
|
||||
const firstItem = document.querySelector('.autocomplete__item');
|
||||
const firstItem = assertNotNull(document.querySelector('.autocomplete__item'));
|
||||
|
||||
if (firstItem) {
|
||||
fireEvent.click(firstItem);
|
||||
}
|
||||
|
||||
expect(itemSelectedHandler).toBeCalledTimes(1);
|
||||
expect(clickEvent?.detail).toEqual(mockedSuggestionsResponse[0]);
|
||||
expect(itemSelectedHandler).toBeCalledWith({
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
suggestion: mockedSuggestions.history[0],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should not emit selection on items without value', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup();
|
||||
describe('HistorySuggestion', () => {
|
||||
it('should render the suggestion', () => {
|
||||
expectHistoryRender('foo bar').toMatchInlineSnapshot(`
|
||||
{
|
||||
"label": " foo bar",
|
||||
"value": "foo bar",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
popup.renderSuggestions([{ label: 'Option without value', value: '' }]);
|
||||
describe('TagSuggestion', () => {
|
||||
it('should format suggested tags as tag name and the count', () => {
|
||||
// The snapshots in this test contain a "narrow no-break space"
|
||||
/* eslint-disable no-irregular-whitespace */
|
||||
expectTagRender({ canonical: 'safe', images: 10 }).toMatchInlineSnapshot(`
|
||||
{
|
||||
"label": " safe 10",
|
||||
"value": "safe",
|
||||
}
|
||||
`);
|
||||
expectTagRender({ canonical: 'safe', images: 10_000 }).toMatchInlineSnapshot(`
|
||||
{
|
||||
"label": " safe 10 000",
|
||||
"value": "safe",
|
||||
}
|
||||
`);
|
||||
expectTagRender({ canonical: 'safe', images: 100_000 }).toMatchInlineSnapshot(`
|
||||
{
|
||||
"label": " safe 100 000",
|
||||
"value": "safe",
|
||||
}
|
||||
`);
|
||||
expectTagRender({ canonical: 'safe', images: 1000_000 }).toMatchInlineSnapshot(`
|
||||
{
|
||||
"label": " safe 1 000 000",
|
||||
"value": "safe",
|
||||
}
|
||||
`);
|
||||
expectTagRender({ canonical: 'safe', images: 10_000_000 }).toMatchInlineSnapshot(`
|
||||
{
|
||||
"label": " safe 10 000 000",
|
||||
"value": "safe",
|
||||
}
|
||||
`);
|
||||
/* eslint-enable no-irregular-whitespace */
|
||||
});
|
||||
|
||||
const itemSelectionHandler = vi.fn();
|
||||
it('should display alias -> canonical for aliased tags', () => {
|
||||
expectTagRender({ images: 10, canonical: 'safe', alias: 'rating:safe' }).toMatchInlineSnapshot(
|
||||
`
|
||||
{
|
||||
"label": " rating:safe → safe 10",
|
||||
"value": "safe",
|
||||
}
|
||||
`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
popup.onItemSelected(itemSelectionHandler);
|
||||
function expectHistoryRender(content: string) {
|
||||
const suggestion = new HistorySuggestion(content, 0);
|
||||
const label = suggestion
|
||||
.render()
|
||||
.map(el => el.textContent)
|
||||
.join('');
|
||||
const value = suggestion.value();
|
||||
|
||||
const firstItem = document.querySelector('.autocomplete__item:first-child')!;
|
||||
|
||||
if (firstItem) {
|
||||
fireEvent.click(firstItem);
|
||||
return expect({ label, value });
|
||||
}
|
||||
|
||||
expect(itemSelectionHandler).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
function expectTagRender(params: Omit<TagSuggestionParams, 'matchLength'>) {
|
||||
const suggestion = new TagSuggestion({ ...params, matchLength: 0 });
|
||||
const label = suggestion
|
||||
.render()
|
||||
.map(el => el.textContent)
|
||||
.join('');
|
||||
const value = suggestion.value();
|
||||
|
||||
describe('fetchSuggestions', () => {
|
||||
it('should only call fetch once per single term', () => {
|
||||
fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||
fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should be case-insensitive to terms and trim spaces', () => {
|
||||
fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||
fetchSuggestions(mockedSuggestionsEndpoint, 'Art');
|
||||
fetchSuggestions(mockedSuggestionsEndpoint, ' ART ');
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return the same suggestions from cache', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 }));
|
||||
|
||||
const firstSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||
const secondSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||
|
||||
expect(firstSuggestions).toBe(secondSuggestions);
|
||||
});
|
||||
|
||||
it('should parse and return array of suggestions', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 }));
|
||||
|
||||
const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||
|
||||
expect(resolvedSuggestions).toBeInstanceOf(Array);
|
||||
expect(resolvedSuggestions.length).toBe(mockedSuggestionsResponse.length);
|
||||
expect(resolvedSuggestions).toEqual(mockedSuggestionsResponse);
|
||||
});
|
||||
|
||||
it('should return empty array on server error', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('', { status: 500 }));
|
||||
|
||||
const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'unknown tag');
|
||||
|
||||
expect(resolvedSuggestions).toBeInstanceOf(Array);
|
||||
expect(resolvedSuggestions.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty array on invalid response format', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('invalid non-JSON response', { status: 200 }));
|
||||
|
||||
const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'invalid response');
|
||||
|
||||
expect(resolvedSuggestions).toBeInstanceOf(Array);
|
||||
expect(resolvedSuggestions.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('purgeSuggestionsCache', () => {
|
||||
it('should clear cached responses', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 }));
|
||||
|
||||
const firstResult = await fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||
purgeSuggestionsCache();
|
||||
const resultAfterPurge = await fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||
|
||||
expect(fetch).toBeCalledTimes(2);
|
||||
expect(firstResult).not.toBe(resultAfterPurge);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchLocalAutocomplete', () => {
|
||||
it('should request binary with date-related cache key', () => {
|
||||
fetchMock.mockResolvedValue(new Response(mockedAutocompleteBuffer, { status: 200 }));
|
||||
|
||||
const now = new Date();
|
||||
const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`;
|
||||
const expectedEndpoint = `/autocomplete/compiled?vsn=2&key=${cacheKey}`;
|
||||
|
||||
fetchLocalAutocomplete();
|
||||
|
||||
expect(fetch).toBeCalledWith(expectedEndpoint, { credentials: 'omit', cache: 'force-cache' });
|
||||
});
|
||||
|
||||
it('should return auto-completer instance', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(mockedAutocompleteBuffer, { status: 200 }));
|
||||
|
||||
const autocomplete = await fetchLocalAutocomplete();
|
||||
|
||||
expect(autocomplete).toBeInstanceOf(LocalAutocompleter);
|
||||
});
|
||||
|
||||
it('should throw generic server error on failing response', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('error', { status: 500 }));
|
||||
|
||||
expect(() => fetchLocalAutocomplete()).rejects.toThrowError('Received error from server');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatLocalAutocompleteResult', () => {
|
||||
it('should format suggested tags as tag name and the count', () => {
|
||||
const tagName = 'safe';
|
||||
const tagCount = getRandomIntBetween(5, 10);
|
||||
|
||||
const resultObject = formatLocalAutocompleteResult({
|
||||
name: tagName,
|
||||
aliasName: tagName,
|
||||
imageCount: tagCount,
|
||||
});
|
||||
|
||||
expect(resultObject.label).toBe(`${tagName} (${tagCount})`);
|
||||
expect(resultObject.value).toBe(tagName);
|
||||
});
|
||||
|
||||
it('should display original alias name for aliased tags', () => {
|
||||
const tagName = 'safe';
|
||||
const tagAlias = 'rating:safe';
|
||||
const tagCount = getRandomIntBetween(5, 10);
|
||||
|
||||
const resultObject = formatLocalAutocompleteResult({
|
||||
name: tagName,
|
||||
aliasName: tagAlias,
|
||||
imageCount: tagCount,
|
||||
});
|
||||
|
||||
expect(resultObject.label).toBe(`${tagAlias} ⇒ ${tagName} (${tagCount})`);
|
||||
expect(resultObject.value).toBe(tagName);
|
||||
});
|
||||
});
|
||||
});
|
||||
return expect({ label, value });
|
||||
}
|
||||
|
|
|
@ -110,3 +110,11 @@ export function escapeCss(css: string): string {
|
|||
export function findFirstTextNode<N extends Node>(of: Node): N {
|
||||
return Array.prototype.filter.call(of.childNodes, el => el.nodeType === Node.TEXT_NODE)[0];
|
||||
}
|
||||
|
||||
export function hideIf(condition: boolean, element: HTMLElement) {
|
||||
if (condition) {
|
||||
element.classList.add('hidden');
|
||||
} else {
|
||||
element.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,17 +43,6 @@ export function leftClick<E extends MouseEvent, Target extends EventTarget>(func
|
|||
};
|
||||
}
|
||||
|
||||
export function mouseMoveThenOver<El extends HTMLElement>(element: El, func: (e: MouseEvent) => void) {
|
||||
element.addEventListener(
|
||||
'mousemove',
|
||||
(event: MouseEvent) => {
|
||||
func(event);
|
||||
element.addEventListener('mouseover', func);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
export function oncePersistedPageShown(func: (e: PageTransitionEvent) => void) {
|
||||
const controller = new AbortController();
|
||||
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
// Ignoring a non-100% coverage for HTTP client for now.
|
||||
// It will be 100% in https://github.com/philomena-dev/philomena/pull/453
|
||||
/* v8 ignore start */
|
||||
import { retry } from './retry';
|
||||
|
||||
interface RequestParams extends RequestInit {
|
||||
|
@ -97,4 +94,3 @@ function generateId(prefix: string) {
|
|||
|
||||
return chars.join('');
|
||||
}
|
||||
/* v8 ignore end */
|
||||
|
|
|
@ -3,9 +3,21 @@ import { UniqueHeap } from './unique-heap';
|
|||
import store from './store';
|
||||
|
||||
export interface Result {
|
||||
aliasName: string;
|
||||
name: string;
|
||||
imageCount: number;
|
||||
/**
|
||||
* If present, then this suggestion is for a tag alias.
|
||||
* If absent, then this suggestion is for the `canonical` tag name.
|
||||
*/
|
||||
alias?: null | string;
|
||||
|
||||
/**
|
||||
* The canonical name of the tag (non-alias).
|
||||
*/
|
||||
canonical: string;
|
||||
|
||||
/**
|
||||
* Number of images tagged with this tag.
|
||||
*/
|
||||
images: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -253,10 +265,19 @@ export class LocalAutocompleter {
|
|||
this.scanResults(referenceToAliasIndex, namespaceMatch, hasFilteredAssociation, isAlias, results);
|
||||
|
||||
// Convert top K from heap into result array
|
||||
return results.topK(k).map((i: TagReferenceIndex) => ({
|
||||
aliasName: this.decoder.decode(this.referenceToName(i, false)),
|
||||
name: this.decoder.decode(this.referenceToName(i)),
|
||||
imageCount: this.getImageCount(i),
|
||||
}));
|
||||
return results.topK(k).map((i: TagReferenceIndex) => {
|
||||
const alias = this.decoder.decode(this.referenceToName(i, false));
|
||||
const canonical = this.decoder.decode(this.referenceToName(i));
|
||||
const result: Result = {
|
||||
canonical,
|
||||
images: this.getImageCount(i),
|
||||
};
|
||||
|
||||
if (alias !== canonical) {
|
||||
result.alias = alias;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
// Ignoring a non-100% coverage for HTTP client for now.
|
||||
// It will be 100% in https://github.com/philomena-dev/philomena/pull/453
|
||||
/* v8 ignore start */
|
||||
/**
|
||||
* localStorage utils
|
||||
*/
|
||||
|
@ -81,4 +78,3 @@ export default {
|
|||
return lastUpdatedTime === null || Date.now() > lastUpdatedTime;
|
||||
},
|
||||
};
|
||||
/* v8 ignore end */
|
||||
|
|
|
@ -1,125 +1,332 @@
|
|||
import { makeEl } from './dom.ts';
|
||||
import { mouseMoveThenOver } from './events.ts';
|
||||
import { handleError } from './requests.ts';
|
||||
import { LocalAutocompleter, Result } from './local-autocompleter.ts';
|
||||
|
||||
export interface TermSuggestion {
|
||||
label: string;
|
||||
value: string;
|
||||
export interface TagSuggestionParams {
|
||||
/**
|
||||
* If present, then this suggestion is for a tag alias.
|
||||
* If absent, then this suggestion is for the `canonical` tag name.
|
||||
*/
|
||||
alias?: null | string;
|
||||
|
||||
/**
|
||||
* The canonical name of the tag (non-alias).
|
||||
*/
|
||||
canonical: string;
|
||||
|
||||
/**
|
||||
* Number of images tagged with this tag.
|
||||
*/
|
||||
images: number;
|
||||
|
||||
/**
|
||||
* Length of the prefix in the suggestion that matches the prefix of the current input.
|
||||
*/
|
||||
matchLength: number;
|
||||
}
|
||||
|
||||
const selectedSuggestionClassName = 'autocomplete__item--selected';
|
||||
export class TagSuggestion {
|
||||
alias?: null | string;
|
||||
canonical: string;
|
||||
images: number;
|
||||
matchLength: number;
|
||||
|
||||
constructor(params: TagSuggestionParams) {
|
||||
this.alias = params.alias;
|
||||
this.canonical = params.canonical;
|
||||
this.images = params.images;
|
||||
this.matchLength = params.matchLength;
|
||||
}
|
||||
|
||||
value(): string {
|
||||
return this.canonical;
|
||||
}
|
||||
|
||||
render(): HTMLElement[] {
|
||||
const { alias: aliasName, canonical: canonicalName, images: imageCount } = this;
|
||||
|
||||
const label = aliasName ? `${aliasName} → ${canonicalName}` : canonicalName;
|
||||
|
||||
return [
|
||||
makeEl('div', { className: 'autocomplete__item__content' }, [
|
||||
makeEl('i', { className: 'fa-solid fa-tag' }),
|
||||
makeEl('b', {
|
||||
className: 'autocomplete__item__tag__match',
|
||||
textContent: ` ${label.slice(0, this.matchLength)}`,
|
||||
}),
|
||||
makeEl('span', {
|
||||
textContent: label.slice(this.matchLength),
|
||||
}),
|
||||
]),
|
||||
makeEl('span', {
|
||||
className: 'autocomplete__item__tag__count',
|
||||
textContent: ` ${TagSuggestion.formatImageCount(imageCount)}`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
static formatImageCount(count: number): string {
|
||||
// We use the 'fr' (French) number formatting style with space-separated
|
||||
// groups of 3 digits.
|
||||
const formatter = new Intl.NumberFormat('fr', { useGrouping: true });
|
||||
|
||||
return formatter.format(count);
|
||||
}
|
||||
}
|
||||
|
||||
export class HistorySuggestion {
|
||||
/**
|
||||
* Full query string that was previously searched and retrieved from the history.
|
||||
*/
|
||||
content: string;
|
||||
|
||||
/**
|
||||
* Length of the prefix in the suggestion that matches the prefix of the current input.
|
||||
*/
|
||||
matchLength: number;
|
||||
|
||||
constructor(content: string, matchIndex: number) {
|
||||
this.content = content;
|
||||
this.matchLength = matchIndex;
|
||||
}
|
||||
|
||||
value(): string {
|
||||
return this.content;
|
||||
}
|
||||
|
||||
render(): HTMLElement[] {
|
||||
return [
|
||||
makeEl('div', { className: 'autocomplete__item__content' }, [
|
||||
makeEl('i', {
|
||||
className: 'autocomplete__item__history__icon fa-solid fa-history',
|
||||
}),
|
||||
makeEl('b', {
|
||||
textContent: ` ${this.content.slice(0, this.matchLength)}`,
|
||||
className: 'autocomplete__item__history__match',
|
||||
}),
|
||||
makeEl('span', {
|
||||
textContent: this.content.slice(this.matchLength),
|
||||
}),
|
||||
]),
|
||||
// Here will be a `delete` button to remove the item from the history.
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export type Suggestion = TagSuggestion | HistorySuggestion;
|
||||
|
||||
export interface Suggestions {
|
||||
history: HistorySuggestion[];
|
||||
tags: TagSuggestion[];
|
||||
}
|
||||
|
||||
export interface ItemSelectedEvent {
|
||||
suggestion: Suggestion;
|
||||
shiftKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
}
|
||||
|
||||
interface SuggestionItem {
|
||||
element: HTMLElement;
|
||||
suggestion: Suggestion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsible for rendering the suggestions dropdown.
|
||||
*/
|
||||
export class SuggestionsPopup {
|
||||
/**
|
||||
* Index of the currently selected suggestion. -1 means an imaginary item
|
||||
* before the first item that represents the state where no item is selected.
|
||||
*/
|
||||
private cursor: number = -1;
|
||||
private items: SuggestionItem[];
|
||||
private readonly container: HTMLElement;
|
||||
private readonly listElement: HTMLUListElement;
|
||||
private selectedElement: HTMLElement | null = null;
|
||||
|
||||
constructor() {
|
||||
this.container = makeEl('div', {
|
||||
className: 'autocomplete',
|
||||
className: 'autocomplete hidden',
|
||||
tabIndex: -1,
|
||||
});
|
||||
|
||||
this.listElement = makeEl('ul', {
|
||||
className: 'autocomplete__list',
|
||||
});
|
||||
|
||||
this.container.appendChild(this.listElement);
|
||||
document.body.appendChild(this.container);
|
||||
this.items = [];
|
||||
}
|
||||
|
||||
get selectedTerm(): string | null {
|
||||
return this.selectedElement?.dataset.value || null;
|
||||
get selectedSuggestion(): Suggestion | null {
|
||||
return this.selectedItem?.suggestion ?? null;
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.container.isConnected;
|
||||
private get selectedItem(): SuggestionItem | null {
|
||||
if (this.cursor < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.items[this.cursor];
|
||||
}
|
||||
|
||||
get isHidden(): boolean {
|
||||
return this.container.classList.contains('hidden');
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.clearSelection();
|
||||
this.container.remove();
|
||||
this.container.classList.add('hidden');
|
||||
}
|
||||
|
||||
private clearSelection() {
|
||||
if (!this.selectedElement) return;
|
||||
|
||||
this.selectedElement.classList.remove(selectedSuggestionClassName);
|
||||
this.selectedElement = null;
|
||||
this.setSelection(-1);
|
||||
}
|
||||
|
||||
private updateSelection(targetItem: HTMLElement) {
|
||||
this.clearSelection();
|
||||
|
||||
this.selectedElement = targetItem;
|
||||
this.selectedElement.classList.add(selectedSuggestionClassName);
|
||||
private setSelection(index: number) {
|
||||
if (this.cursor === index) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderSuggestions(suggestions: TermSuggestion[]): SuggestionsPopup {
|
||||
this.clearSelection();
|
||||
// This can't be triggered via the public API of this class
|
||||
/* v8 ignore start */
|
||||
if (index < -1 || index >= this.items.length) {
|
||||
throw new Error(`BUG: setSelection(): invalid selection index: ${index}`);
|
||||
}
|
||||
/* v8 ignore end */
|
||||
|
||||
this.listElement.innerHTML = '';
|
||||
const selectedClass = 'autocomplete__item--selected';
|
||||
|
||||
for (const suggestedTerm of suggestions) {
|
||||
const listItem = makeEl('li', {
|
||||
className: 'autocomplete__item',
|
||||
innerText: suggestedTerm.label,
|
||||
});
|
||||
this.selectedItem?.element.classList.remove(selectedClass);
|
||||
this.cursor = index;
|
||||
|
||||
listItem.dataset.value = suggestedTerm.value;
|
||||
if (index >= 0) {
|
||||
this.selectedItem?.element.classList.add(selectedClass);
|
||||
}
|
||||
}
|
||||
|
||||
this.watchItem(listItem, suggestedTerm);
|
||||
this.listElement.appendChild(listItem);
|
||||
setSuggestions(params: Suggestions): SuggestionsPopup {
|
||||
this.cursor = -1;
|
||||
this.items = [];
|
||||
this.container.innerHTML = '';
|
||||
|
||||
for (const suggestion of params.history) {
|
||||
this.appendSuggestion(suggestion);
|
||||
}
|
||||
|
||||
if (params.tags.length > 0 && params.history.length > 0) {
|
||||
this.container.appendChild(makeEl('hr', { className: 'autocomplete__separator' }));
|
||||
}
|
||||
|
||||
for (const suggestion of params.tags) {
|
||||
this.appendSuggestion(suggestion);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
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));
|
||||
appendSuggestion(suggestion: Suggestion) {
|
||||
const type = suggestion instanceof TagSuggestion ? 'tag' : 'history';
|
||||
|
||||
listItem.addEventListener('mouseout', () => this.clearSelection());
|
||||
const element = makeEl(
|
||||
'div',
|
||||
{
|
||||
className: `autocomplete__item autocomplete__item__${type}`,
|
||||
},
|
||||
suggestion.render(),
|
||||
);
|
||||
|
||||
listItem.addEventListener('click', () => {
|
||||
if (!listItem.dataset.value) {
|
||||
return;
|
||||
const item: SuggestionItem = { element, suggestion };
|
||||
|
||||
this.watchItem(item);
|
||||
|
||||
this.items.push(item);
|
||||
this.container.appendChild(element);
|
||||
}
|
||||
|
||||
this.container.dispatchEvent(new CustomEvent('item_selected', { detail: suggestion }));
|
||||
private watchItem(item: SuggestionItem) {
|
||||
item.element.addEventListener('click', event => {
|
||||
const detail: ItemSelectedEvent = {
|
||||
suggestion: item.suggestion,
|
||||
shiftKey: event.shiftKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
};
|
||||
|
||||
this.container.dispatchEvent(new CustomEvent('item_selected', { detail }));
|
||||
});
|
||||
}
|
||||
|
||||
private changeSelection(direction: number) {
|
||||
let nextTargetElement: Element | null;
|
||||
|
||||
if (!this.selectedElement) {
|
||||
nextTargetElement = direction > 0 ? this.listElement.firstElementChild : this.listElement.lastElementChild;
|
||||
} else {
|
||||
nextTargetElement =
|
||||
direction > 0 ? this.selectedElement.nextElementSibling : this.selectedElement.previousElementSibling;
|
||||
}
|
||||
|
||||
if (!(nextTargetElement instanceof HTMLElement)) {
|
||||
this.clearSelection();
|
||||
if (this.items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateSelection(nextTargetElement);
|
||||
const index = this.cursor + direction;
|
||||
|
||||
if (index === -1 || index >= this.items.length) {
|
||||
this.clearSelection();
|
||||
} else if (index < -1) {
|
||||
this.setSelection(this.items.length - 1);
|
||||
} else {
|
||||
this.setSelection(index);
|
||||
}
|
||||
}
|
||||
|
||||
selectNext() {
|
||||
selectDown() {
|
||||
this.changeSelection(1);
|
||||
}
|
||||
|
||||
selectPrevious() {
|
||||
selectUp() {
|
||||
this.changeSelection(-1);
|
||||
}
|
||||
|
||||
showForField(targetElement: HTMLElement) {
|
||||
/**
|
||||
* The user wants to jump to the next lower block of types of suggestions.
|
||||
*/
|
||||
selectCtrlDown() {
|
||||
if (this.items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.cursor >= this.items.length - 1) {
|
||||
this.setSelection(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let index = this.cursor + 1;
|
||||
const type = this.itemType(index);
|
||||
|
||||
while (index < this.items.length - 1 && this.itemType(index) === type) {
|
||||
index += 1;
|
||||
}
|
||||
|
||||
this.setSelection(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user wants to jump to the next upper block of types of suggestions.
|
||||
*/
|
||||
selectCtrlUp() {
|
||||
if (this.items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.cursor <= 0) {
|
||||
this.setSelection(this.items.length - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
let index = this.cursor - 1;
|
||||
const type = this.itemType(index);
|
||||
|
||||
while (index > 0 && this.itemType(index) === type) {
|
||||
index -= 1;
|
||||
}
|
||||
|
||||
this.setSelection(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the item's prototype that can be viewed as the item's type identifier.
|
||||
*/
|
||||
private itemType(index: number) {
|
||||
return this.items[index].suggestion instanceof TagSuggestion ? 'tag' : 'history';
|
||||
}
|
||||
|
||||
showForElement(targetElement: HTMLElement) {
|
||||
this.container.style.position = 'absolute';
|
||||
this.container.style.left = `${targetElement.offsetLeft}px`;
|
||||
|
||||
|
@ -130,66 +337,12 @@ export class SuggestionsPopup {
|
|||
}
|
||||
|
||||
this.container.style.top = `${topPosition}px`;
|
||||
|
||||
document.body.appendChild(this.container);
|
||||
this.container.classList.remove('hidden');
|
||||
}
|
||||
|
||||
onItemSelected(callback: (event: CustomEvent<TermSuggestion>) => void) {
|
||||
this.container.addEventListener('item_selected', callback as EventListener);
|
||||
}
|
||||
}
|
||||
|
||||
const cachedSuggestions = new Map<string, Promise<TermSuggestion[]>>();
|
||||
|
||||
export async function fetchSuggestions(endpoint: string, targetTerm: string): Promise<TermSuggestion[]> {
|
||||
const normalizedTerm = targetTerm.trim().toLowerCase();
|
||||
|
||||
if (cachedSuggestions.has(normalizedTerm)) {
|
||||
return cachedSuggestions.get(normalizedTerm)!;
|
||||
}
|
||||
|
||||
const promisedSuggestions: Promise<TermSuggestion[]> = fetch(`${endpoint}${targetTerm}`)
|
||||
.then(handleError)
|
||||
.then(response => response.json())
|
||||
.catch(() => {
|
||||
// Deleting the promised result from cache to allow retrying
|
||||
cachedSuggestions.delete(normalizedTerm);
|
||||
|
||||
// And resolve failed promise with empty array
|
||||
return [];
|
||||
onItemSelected(callback: (event: ItemSelectedEvent) => void) {
|
||||
this.container.addEventListener('item_selected', event => {
|
||||
callback((event as CustomEvent<ItemSelectedEvent>).detail);
|
||||
});
|
||||
|
||||
cachedSuggestions.set(normalizedTerm, promisedSuggestions);
|
||||
|
||||
return promisedSuggestions;
|
||||
}
|
||||
|
||||
export function purgeSuggestionsCache() {
|
||||
cachedSuggestions.clear();
|
||||
}
|
||||
|
||||
export async function fetchLocalAutocomplete(): Promise<LocalAutocompleter> {
|
||||
const now = new Date();
|
||||
const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`;
|
||||
|
||||
return await fetch(`/autocomplete/compiled?vsn=2&key=${cacheKey}`, {
|
||||
credentials: 'omit',
|
||||
cache: 'force-cache',
|
||||
})
|
||||
.then(handleError)
|
||||
.then(resp => resp.arrayBuffer())
|
||||
.then(buf => new LocalAutocompleter(buf));
|
||||
}
|
||||
|
||||
export function formatLocalAutocompleteResult(result: Result): TermSuggestion {
|
||||
let tagName = result.name;
|
||||
|
||||
if (tagName !== result.aliasName) {
|
||||
tagName = `${result.aliasName} ⇒ ${tagName}`;
|
||||
}
|
||||
|
||||
return {
|
||||
value: result.name,
|
||||
label: `${tagName} (${result.imageCount})`,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -37,21 +37,36 @@ defmodule PhilomenaWeb.SettingController do
|
|||
|
||||
defp update_local_settings(conn, user_params) do
|
||||
conn
|
||||
|> set_cookie(user_params, "hidpi", "hidpi")
|
||||
|> set_cookie(user_params, "webm", "webm")
|
||||
|> set_cookie(user_params, "serve_webm", "serve_webm")
|
||||
|> set_cookie(user_params, "unmute_videos", "unmute_videos")
|
||||
|> set_cookie(user_params, "chan_nsfw", "chan_nsfw")
|
||||
|> set_cookie(user_params, "hide_staff_tools", "hide_staff_tools")
|
||||
|> set_cookie(user_params, "hide_uploader", "hide_uploader")
|
||||
|> set_cookie(user_params, "hide_score", "hide_score")
|
||||
|> set_cookie(user_params, "unfilter_tag_suggestions", "unfilter_tag_suggestions")
|
||||
|> set_cookie(user_params, "enable_search_ac", "enable_search_ac")
|
||||
|> set_bool_cookie(user_params, "hidpi", "hidpi")
|
||||
|> set_bool_cookie(user_params, "webm", "webm")
|
||||
|> set_bool_cookie(user_params, "serve_webm", "serve_webm")
|
||||
|> set_bool_cookie(user_params, "unmute_videos", "unmute_videos")
|
||||
|> set_bool_cookie(user_params, "chan_nsfw", "chan_nsfw")
|
||||
|> set_bool_cookie(user_params, "hide_staff_tools", "hide_staff_tools")
|
||||
|> set_bool_cookie(user_params, "hide_uploader", "hide_uploader")
|
||||
|> set_bool_cookie(user_params, "hide_score", "hide_score")
|
||||
|> set_bool_cookie(user_params, "unfilter_tag_suggestions", "unfilter_tag_suggestions")
|
||||
|> set_bool_cookie(user_params, "enable_search_ac", "enable_search_ac")
|
||||
|> set_bool_cookie(
|
||||
user_params,
|
||||
"autocomplete_search_history_hidden",
|
||||
"autocomplete_search_history_hidden"
|
||||
)
|
||||
|> set_cookie(
|
||||
"autocomplete_search_history_max_suggestions_when_typing",
|
||||
user_params["autocomplete_search_history_max_suggestions_when_typing"]
|
||||
)
|
||||
end
|
||||
|
||||
defp set_cookie(conn, params, param_name, cookie_name) do
|
||||
defp set_bool_cookie(conn, params, param_name, cookie_name) do
|
||||
set_cookie(conn, cookie_name, to_string(params[param_name] == "true"))
|
||||
end
|
||||
|
||||
defp set_cookie(conn, _, nil), do: conn
|
||||
|
||||
defp set_cookie(conn, cookie_name, value) do
|
||||
# JS wants access; max-age is set to 25 years from now
|
||||
Conn.put_resp_cookie(conn, cookie_name, to_string(params[param_name] == "true"),
|
||||
Conn.put_resp_cookie(conn, cookie_name, value,
|
||||
max_age: 788_923_800,
|
||||
http_only: false,
|
||||
extra: "SameSite=Lax"
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
.field
|
||||
= label f, :spoilered_complex_str, "Complex Spoiler Filter"
|
||||
br
|
||||
= textarea f, :spoilered_complex_str, class: "input input--wide", autocapitalize: "none", data: [autocomplete: "true", autocomplete_min_length: 3, autocomplete_mode: "search"]
|
||||
= textarea f, :spoilered_complex_str, class: "input input--wide", autocapitalize: "none", autocomplete: "off", data: [autocomplete: "multi-tags"]
|
||||
br
|
||||
= error_tag f, :spoilered_complex_str
|
||||
.fieldlabel
|
||||
|
@ -51,7 +51,7 @@
|
|||
.field
|
||||
= label f, :hidden_complex_str, "Complex Hide Filter"
|
||||
br
|
||||
= textarea f, :hidden_complex_str, class: "input input--wide", autocapitalize: "none", data: [autocomplete: "true", autocomplete_min_length: 3, autocomplete_mode: "search"]
|
||||
= textarea f, :hidden_complex_str, class: "input input--wide", autocapitalize: "none", autocomplete: "off", data: [autocomplete: "multi-tags"]
|
||||
br
|
||||
= error_tag f, :hidden_complex_str
|
||||
.fieldlabel
|
||||
|
|
|
@ -23,9 +23,11 @@ header.header
|
|||
value=@conn.params["q"]
|
||||
placeholder="Search"
|
||||
autocapitalize="none"
|
||||
data-autocomplete="true"
|
||||
data-autocomplete-min-length="3"
|
||||
data-autocomplete-mode="search"
|
||||
autocomplete=if(@conn.cookies["enable_search_ac"], do: "on", else: "off")
|
||||
inputmode="search"
|
||||
data-autocomplete="multi-tags"
|
||||
data-autocomplete-condition="enable_search_ac"
|
||||
data-autocomplete-history-id="search-history"
|
||||
]
|
||||
|
||||
= if present?(@conn.params["sf"]) do
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
' 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
|
||||
' 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: [autocomplete: "true", autocomplete_min_length: "3", autocomplete_source: "/autocomplete/tags?term="]
|
||||
= text_input f, :tag_name, value: assigns[:tag_name], class: "input", autocomplete: "off", placeholder: "artist:your-name", data: [autocomplete: "single-tag"]
|
||||
= error_tag f, :tag
|
||||
|
||||
.field
|
||||
|
|
|
@ -1,7 +1,18 @@
|
|||
h1 Search
|
||||
|
||||
= 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: [autocomplete: "true", autocomplete_min_length: 3, autocomplete_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"],
|
||||
autocomplete: if(@conn.cookies["enable_search_ac"], do: "on", else: "off"),
|
||||
inputmode: "search",
|
||||
data: [ \
|
||||
autocomplete: "multi-tags",
|
||||
autocomplete_condition: "enable_search_ac",
|
||||
autocomplete_history_id: "search-history",
|
||||
]
|
||||
|
||||
.block
|
||||
.block__header.flex
|
||||
|
|
|
@ -183,8 +183,32 @@ h1 Content Settings
|
|||
.fieldlabel: i Show streams marked as NSFW on the channels page.
|
||||
.field
|
||||
=> label f, :enable_search_ac, "Enable search auto-completion"
|
||||
=> checkbox f, :enable_search_ac, checked: @conn.cookies["enable_search_ac"] === "true"
|
||||
.fieldlabel: i Enable the auto-completion of tags in search fields.
|
||||
=> checkbox f, :enable_search_ac, checked: @conn.cookies["enable_search_ac"] == "true"
|
||||
|
||||
.autocomplete-settings class=if(@conn.cookies["enable_search_ac"] != "true", do: "hidden", else: "")
|
||||
.field
|
||||
=> label f,
|
||||
:autocomplete_search_history_hidden,
|
||||
"Hide search history in auto-completion"
|
||||
=> checkbox f,
|
||||
:autocomplete_search_history_hidden,
|
||||
checked: @conn.cookies["autocomplete_search_history_hidden"] == "true"
|
||||
|
||||
.autocomplete-search-history-settings[
|
||||
class=if(@conn.cookies["autocomplete_search_history_hidden"] == "true", do: "hidden", else: "")
|
||||
]
|
||||
.field
|
||||
=> label f,
|
||||
:autocomplete_search_history_max_suggestions_when_typing,
|
||||
"Maximum number of search history suggestions in autocompletion when typing"
|
||||
=> number_input f,
|
||||
:autocomplete_search_history_max_suggestions_when_typing,
|
||||
min: 0,
|
||||
max: 10,
|
||||
step: 1,
|
||||
value: @conn.cookies["autocomplete_search_history_max_suggestions_when_typing"] || 3,
|
||||
class: "input"
|
||||
|
||||
= if staff?(@conn.assigns.current_user) do
|
||||
.field
|
||||
=> label f, :hide_staff_tools
|
||||
|
|
|
@ -16,9 +16,8 @@ elixir:
|
|||
placeholder="add a tag"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
data-autocomplete="true"
|
||||
data-autocomplete-min-length="3"
|
||||
data-autocomplete-source="/autocomplete/tags?term="
|
||||
data-autocomplete="single-tag"
|
||||
data-autocomplete-max-suggestions=5
|
||||
]
|
||||
button.button.button--state-primary.button--bold[
|
||||
class="js-taginput-show"
|
||||
|
|
|
@ -2,7 +2,17 @@ h1 Tags
|
|||
|
||||
= form_for :tags, ~p"/tags", [method: "get", class: "hform", enforce_utf8: false], fn f ->
|
||||
.field
|
||||
= text_input f, :tq, name: :tq, value: @conn.params["tq"] || "*", class: "input hform__text", placeholder: "Search tags", autocapitalize: "none"
|
||||
= text_input f, :tq, name: :tq, value: @conn.params["tq"] || "*",
|
||||
class: "input hform__text",
|
||||
placeholder: "Search tags",
|
||||
autocapitalize: "none",
|
||||
autocomplete: if(@conn.cookies["enable_search_ac"], do: "on", else: "off"),
|
||||
inputmode: "search",
|
||||
data: [ \
|
||||
autocomplete: "multi-tags",
|
||||
autocomplete_condition: "enable_search_ac",
|
||||
]
|
||||
|
||||
= submit "Search", class: "hform__button button"
|
||||
|
||||
.fieldlabel
|
||||
|
|
Loading…
Add table
Reference in a new issue