Skip to content

Commit

Permalink
Start a v2 rewrite of completions code. Add keydown handling, fix som…
Browse files Browse the repository at this point in the history
…e bugs, don't select item on hover, just highlight it slightly. Simplify history store to use a flat string list.
  • Loading branch information
MareStare committed Feb 18, 2025
1 parent b7d49b8 commit 0e00264
Show file tree
Hide file tree
Showing 20 changed files with 749 additions and 343 deletions.
27 changes: 22 additions & 5 deletions assets/css/views/tags.css
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
}

/* Autocomplete */
.autocomplete__list {
.autocomplete {
cursor: pointer;
display: inline-block;
list-style: none;
Expand All @@ -35,17 +35,20 @@
white-space: nowrap;
z-index: 999;
font-family: var(--font-family-monospace);
}

.autocomplete__list {
border-style: solid;
border-width: 1px;
border-top-width: 0;
border-color: var(--meta-border-color);

background: var(--background-color);
}

.autocomplete__separator {
margin: 0;
}

.autocomplete__item {
background: var(--background-color);
padding: 5px;
}

Expand All @@ -72,8 +75,22 @@
color: lch(from var(--block-header-link-text-color) calc(l + 20) c h);
}

.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: lch(from var(--foreground-color) calc(l + 20) c h);
}

.autocomplete__item:hover:not(.autocomplete__item--selected) {
background: lch(from var(--background-color) calc(l + 10) c h);
}

.autocomplete__item--selected,
.autocomplete__item--selected .autocomplete-item-history__match
.autocomplete__item--selected .autocomplete-item-history__match,
.autocomplete__item--selected .autocomplete-item-tag__match
{
background: var(--foreground-color);
color: var(--background-color);
Expand Down
2 changes: 1 addition & 1 deletion assets/js/__tests__/tagsinput.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ describe('Fancy tags input', () => {

it('should respond to autocomplete events', () => {
setupTagsInput(tagBlock);
fancyText.dispatchEvent(new CustomEvent<Suggestion>('autocomplete', { detail: { value: 'a', label: 'a' } }));
fancyText.dispatchEvent(new CustomEvent<Suggestion>('autocomplete', { detail: { content: 'a', label: 'a' } }));
expect($$('span.tag', fancyInput)).toHaveLength(1);
});

Expand Down
56 changes: 17 additions & 39 deletions assets/js/autocomplete/history/history.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { HistoryStore, HistoryRecord } from './store';
import { HistoryStore } from './store';

/**
* Maximum number of records we keep in the history. If the limit is reached,
Expand Down Expand Up @@ -28,9 +28,9 @@ export class InputHistory {
private readonly store: HistoryStore;

/**
* The list of history records sorted by `updatedAt` in descending order.
* The list of history records sorted from the most recently used to the oldest unused.
*/
private records: HistoryRecord[];
private records: string[];

constructor(store: HistoryStore) {
this.store = store;
Expand Down Expand Up @@ -61,61 +61,39 @@ export class InputHistory {
console.warn(`The input is too long to be saved in the search history (length: ${input.length}).`);
}

const record = this.records.find(historyRecord => historyRecord.content === input);
const index = this.records.findIndex(historyRecord => historyRecord === input);

if (record) {
this.update(record);
} else {
this.insert(input);
}

this.store.write(this.records);
}

private update(record: HistoryRecord) {
record.updatedAt = nowRfc3339();

// The records were fully sorted before we updated one of them. Fixing up
// a nearly sorted sequence with `sort()` should be blazingly ⚡️ fast.
// Usually, standard `sort` implementations are optimized for this case.
this.records.sort((a, b) => (b.updatedAt > a.updatedAt ? 1 : -1));
}

private insert(input: string) {
if (this.records.length >= maxRecords) {
if (index >= 0) {
this.records.splice(index, 1);
} else if (this.records.length >= maxRecords) {
// Bye-bye, least popular record! 👋 Nopony will miss you 🔪🩸
this.records.pop();
}

const now = nowRfc3339();
// Put the record on the top of the list as the most recently used.
this.records.unshift(input);

this.records.unshift({
content: input,
createdAt: now,
updatedAt: now,
});
this.store.write(this.records);
}

listSuggestions(query: string, limit: number): string[] {
// Waiting for iterator combinators such as `Iterator.prototype.filter()`
// and `Iterator.prototype.take()` to reach a greater availability 🙏:
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/filter
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/take

const results = [];

for (const record of this.records) {
if (results.length >= limit) {
break;
}

if (record.content.startsWith(query)) {
results.push(record.content);
if (record.startsWith(query)) {
results.push(record);
}
}

return results;
}
}

function nowRfc3339(): string {
const date = new Date();
// Second-level precision is enough for our use case.
date.setMilliseconds(0);
return date.toISOString().replace('.000Z', 'Z');
}
32 changes: 6 additions & 26 deletions assets/js/autocomplete/history/store.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,4 @@
import store from '../../utils/store';

export interface HistoryRecord {
/**
* The textual payload. It shapes the record's identity.
*/
content: string;

/**
* RCF3339 timestamp. Defines the time when the content was first used,
* and thus the record was created.
*/
createdAt: string;

/**
* RCF3339 timestamp. Defines the time when the content was last used,
* and thus the record was updated.
*/
updatedAt: string;
}

/**
* The root JSON object that contains the history records and is persisted to disk.
*/
Expand All @@ -33,9 +13,9 @@ interface History {
schemaVersion: 1;

/**
* The list of history records sorted by `updatedAt` in descending order.
* The list of history records sorted from the most recently used to the oldest unused.
*/
records: HistoryRecord[];
records: string[];
}

/**
Expand All @@ -52,11 +32,11 @@ export class HistoryStore {
this.key = key;
}

read(): HistoryRecord[] {
read(): string[] {
return this.extractRecords(store.get<History>(this.key));
}

write(records: HistoryRecord[]): void {
write(records: string[]): void {
if (!this.writable) {
return;
}
Expand All @@ -73,7 +53,7 @@ export class HistoryStore {
console.debug(`Writing ${records.length} history records to the localStorage took ${end - start}ms.`);
}

watch(callback: (value: HistoryRecord[]) => void): void {
watch(callback: (value: string[]) => void): void {
store.watch<History>(this.key, history => {
callback(this.extractRecords(history));
});
Expand All @@ -83,7 +63,7 @@ export class HistoryStore {
* 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: null | History): HistoryRecord[] {
private extractRecords(history: History | null): string[] {
// `null` here means we are starting from the initial state (empty list of records).
if (history === null) {
return [];
Expand Down
70 changes: 21 additions & 49 deletions assets/js/autocomplete/history/view.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Suggestion } from 'utils/suggestions';
import { HistorySuggestion } from '../../utils/suggestions';
import { InputHistory } from './history';
import { HistoryStore } from './store';
import { makeEl } from '../../utils/dom';
import { AutocompletableInput } from '../../autocomplete/v2/input';

/**
* Stores a set of histories identified by their unique IDs.
Expand All @@ -24,77 +24,49 @@ class InputHistoriesPool {
}
}

type HistoryAutocompletableInputElement = (HTMLInputElement | HTMLTextAreaElement) & {
dataset: { autocompleteHistoryId: string };
};

function hasHistoryAutocompletion(element: unknown): element is HistoryAutocompletableInputElement {
return (
(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) &&
Boolean(element.dataset.autocompleteHistoryId)
);
}

const histories = new InputHistoriesPool();

export function listen(): InputHistoriesPool {
export function listen() {
// Only load the history for the input element when it gets focused.
document.addEventListener('focusin', event => {
if (!hasHistoryAutocompletion(event.target)) {
const input = AutocompletableInput.fromElement(event.target);

if (!input?.historyId) {
return;
}

const historyId = event.target.dataset.autocompleteHistoryId;

histories.load(historyId);
histories.load(input.historyId);
});

document.addEventListener('submit', event => {
if (!(event.target instanceof HTMLFormElement)) {
return;
}

const input = [...event.target.elements].find(hasHistoryAutocompletion);
const input = [...event.target.elements]
.map(elem => AutocompletableInput.fromElement(elem))
.find(it => it !== null && it.hasHistory());

if (!input) {
return;
}

const content = input.value.trim();

histories.load(input.dataset.autocompleteHistoryId).write(content);
histories.load(input.historyId).write(input.snapshot.trimmedValue);
});

return histories;
}

export function listSuggestions(element: HTMLInputElement | HTMLTextAreaElement, limit: number): Suggestion[] {
if (!hasHistoryAutocompletion(element)) {
/**
* 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 query = element.value.trim();

return histories
.load(element.dataset.autocompleteHistoryId)
.listSuggestions(query, limit)
.map(result => {
const icon = makeEl('i', {
className: 'autocomplete-item-history__icon fa-solid fa-history',
});

const prefix = makeEl('span', {
textContent: ` ${query}`,
className: 'autocomplete-item-history__match',
});

const suffix = makeEl('span', {
textContent: result.slice(query.length),
});

return {
value: result,
label: [icon, prefix, suffix],
};
});
.load(input.historyId)
.listSuggestions(input.snapshot.trimmedValue, limit ?? input.maxSuggestions)
.map(content => new HistorySuggestion(content, input.snapshot.trimmedValue.length));
}
Loading

0 comments on commit 0e00264

Please sign in to comment.