Merge pull request #458 from MareStare/feat/autocomplete-history-full

Full autocomplete history feature
This commit is contained in:
liamwhite 2025-03-16 14:20:21 -04:00 committed by GitHub
commit 28cd6ad987
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2219 additions and 822 deletions

View file

@ -165,5 +165,14 @@
--dnp-warning-hover-color: hsl(from $vote-down-color h s calc(l + 10)); --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)); --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)); --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));
} }
} }

View file

@ -162,5 +162,14 @@
--dnp-warning-hover-color: hsl(from $vote-down-color h s calc(l + 10)); --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)); --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)); --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));
} }
} }

View file

@ -24,27 +24,91 @@
} }
/* Autocomplete */ /* Autocomplete */
.autocomplete__list { .autocomplete {
cursor: pointer; cursor: pointer;
display: inline-block; display: inline-block;
list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
position: absolute; position: absolute;
user-select: none; user-select: none;
white-space: nowrap; white-space: nowrap;
z-index: 999; 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 { .autocomplete__item {
background: var(--base-color);
color: var(--link-light-color);
padding: 5px; padding: 5px;
} }
.autocomplete__item--selected { .autocomplete__item__content {
background: var(--link-light-color); /* Squash overly long suggestions */
color: var(--base-color); 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 */ /* Tags */

View file

@ -1,6 +1,5 @@
import { $, $$, hideEl } from '../utils/dom'; import { $, $$, hideEl } from '../utils/dom';
import { assertNotNull } from '../utils/assert'; import { assertNotNull } from '../utils/assert';
import { TermSuggestion } from '../utils/suggestions';
import { setupTagsInput, addTag, reloadTagsInput } from '../tagsinput'; import { setupTagsInput, addTag, reloadTagsInput } from '../tagsinput';
const formData = `<form class="tags-form"> const formData = `<form class="tags-form">
@ -96,7 +95,7 @@ describe('Fancy tags input', () => {
it('should respond to autocomplete events', () => { it('should respond to autocomplete events', () => {
setupTagsInput(tagBlock); 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); expect($$('span.tag', fancyInput)).toHaveLength(1);
}); });

View file

@ -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,
),
}),
);
});
}

View 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;
}

View 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",
],
}
`);
});

View 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": [],
}
`);
});

View file

@ -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);
});

View 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();
}
}

View 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);
}
}

View 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));
}

View 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 [];
}
}
}

View 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));
}

View file

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

View file

@ -22,8 +22,15 @@ const tokenList: Token[] = [
export type ParseTerm = (term: string, fuzz: number, boost: number) => AstMatcher; export type ParseTerm = (term: string, fuzz: number, boost: number) => AstMatcher;
export type Range = [number, number]; export interface Range {
export type TermContext = [Range, string]; start: number;
end: number;
}
export interface TermContext {
range: Range;
content: string;
}
export interface LexResult { export interface LexResult {
tokenList: TokenList; tokenList: TokenList;
@ -61,7 +68,11 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR
if (searchTerm !== null) { if (searchTerm !== null) {
// Push to stack. // Push to stack.
ret.tokenList.push(parseTerm(searchTerm, fuzz, boost)); 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. // Reset term and options data.
boost = 1; boost = 1;
fuzz = 0; fuzz = 0;

View file

@ -3,7 +3,7 @@
*/ */
import { assertNotNull, assertNotUndefined } from './utils/assert'; import { assertNotNull, assertNotUndefined } from './utils/assert';
import { $, $$ } from './utils/dom'; import { $, $$, hideIf } from './utils/dom';
import store from './utils/store'; import store from './utils/store';
function setupThemeSettings() { function setupThemeSettings() {
@ -28,17 +28,34 @@ function setupThemeSettings() {
themeColorSelect.addEventListener('change', themePreviewCallback); 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() { export function setupSettings() {
if (!$('#js-setting-table')) return; if (!$('#js-setting-table')) return;
const localCheckboxes = $$<HTMLInputElement>('[data-tab="local"] input[type="checkbox"]');
// Local settings // Local settings
localCheckboxes.forEach(checkbox => { for (const input of $$<HTMLInputElement>('[data-tab="local"] input')) {
checkbox.addEventListener('change', () => { input.addEventListener('change', () => {
store.set(checkbox.id.replace('user_', ''), checkbox.checked); const newValue = input.type === 'checkbox' ? input.checked : input.value;
store.set(input.id.replace('user_', ''), newValue);
}); });
}); }
setupThemeSettings(); setupThemeSettings();
setupAutocompleteSettings();
} }

View file

@ -4,7 +4,6 @@
import { assertNotNull, assertType } from './utils/assert'; import { assertNotNull, assertType } from './utils/assert';
import { $, $$, clearEl, removeEl, showEl, hideEl, escapeCss, escapeHtml } from './utils/dom'; import { $, $$, clearEl, removeEl, showEl, hideEl, escapeCss, escapeHtml } from './utils/dom';
import { TermSuggestion } from './utils/suggestions';
export function setupTagsInput(tagBlock: HTMLDivElement) { export function setupTagsInput(tagBlock: HTMLDivElement) {
const form = assertNotNull(tagBlock.closest('form')); const form = assertNotNull(tagBlock.closest('form'));
@ -48,8 +47,8 @@ export function setupTagsInput(tagBlock: HTMLDivElement) {
importTags(); importTags();
} }
function handleAutocomplete(event: CustomEvent<TermSuggestion>) { function handleAutocomplete(event: CustomEvent<string>) {
insertTag(event.detail.value); insertTag(event.detail);
inputField.focus(); inputField.focus();
} }

View file

@ -14,6 +14,7 @@ import {
findFirstTextNode, findFirstTextNode,
disableEl, disableEl,
enableEl, enableEl,
hideIf,
} from '../dom'; } from '../dom';
import { getRandomArrayItem, getRandomIntBetween } from '../../../test/randomness'; import { getRandomArrayItem, getRandomIntBetween } from '../../../test/randomness';
import { fireEvent } from '@testing-library/dom'; import { fireEvent } from '@testing-library/dom';
@ -444,4 +445,18 @@ describe('DOM Utilities', () => {
expect(result).toBe(undefined); 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');
});
});
}); });

View file

@ -1,12 +1,4 @@
import { import { delegate, fire, leftClick, on, PhilomenaAvailableEventsMap, oncePersistedPageShown } from '../events';
delegate,
fire,
mouseMoveThenOver,
leftClick,
on,
PhilomenaAvailableEventsMap,
oncePersistedPageShown,
} from '../events';
import { getRandomArrayItem } from '../../../test/randomness'; import { getRandomArrayItem } from '../../../test/randomness';
import { fireEvent } from '@testing-library/dom'; 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', () => { describe('oncePersistedPageShown', () => {
it('should NOT fire on usual page show', () => { it('should NOT fire on usual page show', () => {
const mockHandler = vi.fn(); const mockHandler = vi.fn();

View file

@ -3,9 +3,8 @@ import { promises } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { TextDecoder } from 'util'; import { TextDecoder } from 'util';
describe('Local Autocompleter', () => { describe('LocalAutocompleter', () => {
let mockData: ArrayBuffer; let mockData: ArrayBuffer;
const defaultK = 5;
beforeAll(async () => { beforeAll(async () => {
const mockDataPath = join(__dirname, 'autocomplete-compiled-v2.bin'); const mockDataPath = join(__dirname, 'autocomplete-compiled-v2.bin');
@ -44,59 +43,81 @@ describe('Local Autocompleter', () => {
}); });
}); });
describe('topK', () => { describe('matchPrefix', () => {
const termStem = ['f', 'o'].join(''); const termStem = ['f', 'o'].join('');
let localAutocomplete: LocalAutocompleter; 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;
});
beforeAll(() => { return expect(actual);
localAutocomplete = new LocalAutocompleter(mockData); }
});
beforeEach(() => { beforeEach(() => {
window.booru.hiddenTagList = []; window.booru.hiddenTagList = [];
}); });
it('should return suggestions for exact tag name match', () => { it('should return suggestions for exact tag name match', () => {
const result = localAutocomplete.matchPrefix('safe', defaultK); expectLocalAutocomplete('safe').toMatchInlineSnapshot(`
expect(result).toEqual([expect.objectContaining({ aliasName: 'safe', name: 'safe', imageCount: 6 })]); [
"safe (6)",
]
`);
}); });
it('should return suggestion for original tag when passed an alias', () => { it('should return suggestion for an alias', () => {
const result = localAutocomplete.matchPrefix('flowers', defaultK); expectLocalAutocomplete('flowers').toMatchInlineSnapshot(`
expect(result).toEqual([expect.objectContaining({ aliasName: 'flowers', name: 'flower', imageCount: 1 })]); [
"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', () => { it('should return suggestions sorted by image count', () => {
const result = localAutocomplete.matchPrefix(termStem, defaultK); expectLocalAutocomplete(termStem).toMatchInlineSnapshot(`
expect(result).toEqual([ [
expect.objectContaining({ aliasName: 'forest', name: 'forest', imageCount: 3 }), "forest (3)",
expect.objectContaining({ aliasName: 'fog', name: 'fog', imageCount: 1 }), "fog (1)",
expect.objectContaining({ aliasName: 'force field', name: 'force field', imageCount: 1 }), "force field (1)",
]); ]
`);
}); });
it('should return namespaced suggestions without including namespace', () => { it('should return namespaced suggestions without including namespace', () => {
const result = localAutocomplete.matchPrefix('test', defaultK); expectLocalAutocomplete('test').toMatchInlineSnapshot(`
expect(result).toEqual([ [
expect.objectContaining({ aliasName: 'artist:test', name: 'artist:test', imageCount: 1 }), "artist:test (1)",
]); ]
`);
}); });
it('should return only the required number of suggestions', () => { it('should return only the required number of suggestions', () => {
const result = localAutocomplete.matchPrefix(termStem, 1); expectLocalAutocomplete(termStem, 1).toMatchInlineSnapshot(`
expect(result).toEqual([expect.objectContaining({ aliasName: 'forest', name: 'forest', imageCount: 3 })]); [
"forest (3)",
]
`);
}); });
it('should NOT return suggestions associated with hidden tags', () => { it('should NOT return suggestions associated with hidden tags', () => {
window.booru.hiddenTagList = [1]; window.booru.hiddenTagList = [1];
const result = localAutocomplete.matchPrefix(termStem, defaultK); expectLocalAutocomplete(termStem).toMatchInlineSnapshot(`[]`);
expect(result).toEqual([]);
}); });
it('should return empty array for empty prefix', () => { it('should return empty array for empty prefix', () => {
const result = localAutocomplete.matchPrefix('', defaultK); expectLocalAutocomplete('').toMatchInlineSnapshot(`[]`);
expect(result).toEqual([]);
}); });
}); });
}); });

View file

@ -1,37 +1,35 @@
import { fetchMock } from '../../../test/fetch-mock.ts';
import { import {
fetchLocalAutocomplete,
fetchSuggestions,
formatLocalAutocompleteResult,
purgeSuggestionsCache,
SuggestionsPopup, SuggestionsPopup,
TermSuggestion, TagSuggestion,
TagSuggestionParams,
Suggestions,
HistorySuggestion,
ItemSelectedEvent,
} from '../suggestions.ts'; } from '../suggestions.ts';
import fs from 'fs';
import path from 'path';
import { LocalAutocompleter } from '../local-autocompleter.ts';
import { afterEach } from 'vitest'; import { afterEach } from 'vitest';
import { fireEvent } from '@testing-library/dom'; import { fireEvent } from '@testing-library/dom';
import { getRandomIntBetween } from '../../../test/randomness.ts'; import { assertNotNull } from '../assert.ts';
const mockedSuggestionsEndpoint = '/endpoint?term='; const mockedSuggestions: Suggestions = {
const mockedSuggestionsResponse = [ history: ['foo bar', 'bar baz', 'baz qux'].map(content => new HistorySuggestion(content, 0)),
{ label: 'artist:assasinmonkey (1)', value: 'artist:assasinmonkey' }, tags: [
{ label: 'artist:hydrusbeta (1)', value: 'artist:hydrusbeta' }, { images: 10, canonical: 'artist:assasinmonkey' },
{ label: 'artist:the sexy assistant (1)', value: 'artist:the sexy assistant' }, { images: 10, canonical: 'artist:hydrusbeta' },
{ label: 'artist:devinian (1)', value: 'artist:devinian' }, { images: 10, canonical: 'artist:the sexy assistant' },
{ label: 'artist:moe (1)', value: 'artist:moe' }, { images: 10, canonical: 'artist:devinian' },
]; { images: 10, canonical: 'artist:moe' },
].map(tags => new TagSuggestion({ ...tags, matchLength: 0 })),
};
function mockBaseSuggestionsPopup(includeMockedSuggestions: boolean = false): [SuggestionsPopup, HTMLInputElement] { function mockBaseSuggestionsPopup(includeMockedSuggestions: boolean = false): [SuggestionsPopup, HTMLInputElement] {
const input = document.createElement('input'); const input = document.createElement('input');
const popup = new SuggestionsPopup(); const popup = new SuggestionsPopup();
document.body.append(input); document.body.append(input);
popup.showForField(input); popup.showForElement(input);
if (includeMockedSuggestions) { if (includeMockedSuggestions) {
popup.renderSuggestions(mockedSuggestionsResponse); popup.setSuggestions(mockedSuggestions);
} }
return [popup, input]; return [popup, input];
@ -40,27 +38,9 @@ function mockBaseSuggestionsPopup(includeMockedSuggestions: boolean = false): [S
const selectedItemClassName = 'autocomplete__item--selected'; const selectedItemClassName = 'autocomplete__item--selected';
describe('Suggestions', () => { describe('Suggestions', () => {
let mockedAutocompleteBuffer: ArrayBuffer;
let popup: SuggestionsPopup | undefined; let popup: SuggestionsPopup | undefined;
let input: HTMLInputElement | 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(() => { afterEach(() => {
if (input) { if (input) {
input.remove(); input.remove();
@ -69,6 +49,7 @@ describe('Suggestions', () => {
if (popup) { if (popup) {
popup.hide(); popup.hide();
popup.setSuggestions({ history: [], tags: [] });
popup = undefined; popup = undefined;
} }
}); });
@ -78,113 +59,113 @@ describe('Suggestions', () => {
[popup, input] = mockBaseSuggestionsPopup(); [popup, input] = mockBaseSuggestionsPopup();
expect(document.querySelector('.autocomplete')).toBeInstanceOf(HTMLElement); expect(document.querySelector('.autocomplete')).toBeInstanceOf(HTMLElement);
expect(popup.isActive).toBe(true); expect(popup.isHidden).toBe(false);
});
it('should be removed when hidden', () => {
[popup, input] = mockBaseSuggestionsPopup();
popup.hide();
expect(document.querySelector('.autocomplete')).not.toBeInstanceOf(HTMLElement);
expect(popup.isActive).toBe(false);
}); });
it('should render suggestions', () => { it('should render suggestions', () => {
[popup, input] = mockBaseSuggestionsPopup(true); [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, input] = mockBaseSuggestionsPopup(true);
popup.selectNext(); popup.selectDown();
expect(document.querySelector('.autocomplete__item:first-child')).toHaveClass(selectedItemClassName); 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, input] = mockBaseSuggestionsPopup(true);
popup.selectPrevious(); popup.selectUp();
expect(document.querySelector('.autocomplete__item:last-child')).toHaveClass(selectedItemClassName); 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); [popup, input] = mockBaseSuggestionsPopup(true);
const firstItem = document.querySelector('.autocomplete__item:first-child'); popup.selectCtrlDown();
const lastItem = document.querySelector('.autocomplete__item:last-child');
if (firstItem) { expect(popup.selectedSuggestion).toBe(mockedSuggestions.tags[0]);
fireEvent.mouseOver(firstItem); expect(document.querySelector('.autocomplete__item__tag')).toHaveClass(selectedItemClassName);
fireEvent.mouseMove(firstItem);
}
expect(firstItem).toHaveClass(selectedItemClassName); popup.selectCtrlDown();
if (lastItem) { expect(popup.selectedSuggestion).toBe(mockedSuggestions.tags.at(-1));
fireEvent.mouseOver(lastItem); expect(document.querySelector('.autocomplete__item__tag:last-child')).toHaveClass(selectedItemClassName);
fireEvent.mouseMove(lastItem);
}
expect(firstItem).not.toHaveClass(selectedItemClassName); // Should loop around
expect(lastItem).toHaveClass(selectedItemClassName); popup.selectCtrlDown();
expect(popup.selectedSuggestion).toBe(mockedSuggestions.history[0]);
if (lastItem) { expect(document.querySelector('.autocomplete__item:first-child')).toHaveClass(selectedItemClassName);
fireEvent.mouseOut(lastItem);
}
expect(lastItem).not.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); [popup, input] = mockBaseSuggestionsPopup(true);
const secondItem = document.querySelector('.autocomplete__item:nth-child(2)'); popup.selectCtrlUp();
const thirdItem = document.querySelector('.autocomplete__item:nth-child(3)');
if (secondItem) { expect(popup.selectedSuggestion).toBe(mockedSuggestions.tags.at(-1));
fireEvent.mouseOver(secondItem); expect(document.querySelector('.autocomplete__item__tag:last-child')).toHaveClass(selectedItemClassName);
fireEvent.mouseMove(secondItem);
}
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); popup.selectCtrlUp();
expect(thirdItem).toHaveClass(selectedItemClassName);
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', () => { it('should loop around when selecting next on last and previous on first', () => {
[popup, input] = mockBaseSuggestionsPopup(true); [popup, input] = mockBaseSuggestionsPopup(true);
const firstItem = document.querySelector('.autocomplete__item:first-child'); const firstItem = assertNotNull(document.querySelector('.autocomplete__item:first-child'));
const lastItem = document.querySelector('.autocomplete__item:last-child'); const lastItem = assertNotNull(document.querySelector('.autocomplete__item:last-child'));
if (lastItem) { popup.selectUp();
fireEvent.mouseOver(lastItem);
fireEvent.mouseMove(lastItem);
}
expect(lastItem).toHaveClass(selectedItemClassName); expect(lastItem).toHaveClass(selectedItemClassName);
popup.selectNext(); popup.selectDown();
expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull(); expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull();
popup.selectNext(); popup.selectDown();
expect(firstItem).toHaveClass(selectedItemClassName); expect(firstItem).toHaveClass(selectedItemClassName);
popup.selectPrevious(); popup.selectUp();
expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull(); expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull();
popup.selectPrevious(); popup.selectUp();
expect(lastItem).toHaveClass(selectedItemClassName); expect(lastItem).toHaveClass(selectedItemClassName);
}); });
@ -192,176 +173,112 @@ describe('Suggestions', () => {
it('should return selected item value', () => { it('should return selected item value', () => {
[popup, input] = mockBaseSuggestionsPopup(true); [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); [popup, input] = mockBaseSuggestionsPopup(true);
let clickEvent: CustomEvent<TermSuggestion> | undefined; const itemSelectedHandler = vi.fn<(event: ItemSelectedEvent) => void>();
const itemSelectedHandler = vi.fn((event: CustomEvent<TermSuggestion>) => {
clickEvent = event;
});
popup.onItemSelected(itemSelectedHandler); popup.onItemSelected(itemSelectedHandler);
const firstItem = document.querySelector('.autocomplete__item'); const firstItem = assertNotNull(document.querySelector('.autocomplete__item'));
if (firstItem) { fireEvent.click(firstItem);
fireEvent.click(firstItem);
}
expect(itemSelectedHandler).toBeCalledTimes(1); expect(itemSelectedHandler).toBeCalledTimes(1);
expect(clickEvent?.detail).toEqual(mockedSuggestionsResponse[0]); expect(itemSelectedHandler).toBeCalledWith({
}); ctrlKey: false,
shiftKey: false,
it('should not emit selection on items without value', () => { suggestion: mockedSuggestions.history[0],
[popup, input] = mockBaseSuggestionsPopup(); });
popup.renderSuggestions([{ label: 'Option without value', value: '' }]);
const itemSelectionHandler = vi.fn();
popup.onItemSelected(itemSelectionHandler);
const firstItem = document.querySelector('.autocomplete__item:first-child')!;
if (firstItem) {
fireEvent.click(firstItem);
}
expect(itemSelectionHandler).not.toBeCalled();
}); });
}); });
describe('fetchSuggestions', () => { describe('HistorySuggestion', () => {
it('should only call fetch once per single term', () => { it('should render the suggestion', () => {
fetchSuggestions(mockedSuggestionsEndpoint, 'art'); expectHistoryRender('foo bar').toMatchInlineSnapshot(`
fetchSuggestions(mockedSuggestionsEndpoint, 'art'); {
"label": " foo bar",
expect(fetch).toHaveBeenCalledTimes(1); "value": "foo bar",
}); }
`);
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', () => { describe('TagSuggestion', () => {
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', () => { it('should format suggested tags as tag name and the count', () => {
const tagName = 'safe'; // The snapshots in this test contain a "narrow no-break space"
const tagCount = getRandomIntBetween(5, 10); /* eslint-disable no-irregular-whitespace */
expectTagRender({ canonical: 'safe', images: 10 }).toMatchInlineSnapshot(`
const resultObject = formatLocalAutocompleteResult({ {
name: tagName, "label": " safe 10",
aliasName: tagName, "value": "safe",
imageCount: tagCount, }
}); `);
expectTagRender({ canonical: 'safe', images: 10_000 }).toMatchInlineSnapshot(`
expect(resultObject.label).toBe(`${tagName} (${tagCount})`); {
expect(resultObject.value).toBe(tagName); "label": " safe 10000",
"value": "safe",
}
`);
expectTagRender({ canonical: 'safe', images: 100_000 }).toMatchInlineSnapshot(`
{
"label": " safe 100000",
"value": "safe",
}
`);
expectTagRender({ canonical: 'safe', images: 1000_000 }).toMatchInlineSnapshot(`
{
"label": " safe 1000000",
"value": "safe",
}
`);
expectTagRender({ canonical: 'safe', images: 10_000_000 }).toMatchInlineSnapshot(`
{
"label": " safe 10000000",
"value": "safe",
}
`);
/* eslint-enable no-irregular-whitespace */
}); });
it('should display original alias name for aliased tags', () => { it('should display alias -> canonical for aliased tags', () => {
const tagName = 'safe'; expectTagRender({ images: 10, canonical: 'safe', alias: 'rating:safe' }).toMatchInlineSnapshot(
const tagAlias = 'rating:safe'; `
const tagCount = getRandomIntBetween(5, 10); {
"label": " rating:safe → safe 10",
const resultObject = formatLocalAutocompleteResult({ "value": "safe",
name: tagName, }
aliasName: tagAlias, `,
imageCount: tagCount, );
});
expect(resultObject.label).toBe(`${tagAlias}${tagName} (${tagCount})`);
expect(resultObject.value).toBe(tagName);
}); });
}); });
}); });
function expectHistoryRender(content: string) {
const suggestion = new HistorySuggestion(content, 0);
const label = suggestion
.render()
.map(el => el.textContent)
.join('');
const value = suggestion.value();
return expect({ label, value });
}
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();
return expect({ label, value });
}

View file

@ -110,3 +110,11 @@ export function escapeCss(css: string): string {
export function findFirstTextNode<N extends Node>(of: Node): N { export function findFirstTextNode<N extends Node>(of: Node): N {
return Array.prototype.filter.call(of.childNodes, el => el.nodeType === Node.TEXT_NODE)[0]; 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');
}
}

View file

@ -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) { export function oncePersistedPageShown(func: (e: PageTransitionEvent) => void) {
const controller = new AbortController(); const controller = new AbortController();

View file

@ -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'; import { retry } from './retry';
interface RequestParams extends RequestInit { interface RequestParams extends RequestInit {
@ -97,4 +94,3 @@ function generateId(prefix: string) {
return chars.join(''); return chars.join('');
} }
/* v8 ignore end */

View file

@ -3,9 +3,21 @@ import { UniqueHeap } from './unique-heap';
import store from './store'; import store from './store';
export interface Result { export interface Result {
aliasName: string; /**
name: string; * If present, then this suggestion is for a tag alias.
imageCount: number; * 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); this.scanResults(referenceToAliasIndex, namespaceMatch, hasFilteredAssociation, isAlias, results);
// Convert top K from heap into result array // Convert top K from heap into result array
return results.topK(k).map((i: TagReferenceIndex) => ({ return results.topK(k).map((i: TagReferenceIndex) => {
aliasName: this.decoder.decode(this.referenceToName(i, false)), const alias = this.decoder.decode(this.referenceToName(i, false));
name: this.decoder.decode(this.referenceToName(i)), const canonical = this.decoder.decode(this.referenceToName(i));
imageCount: this.getImageCount(i), const result: Result = {
})); canonical,
images: this.getImageCount(i),
};
if (alias !== canonical) {
result.alias = alias;
}
return result;
});
} }
} }

View file

@ -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 * localStorage utils
*/ */
@ -81,4 +78,3 @@ export default {
return lastUpdatedTime === null || Date.now() > lastUpdatedTime; return lastUpdatedTime === null || Date.now() > lastUpdatedTime;
}, },
}; };
/* v8 ignore end */

View file

@ -1,125 +1,332 @@
import { makeEl } from './dom.ts'; 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 { export interface TagSuggestionParams {
label: string; /**
value: string; * 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 { 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 container: HTMLElement;
private readonly listElement: HTMLUListElement;
private selectedElement: HTMLElement | null = null;
constructor() { constructor() {
this.container = makeEl('div', { this.container = makeEl('div', {
className: 'autocomplete', className: 'autocomplete hidden',
tabIndex: -1,
}); });
this.listElement = makeEl('ul', { document.body.appendChild(this.container);
className: 'autocomplete__list', this.items = [];
});
this.container.appendChild(this.listElement);
} }
get selectedTerm(): string | null { get selectedSuggestion(): Suggestion | null {
return this.selectedElement?.dataset.value || null; return this.selectedItem?.suggestion ?? null;
} }
get isActive(): boolean { private get selectedItem(): SuggestionItem | null {
return this.container.isConnected; if (this.cursor < 0) {
return null;
}
return this.items[this.cursor];
}
get isHidden(): boolean {
return this.container.classList.contains('hidden');
} }
hide() { hide() {
this.clearSelection(); this.clearSelection();
this.container.remove(); this.container.classList.add('hidden');
} }
private clearSelection() { private clearSelection() {
if (!this.selectedElement) return; this.setSelection(-1);
this.selectedElement.classList.remove(selectedSuggestionClassName);
this.selectedElement = null;
} }
private updateSelection(targetItem: HTMLElement) { private setSelection(index: number) {
this.clearSelection(); if (this.cursor === index) {
return;
}
this.selectedElement = targetItem; // This can't be triggered via the public API of this class
this.selectedElement.classList.add(selectedSuggestionClassName); /* v8 ignore start */
if (index < -1 || index >= this.items.length) {
throw new Error(`BUG: setSelection(): invalid selection index: ${index}`);
}
/* v8 ignore end */
const selectedClass = 'autocomplete__item--selected';
this.selectedItem?.element.classList.remove(selectedClass);
this.cursor = index;
if (index >= 0) {
this.selectedItem?.element.classList.add(selectedClass);
}
} }
renderSuggestions(suggestions: TermSuggestion[]): SuggestionsPopup { setSuggestions(params: Suggestions): SuggestionsPopup {
this.clearSelection(); this.cursor = -1;
this.items = [];
this.container.innerHTML = '';
this.listElement.innerHTML = ''; for (const suggestion of params.history) {
this.appendSuggestion(suggestion);
}
for (const suggestedTerm of suggestions) { if (params.tags.length > 0 && params.history.length > 0) {
const listItem = makeEl('li', { this.container.appendChild(makeEl('hr', { className: 'autocomplete__separator' }));
className: 'autocomplete__item', }
innerText: suggestedTerm.label,
});
listItem.dataset.value = suggestedTerm.value; for (const suggestion of params.tags) {
this.appendSuggestion(suggestion);
this.watchItem(listItem, suggestedTerm);
this.listElement.appendChild(listItem);
} }
return this; return this;
} }
private watchItem(listItem: HTMLElement, suggestion: TermSuggestion) { appendSuggestion(suggestion: Suggestion) {
// This makes sure the item isn't selected if the mouse pointer happens to const type = suggestion instanceof TagSuggestion ? 'tag' : 'history';
// 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));
listItem.addEventListener('mouseout', () => this.clearSelection()); const element = makeEl(
'div',
{
className: `autocomplete__item autocomplete__item__${type}`,
},
suggestion.render(),
);
listItem.addEventListener('click', () => { const item: SuggestionItem = { element, suggestion };
if (!listItem.dataset.value) {
return;
}
this.container.dispatchEvent(new CustomEvent('item_selected', { detail: suggestion })); this.watchItem(item);
this.items.push(item);
this.container.appendChild(element);
}
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) { private changeSelection(direction: number) {
let nextTargetElement: Element | null; if (this.items.length === 0) {
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();
return; 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); this.changeSelection(1);
} }
selectPrevious() { selectUp() {
this.changeSelection(-1); 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.position = 'absolute';
this.container.style.left = `${targetElement.offsetLeft}px`; this.container.style.left = `${targetElement.offsetLeft}px`;
@ -130,66 +337,12 @@ export class SuggestionsPopup {
} }
this.container.style.top = `${topPosition}px`; this.container.style.top = `${topPosition}px`;
this.container.classList.remove('hidden');
document.body.appendChild(this.container);
} }
onItemSelected(callback: (event: CustomEvent<TermSuggestion>) => void) { onItemSelected(callback: (event: ItemSelectedEvent) => void) {
this.container.addEventListener('item_selected', callback as EventListener); this.container.addEventListener('item_selected', event => {
} callback((event as CustomEvent<ItemSelectedEvent>).detail);
}
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 [];
}); });
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})`,
};
} }

View file

@ -37,21 +37,36 @@ defmodule PhilomenaWeb.SettingController do
defp update_local_settings(conn, user_params) do defp update_local_settings(conn, user_params) do
conn conn
|> set_cookie(user_params, "hidpi", "hidpi") |> set_bool_cookie(user_params, "hidpi", "hidpi")
|> set_cookie(user_params, "webm", "webm") |> set_bool_cookie(user_params, "webm", "webm")
|> set_cookie(user_params, "serve_webm", "serve_webm") |> set_bool_cookie(user_params, "serve_webm", "serve_webm")
|> set_cookie(user_params, "unmute_videos", "unmute_videos") |> set_bool_cookie(user_params, "unmute_videos", "unmute_videos")
|> set_cookie(user_params, "chan_nsfw", "chan_nsfw") |> set_bool_cookie(user_params, "chan_nsfw", "chan_nsfw")
|> set_cookie(user_params, "hide_staff_tools", "hide_staff_tools") |> set_bool_cookie(user_params, "hide_staff_tools", "hide_staff_tools")
|> set_cookie(user_params, "hide_uploader", "hide_uploader") |> set_bool_cookie(user_params, "hide_uploader", "hide_uploader")
|> set_cookie(user_params, "hide_score", "hide_score") |> set_bool_cookie(user_params, "hide_score", "hide_score")
|> set_cookie(user_params, "unfilter_tag_suggestions", "unfilter_tag_suggestions") |> set_bool_cookie(user_params, "unfilter_tag_suggestions", "unfilter_tag_suggestions")
|> set_cookie(user_params, "enable_search_ac", "enable_search_ac") |> 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 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 # 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, max_age: 788_923_800,
http_only: false, http_only: false,
extra: "SameSite=Lax" extra: "SameSite=Lax"

View file

@ -26,7 +26,7 @@
.field .field
= label f, :spoilered_complex_str, "Complex Spoiler Filter" = label f, :spoilered_complex_str, "Complex Spoiler Filter"
br br
= textarea f, :spoilered_complex_str, class: "input input--wide", autocapitalize: "none", data: [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 br
= error_tag f, :spoilered_complex_str = error_tag f, :spoilered_complex_str
.fieldlabel .fieldlabel
@ -51,7 +51,7 @@
.field .field
= label f, :hidden_complex_str, "Complex Hide Filter" = label f, :hidden_complex_str, "Complex Hide Filter"
br br
= textarea f, :hidden_complex_str, class: "input input--wide", autocapitalize: "none", data: [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 br
= error_tag f, :hidden_complex_str = error_tag f, :hidden_complex_str
.fieldlabel .fieldlabel

View file

@ -23,9 +23,11 @@ header.header
value=@conn.params["q"] value=@conn.params["q"]
placeholder="Search" placeholder="Search"
autocapitalize="none" autocapitalize="none"
data-autocomplete="true" autocomplete=if(@conn.cookies["enable_search_ac"], do: "on", else: "off")
data-autocomplete-min-length="3" inputmode="search"
data-autocomplete-mode="search" data-autocomplete="multi-tags"
data-autocomplete-condition="enable_search_ac"
data-autocomplete-history-id="search-history"
] ]
= if present?(@conn.params["sf"]) do = if present?(@conn.params["sf"]) do

View file

@ -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 ' Artist Link validation is intended for artists. Validating your link will give you control over your content on the site, allowing you to create a
a> href="/commissions" commissions a> href="/commissions" commissions
' listing and request takedowns or DNPs. Do not request a link if the source contains no artwork which you have created. ' listing and request takedowns or DNPs. Do not request a link if the source contains no artwork which you have created.
= text_input f, :tag_name, value: assigns[:tag_name], class: "input", autocomplete: "off", placeholder: "artist:your-name", data: [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 = error_tag f, :tag
.field .field

View file

@ -1,7 +1,18 @@
h1 Search h1 Search
= form_for :search, ~p"/search", [id: "searchform", method: "get", class: "js-search-form", enforce_utf8: false], fn f -> = form_for :search, ~p"/search", [id: "searchform", method: "get", class: "js-search-form", enforce_utf8: false], fn f ->
= text_input f, :q, class: "input input--wide js-search-field", placeholder: "Search terms are chained with commas", autocapitalize: "none", name: "q", value: @conn.params["q"], data: [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
.block__header.flex .block__header.flex

View file

@ -183,8 +183,32 @@ h1 Content Settings
.fieldlabel: i Show streams marked as NSFW on the channels page. .fieldlabel: i Show streams marked as NSFW on the channels page.
.field .field
=> label f, :enable_search_ac, "Enable search auto-completion" => label f, :enable_search_ac, "Enable search auto-completion"
=> checkbox f, :enable_search_ac, checked: @conn.cookies["enable_search_ac"] === "true" => checkbox f, :enable_search_ac, checked: @conn.cookies["enable_search_ac"] == "true"
.fieldlabel: i Enable the auto-completion of tags in search fields.
.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 = if staff?(@conn.assigns.current_user) do
.field .field
=> label f, :hide_staff_tools => label f, :hide_staff_tools

View file

@ -16,9 +16,8 @@ elixir:
placeholder="add a tag" placeholder="add a tag"
autocomplete="off" autocomplete="off"
autocapitalize="none" autocapitalize="none"
data-autocomplete="true" data-autocomplete="single-tag"
data-autocomplete-min-length="3" data-autocomplete-max-suggestions=5
data-autocomplete-source="/autocomplete/tags?term="
] ]
button.button.button--state-primary.button--bold[ button.button.button--state-primary.button--bold[
class="js-taginput-show" class="js-taginput-show"

View file

@ -2,7 +2,17 @@ h1 Tags
= form_for :tags, ~p"/tags", [method: "get", class: "hform", enforce_utf8: false], fn f -> = form_for :tags, ~p"/tags", [method: "get", class: "hform", enforce_utf8: false], fn f ->
.field .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" = submit "Search", class: "hform__button button"
.fieldlabel .fieldlabel
@ -31,7 +41,7 @@ h2 Search Results
.block.block--fixed.block--danger .block.block--fixed.block--danger
' Oops, there was an error parsing your query! Check for mistakes like mismatched parentheses. The error was: ' Oops, there was an error parsing your query! Check for mistakes like mismatched parentheses. The error was:
pre = assigns[:error] pre = assigns[:error]
- true -> - true ->
p p
' No tags found! ' No tags found!