refactor: encapsulate search state, bound cache
This commit is contained in:
@@ -1,4 +1,12 @@
|
||||
import { state, searchInput, searchDialog } from "./state";
|
||||
import {
|
||||
searchInput,
|
||||
searchDialog,
|
||||
rememberSearchOpener,
|
||||
focusLastSearchOpener,
|
||||
setLastQuery,
|
||||
getActiveRequestController,
|
||||
setActiveRequestController,
|
||||
} from "./state";
|
||||
import { setSearchState, setClearButtonState, clearResults } from "./render";
|
||||
import { cancelScheduledFetch, fetchSearchItems } from "./fetch";
|
||||
|
||||
@@ -8,12 +16,11 @@ export const openSearch = (): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
state.lastFocusedSearchOpener =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
rememberSearchOpener();
|
||||
if (searchDialog) {
|
||||
setSearchState(true);
|
||||
searchInput.value = "";
|
||||
state.lastQuery = "";
|
||||
setLastQuery("");
|
||||
cancelScheduledFetch();
|
||||
setClearButtonState(false);
|
||||
clearResults();
|
||||
@@ -28,15 +35,16 @@ export const closeSearch = (): void => {
|
||||
|
||||
setSearchState(false);
|
||||
cancelScheduledFetch();
|
||||
if (state.activeRequestController) {
|
||||
state.activeRequestController.abort();
|
||||
state.activeRequestController = undefined;
|
||||
const activeRequestController = getActiveRequestController();
|
||||
if (activeRequestController) {
|
||||
activeRequestController.abort();
|
||||
setActiveRequestController(undefined);
|
||||
}
|
||||
searchInput.value = "";
|
||||
state.lastQuery = "";
|
||||
setLastQuery("");
|
||||
setClearButtonState(false);
|
||||
clearResults();
|
||||
state.lastFocusedSearchOpener?.focus();
|
||||
focusLastSearchOpener();
|
||||
};
|
||||
|
||||
export const clearSearchInput = (): void => {
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import type { CommandPaletteItem, CommandPaletteResponse } from "./state";
|
||||
import { state, searchInput, searchResults, responseCache } from "./state";
|
||||
import {
|
||||
searchInput,
|
||||
searchResults,
|
||||
responseCache,
|
||||
getFetchTimeout,
|
||||
setFetchTimeout,
|
||||
setLastQuery,
|
||||
getLastQuery,
|
||||
getActiveRequestController,
|
||||
setActiveRequestController,
|
||||
setSearchPagination,
|
||||
getNextSearchPage,
|
||||
hasNextSearchPage,
|
||||
isFetchingNextPage,
|
||||
setFetchingNextPage,
|
||||
} from "./state";
|
||||
import {
|
||||
setClearButtonState,
|
||||
clearResults,
|
||||
@@ -46,21 +61,23 @@ const renderPendingQuery = (query: string): void => {
|
||||
};
|
||||
|
||||
export const cancelScheduledFetch = (): void => {
|
||||
if (!state.fetchTimeout) {
|
||||
const fetchTimeout = getFetchTimeout();
|
||||
if (!fetchTimeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearTimeout(state.fetchTimeout);
|
||||
state.fetchTimeout = undefined;
|
||||
window.clearTimeout(fetchTimeout);
|
||||
setFetchTimeout(undefined);
|
||||
};
|
||||
|
||||
export const fetchSearchItems = (query: string): void => {
|
||||
state.lastQuery = query;
|
||||
setLastQuery(query);
|
||||
setClearButtonState(query !== "");
|
||||
|
||||
if (state.activeRequestController) {
|
||||
state.activeRequestController.abort();
|
||||
state.activeRequestController = undefined;
|
||||
const activeRequestController = getActiveRequestController();
|
||||
if (activeRequestController) {
|
||||
activeRequestController.abort();
|
||||
setActiveRequestController(undefined);
|
||||
}
|
||||
|
||||
if (query === "") {
|
||||
@@ -71,8 +88,7 @@ export const fetchSearchItems = (query: string): void => {
|
||||
|
||||
const cached = responseCache.get(query);
|
||||
if (cached) {
|
||||
state.nextSearchPage = cached.nextPage;
|
||||
state.hasNextSearchPage = cached.hasNextPage;
|
||||
setSearchPagination(cached.nextPage, cached.hasNextPage);
|
||||
renderItems(visibleSearchItems(cached.items, query));
|
||||
return;
|
||||
}
|
||||
@@ -80,7 +96,7 @@ export const fetchSearchItems = (query: string): void => {
|
||||
renderPendingQuery(query);
|
||||
|
||||
const controller = new AbortController();
|
||||
state.activeRequestController = controller;
|
||||
setActiveRequestController(controller);
|
||||
|
||||
fetch("/api/command-palette?q=" + encodeURIComponent(query), { signal: controller.signal })
|
||||
.then((res: Response) => {
|
||||
@@ -90,15 +106,14 @@ export const fetchSearchItems = (query: string): void => {
|
||||
return res.json();
|
||||
})
|
||||
.then((payload: unknown) => {
|
||||
if (controller.signal.aborted || query !== state.lastQuery) {
|
||||
if (controller.signal.aborted || query !== getLastQuery()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = parseCommandPaletteResponse(payload);
|
||||
const visibleItems = visibleSearchItems(response.items, query);
|
||||
state.activeRequestController = undefined;
|
||||
state.nextSearchPage = response.nextPage;
|
||||
state.hasNextSearchPage = response.hasNextPage;
|
||||
setActiveRequestController(undefined);
|
||||
setSearchPagination(response.nextPage, response.hasNextPage);
|
||||
responseCache.set(query, response);
|
||||
renderItems(visibleItems);
|
||||
})
|
||||
@@ -107,25 +122,21 @@ export const fetchSearchItems = (query: string): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
state.activeRequestController = undefined;
|
||||
setActiveRequestController(undefined);
|
||||
console.error("Search overlay error:", err);
|
||||
renderItems([]);
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchNextSearchPage = (): void => {
|
||||
if (
|
||||
!state.lastQuery ||
|
||||
!state.hasNextSearchPage ||
|
||||
!state.nextSearchPage ||
|
||||
state.isFetchingNextPage
|
||||
) {
|
||||
const query = getLastQuery();
|
||||
const page = getNextSearchPage();
|
||||
|
||||
if (!query || !hasNextSearchPage() || !page || isFetchingNextPage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.isFetchingNextPage = true;
|
||||
const query = state.lastQuery;
|
||||
const page = state.nextSearchPage;
|
||||
setFetchingNextPage(true);
|
||||
|
||||
fetch(
|
||||
"/api/command-palette?q=" +
|
||||
@@ -140,7 +151,7 @@ export const fetchNextSearchPage = (): void => {
|
||||
return res.json();
|
||||
})
|
||||
.then((payload: unknown) => {
|
||||
if (query !== state.lastQuery) {
|
||||
if (query !== getLastQuery()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -154,15 +165,14 @@ export const fetchNextSearchPage = (): void => {
|
||||
nextPage: response.nextPage,
|
||||
});
|
||||
}
|
||||
state.nextSearchPage = response.nextPage;
|
||||
state.hasNextSearchPage = response.hasNextPage;
|
||||
setSearchPagination(response.nextPage, response.hasNextPage);
|
||||
appendItems(visibleItems);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error("Search overlay pagination error:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
state.isFetchingNextPage = false;
|
||||
setFetchingNextPage(false);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -179,14 +189,12 @@ export const onResultsScroll = (): void => {
|
||||
};
|
||||
|
||||
export const scheduleFetch = (): void => {
|
||||
if (state.fetchTimeout) {
|
||||
window.clearTimeout(state.fetchTimeout);
|
||||
const fetchTimeout = getFetchTimeout();
|
||||
if (fetchTimeout) {
|
||||
window.clearTimeout(fetchTimeout);
|
||||
}
|
||||
|
||||
const query = searchInput?.value.trim() || "";
|
||||
setClearButtonState(query !== "");
|
||||
state.fetchTimeout = window.setTimeout(
|
||||
() => fetchSearchItems(query),
|
||||
query.length >= 2 ? 240 : 80,
|
||||
);
|
||||
setFetchTimeout(window.setTimeout(() => fetchSearchItems(query), query.length >= 2 ? 240 : 80));
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
state,
|
||||
commandPaletteInitializedKey,
|
||||
globalWindow,
|
||||
searchInput,
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
searchCloseButtons,
|
||||
searchClearButtons,
|
||||
searchDialog,
|
||||
getSelectedIndex,
|
||||
isSearchOpen,
|
||||
isTypingTarget,
|
||||
} from "./state";
|
||||
@@ -26,13 +26,13 @@ const onDocumentClick = (event: MouseEvent): void => {
|
||||
const onInputKeydown = (event: KeyboardEvent): void => {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
selectItem(state.selectedIndex + 1, true);
|
||||
selectItem(getSelectedIndex() + 1, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
selectItem(state.selectedIndex - 1, true);
|
||||
selectItem(getSelectedIndex() - 1, true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { dedupeByID, dedupeWithin } from "../dedupe";
|
||||
import type { CommandPaletteItem } from "./state";
|
||||
import {
|
||||
state,
|
||||
searchResults,
|
||||
searchClearButtons,
|
||||
shortcutHints,
|
||||
searchDialog,
|
||||
responseCache,
|
||||
getResultItems,
|
||||
setResultItems,
|
||||
getSelectedIndex,
|
||||
setSelectedIndex,
|
||||
getLastQuery,
|
||||
resetSearchResultsState,
|
||||
iconPaths,
|
||||
typeLabels,
|
||||
groupOrder,
|
||||
@@ -94,23 +99,21 @@ const buildPosterImage = (item: CommandPaletteItem): HTMLElement => {
|
||||
};
|
||||
|
||||
export const clearResults = (): void => {
|
||||
state.resultItems = [];
|
||||
state.selectedIndex = 0;
|
||||
state.nextSearchPage = undefined;
|
||||
state.hasNextSearchPage = false;
|
||||
state.isFetchingNextPage = false;
|
||||
resetSearchResultsState();
|
||||
searchResults?.replaceChildren();
|
||||
};
|
||||
|
||||
export const selectItem = (index: number, scrollIntoView: boolean): void => {
|
||||
if (!searchResults || state.resultItems.length === 0) {
|
||||
state.selectedIndex = 0;
|
||||
const resultItems = getResultItems();
|
||||
if (!searchResults || resultItems.length === 0) {
|
||||
setSelectedIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
state.selectedIndex = Math.max(0, Math.min(index, state.resultItems.length - 1));
|
||||
const selectedIndex = Math.max(0, Math.min(index, resultItems.length - 1));
|
||||
setSelectedIndex(selectedIndex);
|
||||
searchResults.querySelectorAll<HTMLElement>("[data-command-palette-item]").forEach((item) => {
|
||||
const selected = Number(item.dataset.resultIndex) === state.selectedIndex;
|
||||
const selected = Number(item.dataset.resultIndex) === selectedIndex;
|
||||
item.classList.toggle("opacity-75", selected);
|
||||
item.setAttribute("aria-selected", String(selected));
|
||||
if (selected && scrollIntoView) {
|
||||
@@ -120,7 +123,7 @@ export const selectItem = (index: number, scrollIntoView: boolean): void => {
|
||||
};
|
||||
|
||||
export const runSelectedItem = (): void => {
|
||||
const item = state.resultItems[state.selectedIndex];
|
||||
const item = getResultItems()[getSelectedIndex()];
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
@@ -155,7 +158,7 @@ export const removeContinueWatchingItem = (item: CommandPaletteItem): void => {
|
||||
|
||||
responseCache.clear();
|
||||
removeContinueWatchingCard(animeID);
|
||||
renderItems(state.resultItems.filter((candidate) => candidate.id !== item.id));
|
||||
renderItems(getResultItems().filter((candidate) => candidate.id !== item.id));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error("Continue watching remove error:", err);
|
||||
@@ -183,7 +186,7 @@ const buildCard = (item: CommandPaletteItem, index: number): HTMLAnchorElement =
|
||||
card.dataset.id = item.id;
|
||||
card.dataset.resultIndex = String(index);
|
||||
card.setAttribute("role", "option");
|
||||
card.setAttribute("aria-selected", String(index === state.selectedIndex));
|
||||
card.setAttribute("aria-selected", String(index === getSelectedIndex()));
|
||||
card.addEventListener("mouseenter", () => selectItem(index, false));
|
||||
|
||||
const media = document.createElement("div");
|
||||
@@ -231,7 +234,7 @@ const buildCompactItem = (item: CommandPaletteItem, index: number): HTMLAnchorEl
|
||||
row.dataset.id = item.id;
|
||||
row.dataset.resultIndex = String(index);
|
||||
row.setAttribute("role", "option");
|
||||
row.setAttribute("aria-selected", String(index === state.selectedIndex));
|
||||
row.setAttribute("aria-selected", String(index === getSelectedIndex()));
|
||||
row.addEventListener("mouseenter", () => selectItem(index, false));
|
||||
|
||||
const thumb = document.createElement("div");
|
||||
@@ -333,10 +336,10 @@ export const renderItems = (items: CommandPaletteItem[]): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
state.selectedIndex = 0;
|
||||
setSelectedIndex(0);
|
||||
|
||||
if (items.length === 0) {
|
||||
renderEmptyState(state.lastQuery);
|
||||
renderEmptyState(getLastQuery());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -345,7 +348,7 @@ export const renderItems = (items: CommandPaletteItem[]): void => {
|
||||
|
||||
const groups = groupedItems(dedupeByID(items, (item) => item.id));
|
||||
const keys = orderedGroupKeys(groups);
|
||||
state.resultItems = keys.flatMap((key) => groups.get(key) || []);
|
||||
setResultItems(keys.flatMap((key) => groups.get(key) || []));
|
||||
let cursor = 0;
|
||||
|
||||
keys.forEach((key) => {
|
||||
@@ -371,7 +374,8 @@ export const appendItems = (items: CommandPaletteItem[]): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingIDs = new Set(state.resultItems.map((item) => item.id));
|
||||
const resultItems = getResultItems();
|
||||
const existingIDs = new Set(resultItems.map((item) => item.id));
|
||||
const nextItems = dedupeByID(items, (item) => item.id).filter(
|
||||
(item) => !existingIDs.has(item.id),
|
||||
);
|
||||
@@ -379,5 +383,5 @@ export const appendItems = (items: CommandPaletteItem[]): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
renderItems([...state.resultItems, ...nextItems]);
|
||||
renderItems([...resultItems, ...nextItems]);
|
||||
};
|
||||
|
||||
@@ -37,19 +37,119 @@ export const searchCloseButtons = document.querySelectorAll("[data-command-palet
|
||||
export const searchClearButtons = document.querySelectorAll("[data-command-palette-clear]");
|
||||
export const shortcutHints = document.querySelectorAll("[data-command-palette-shortcut]");
|
||||
|
||||
export const state = {
|
||||
resultItems: [] as CommandPaletteItem[],
|
||||
selectedIndex: 0,
|
||||
fetchTimeout: undefined as number | undefined,
|
||||
lastQuery: "",
|
||||
activeRequestController: undefined as AbortController | undefined,
|
||||
nextSearchPage: undefined as number | undefined,
|
||||
hasNextSearchPage: false,
|
||||
isFetchingNextPage: false,
|
||||
lastFocusedSearchOpener: null as HTMLElement | null,
|
||||
let resultItems: CommandPaletteItem[] = [];
|
||||
let selectedIndex = 0;
|
||||
let fetchTimeout: number | undefined;
|
||||
let lastQuery = "";
|
||||
let activeRequestController: AbortController | undefined;
|
||||
let nextSearchPage: number | undefined;
|
||||
let searchHasNextPage = false;
|
||||
let fetchingNextPage = false;
|
||||
let lastFocusedSearchOpener: HTMLElement | null = null;
|
||||
|
||||
export const getResultItems = (): CommandPaletteItem[] => resultItems;
|
||||
|
||||
export const setResultItems = (items: CommandPaletteItem[]): void => {
|
||||
resultItems = items;
|
||||
};
|
||||
|
||||
export const responseCache = new Map<string, CommandPaletteResponse>();
|
||||
export const getSelectedIndex = (): number => selectedIndex;
|
||||
|
||||
export const setSelectedIndex = (index: number): void => {
|
||||
selectedIndex = index;
|
||||
};
|
||||
|
||||
export const getLastQuery = (): string => lastQuery;
|
||||
|
||||
export const setLastQuery = (query: string): void => {
|
||||
lastQuery = query;
|
||||
};
|
||||
|
||||
export const getFetchTimeout = (): number | undefined => fetchTimeout;
|
||||
|
||||
export const setFetchTimeout = (timeout: number | undefined): void => {
|
||||
fetchTimeout = timeout;
|
||||
};
|
||||
|
||||
export const getActiveRequestController = (): AbortController | undefined =>
|
||||
activeRequestController;
|
||||
|
||||
export const setActiveRequestController = (controller: AbortController | undefined): void => {
|
||||
activeRequestController = controller;
|
||||
};
|
||||
|
||||
export const getNextSearchPage = (): number | undefined => nextSearchPage;
|
||||
|
||||
export const hasNextSearchPage = (): boolean => searchHasNextPage;
|
||||
|
||||
export const setSearchPagination = (nextPage: number | undefined, hasNextPage: boolean): void => {
|
||||
nextSearchPage = nextPage;
|
||||
searchHasNextPage = hasNextPage;
|
||||
};
|
||||
|
||||
export const isFetchingNextPage = (): boolean => fetchingNextPage;
|
||||
|
||||
export const setFetchingNextPage = (isFetching: boolean): void => {
|
||||
fetchingNextPage = isFetching;
|
||||
};
|
||||
|
||||
export const resetSearchResultsState = (): void => {
|
||||
resultItems = [];
|
||||
selectedIndex = 0;
|
||||
nextSearchPage = undefined;
|
||||
searchHasNextPage = false;
|
||||
fetchingNextPage = false;
|
||||
};
|
||||
|
||||
export const rememberSearchOpener = (): void => {
|
||||
lastFocusedSearchOpener =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
};
|
||||
|
||||
export const focusLastSearchOpener = (): void => {
|
||||
lastFocusedSearchOpener?.focus();
|
||||
};
|
||||
|
||||
const maxCachedResponses = 20;
|
||||
|
||||
const createResponseCache = () => {
|
||||
const responses = new Map<string, CommandPaletteResponse>();
|
||||
|
||||
return {
|
||||
get(query: string): CommandPaletteResponse | undefined {
|
||||
const response = responses.get(query);
|
||||
if (!response) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
responses.delete(query);
|
||||
responses.set(query, response);
|
||||
return response;
|
||||
},
|
||||
|
||||
set(query: string, response: CommandPaletteResponse): void {
|
||||
if (responses.has(query)) {
|
||||
responses.delete(query);
|
||||
}
|
||||
|
||||
responses.set(query, response);
|
||||
if (responses.size <= maxCachedResponses) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldestResponse = responses.keys().next();
|
||||
if (!oldestResponse.done) {
|
||||
responses.delete(oldestResponse.value);
|
||||
}
|
||||
},
|
||||
|
||||
clear(): void {
|
||||
responses.clear();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const responseCache = createResponseCache();
|
||||
|
||||
export const iconPaths: Record<string, string> = {
|
||||
bookmark: "M19 21l-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z",
|
||||
|
||||
Reference in New Issue
Block a user