diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-11-29 16:31:25 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-11-29 16:31:25 +0000 |
| commit | ebafbe599df40c02a0683efc9b424bc8b75af3c3 (patch) | |
| tree | b60276b97612a37876d6cde86d04376887e935b0 /apps/web/components/dashboard/search/useSearchAutocomplete.ts | |
| parent | 335a84bb59377371ecb2e6dc9702ce572d2e6cc6 (diff) | |
| download | karakeep-ebafbe599df40c02a0683efc9b424bc8b75af3c3.tar.zst | |
feat: autocomplete search terms (#2178)
* refactor(web): split search autocomplete logic
* some improvements
* restructure the code
* fix typesafety
* add feed suggestions
* fix
Diffstat (limited to 'apps/web/components/dashboard/search/useSearchAutocomplete.ts')
| -rw-r--r-- | apps/web/components/dashboard/search/useSearchAutocomplete.ts | 555 |
1 files changed, 555 insertions, 0 deletions
diff --git a/apps/web/components/dashboard/search/useSearchAutocomplete.ts b/apps/web/components/dashboard/search/useSearchAutocomplete.ts new file mode 100644 index 00000000..cce5fc0b --- /dev/null +++ b/apps/web/components/dashboard/search/useSearchAutocomplete.ts @@ -0,0 +1,555 @@ +import type translation from "@/lib/i18n/locales/en/translation.json"; +import type { TFunction } from "i18next"; +import type { LucideIcon } from "lucide-react"; +import { useCallback, useMemo } from "react"; +import { api } from "@/lib/trpc"; +import { + History, + ListTree, + RssIcon, + Sparkles, + Tag as TagIcon, +} from "lucide-react"; + +import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists"; +import { useTagAutocomplete } from "@karakeep/shared-react/hooks/tags"; +import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; + +const MAX_DISPLAY_SUGGESTIONS = 5; + +type SearchTranslationKey = `search.${keyof typeof translation.search}`; + +interface QualifierDefinition { + value: string; + descriptionKey?: SearchTranslationKey; + negatedDescriptionKey?: SearchTranslationKey; + appendSpace?: boolean; +} + +const QUALIFIER_DEFINITIONS = [ + { + value: "is:fav", + descriptionKey: "search.is_favorited", + negatedDescriptionKey: "search.is_not_favorited", + appendSpace: true, + }, + { + value: "is:archived", + descriptionKey: "search.is_archived", + negatedDescriptionKey: "search.is_not_archived", + appendSpace: true, + }, + { + value: "is:tagged", + descriptionKey: "search.has_any_tag", + negatedDescriptionKey: "search.has_no_tags", + appendSpace: true, + }, + { + value: "is:inlist", + descriptionKey: "search.is_in_any_list", + negatedDescriptionKey: "search.is_not_in_any_list", + appendSpace: true, + }, + { + value: "is:link", + appendSpace: true, + }, + { + value: "is:text", + appendSpace: true, + }, + { + value: "is:media", + appendSpace: true, + }, + { + value: "url:", + descriptionKey: "search.url_contains", + }, + { + value: "title:", + descriptionKey: "search.title_contains", + }, + { + value: "list:", + descriptionKey: "search.is_in_list", + }, + { + value: "after:", + descriptionKey: "search.created_on_or_after", + }, + { + value: "before:", + descriptionKey: "search.created_on_or_before", + }, + { + value: "feed:", + descriptionKey: "search.is_from_feed", + }, + { + value: "age:", + descriptionKey: "search.created_within", + }, +] satisfies ReadonlyArray<QualifierDefinition>; + +export interface AutocompleteSuggestionItem { + type: "token" | "tag" | "list" | "feed"; + id: string; + label: string; + insertText: string; + appendSpace?: boolean; + description?: string; + Icon: LucideIcon; +} + +export interface HistorySuggestionItem { + type: "history"; + id: string; + term: string; + label: string; + Icon: LucideIcon; +} + +export type SuggestionItem = AutocompleteSuggestionItem | HistorySuggestionItem; + +export interface SuggestionGroup { + id: string; + label: string; + items: SuggestionItem[]; +} + +const stripSurroundingQuotes = (value: string) => { + let nextValue = value; + if (nextValue.startsWith('"')) { + nextValue = nextValue.slice(1); + } + if (nextValue.endsWith('"')) { + nextValue = nextValue.slice(0, -1); + } + return nextValue; +}; + +const shouldQuoteValue = (value: string) => /[\s:]/.test(value); + +const formatSearchValue = (value: string) => + shouldQuoteValue(value) ? `"${value}"` : value; + +interface ParsedSearchState { + activeToken: string; + isTokenNegative: boolean; + tokenWithoutMinus: string; + normalizedTokenWithoutMinus: string; + getActiveToken: (cursorPosition: number) => { token: string; start: number }; +} + +interface UseSearchAutocompleteParams { + value: string; + onValueChange: (value: string) => void; + inputRef: React.RefObject<HTMLInputElement | null>; + isPopoverOpen: boolean; + setIsPopoverOpen: React.Dispatch<React.SetStateAction<boolean>>; + t: TFunction; + history: string[]; +} + +const useParsedSearchState = (value: string): ParsedSearchState => { + const getActiveToken = useCallback( + (cursorPosition: number) => { + let start = 0; + let inQuotes = false; + + for (let index = 0; index < cursorPosition; index += 1) { + const char = value[index]; + if (char === '"') { + inQuotes = !inQuotes; + continue; + } + + if (!inQuotes) { + if (char === " " || char === "\t" || char === "\n") { + start = index + 1; + continue; + } + + if (char === "(") { + start = index + 1; + } + } + } + + return { + token: value.slice(start, cursorPosition), + start, + }; + }, + [value], + ); + + const activeTokenInfo = useMemo( + () => getActiveToken(value.length), + [getActiveToken, value], + ); + const activeToken = activeTokenInfo.token; + const isTokenNegative = activeToken.startsWith("-"); + const tokenWithoutMinus = isTokenNegative + ? activeToken.slice(1) + : activeToken; + const normalizedTokenWithoutMinus = tokenWithoutMinus.toLowerCase(); + + return { + activeToken, + isTokenNegative, + tokenWithoutMinus, + normalizedTokenWithoutMinus, + getActiveToken, + }; +}; + +const useQualifierSuggestions = ( + parsed: ParsedSearchState, + t: TFunction, +): AutocompleteSuggestionItem[] => { + const qualifierSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => { + // Don't suggest qualifiers if the user hasn't started typing + if (parsed.normalizedTokenWithoutMinus.length === 0) { + return []; + } + + return QUALIFIER_DEFINITIONS.filter((definition) => { + return definition.value + .toLowerCase() + .startsWith(parsed.normalizedTokenWithoutMinus); + }) + .slice(0, MAX_DISPLAY_SUGGESTIONS) + .map((definition) => { + const insertText = `${parsed.isTokenNegative ? "-" : ""}${definition.value}`; + const descriptionKey = parsed.isTokenNegative + ? (definition.negatedDescriptionKey ?? definition.descriptionKey) + : definition.descriptionKey; + const description = descriptionKey ? t(descriptionKey) : undefined; + + return { + type: "token" as const, + id: `qualifier-${definition.value}`, + label: insertText, + insertText, + appendSpace: definition.appendSpace, + description, + Icon: Sparkles, + } satisfies AutocompleteSuggestionItem; + }); + }, [parsed.normalizedTokenWithoutMinus, parsed.isTokenNegative, t]); + + return qualifierSuggestions; +}; + +const useTagSuggestions = ( + parsed: ParsedSearchState, +): AutocompleteSuggestionItem[] => { + const shouldSuggestTags = parsed.tokenWithoutMinus.startsWith("#"); + const tagSearchTermRaw = shouldSuggestTags + ? parsed.tokenWithoutMinus.slice(1) + : ""; + const tagSearchTerm = stripSurroundingQuotes(tagSearchTermRaw); + const debouncedTagSearchTerm = useDebounce(tagSearchTerm, 200); + + const { data: tagResults } = useTagAutocomplete({ + nameContains: debouncedTagSearchTerm, + select: (data) => data.tags, + }); + + const tagSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => { + if (!shouldSuggestTags) { + return []; + } + + return (tagResults ?? []).slice(0, MAX_DISPLAY_SUGGESTIONS).map((tag) => { + const formattedName = formatSearchValue(tag.name); + const insertText = `${parsed.isTokenNegative ? "-" : ""}#${formattedName}`; + + return { + type: "tag" as const, + id: `tag-${tag.id}`, + label: insertText, + insertText, + appendSpace: true, + description: undefined, + Icon: TagIcon, + } satisfies AutocompleteSuggestionItem; + }); + }, [shouldSuggestTags, tagResults, parsed.isTokenNegative]); + + return tagSuggestions; +}; + +const useFeedSuggestions = ( + parsed: ParsedSearchState, +): AutocompleteSuggestionItem[] => { + const shouldSuggestFeeds = + parsed.normalizedTokenWithoutMinus.startsWith("feed:"); + const feedSearchTermRaw = shouldSuggestFeeds + ? parsed.tokenWithoutMinus.slice("feed:".length) + : ""; + const feedSearchTerm = stripSurroundingQuotes(feedSearchTermRaw); + const normalizedFeedSearchTerm = feedSearchTerm.toLowerCase(); + const { data: feedResults } = api.feeds.list.useQuery(); + + const feedSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => { + if (!shouldSuggestFeeds) { + return []; + } + + const feeds = feedResults?.feeds ?? []; + + return feeds + .filter((feed) => { + if (normalizedFeedSearchTerm.length === 0) { + return true; + } + return feed.name.toLowerCase().includes(normalizedFeedSearchTerm); + }) + .slice(0, MAX_DISPLAY_SUGGESTIONS) + .map((feed) => { + const formattedName = formatSearchValue(feed.name); + const insertText = `${parsed.isTokenNegative ? "-" : ""}feed:${formattedName}`; + return { + type: "feed" as const, + id: `feed-${feed.id}`, + label: insertText, + insertText, + appendSpace: true, + description: undefined, + Icon: RssIcon, + } satisfies AutocompleteSuggestionItem; + }); + }, [ + shouldSuggestFeeds, + feedResults, + normalizedFeedSearchTerm, + parsed.isTokenNegative, + ]); + + return feedSuggestions; +}; + +const useListSuggestions = ( + parsed: ParsedSearchState, +): AutocompleteSuggestionItem[] => { + const shouldSuggestLists = + parsed.normalizedTokenWithoutMinus.startsWith("list:"); + const listSearchTermRaw = shouldSuggestLists + ? parsed.tokenWithoutMinus.slice("list:".length) + : ""; + const listSearchTerm = stripSurroundingQuotes(listSearchTermRaw); + const normalizedListSearchTerm = listSearchTerm.toLowerCase(); + const { data: listResults } = useBookmarkLists(); + + const listSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => { + if (!shouldSuggestLists) { + return []; + } + + const lists = listResults?.data ?? []; + + return lists + .filter((list) => { + if (normalizedListSearchTerm.length === 0) { + return true; + } + return list.name.toLowerCase().includes(normalizedListSearchTerm); + }) + .slice(0, MAX_DISPLAY_SUGGESTIONS) + .map((list) => { + const formattedName = formatSearchValue(list.name); + const insertText = `${parsed.isTokenNegative ? "-" : ""}list:${formattedName}`; + return { + type: "list" as const, + id: `list-${list.id}`, + label: insertText, + insertText, + appendSpace: true, + description: undefined, + Icon: ListTree, + } satisfies AutocompleteSuggestionItem; + }); + }, [ + shouldSuggestLists, + listResults, + normalizedListSearchTerm, + parsed.isTokenNegative, + ]); + + return listSuggestions; +}; + +const useHistorySuggestions = ( + value: string, + history: string[], +): HistorySuggestionItem[] => { + const historyItems = useMemo<HistorySuggestionItem[]>(() => { + const trimmedValue = value.trim(); + const results = + trimmedValue.length === 0 + ? history + : history.filter((item) => + item.toLowerCase().includes(trimmedValue.toLowerCase()), + ); + + return results.slice(0, MAX_DISPLAY_SUGGESTIONS).map( + (term) => + ({ + type: "history" as const, + id: `history-${term}`, + term, + label: term, + Icon: History, + }) satisfies HistorySuggestionItem, + ); + }, [history, value]); + + return historyItems; +}; + +export const useSearchAutocomplete = ({ + value, + onValueChange, + inputRef, + isPopoverOpen, + setIsPopoverOpen, + t, + history, +}: UseSearchAutocompleteParams) => { + const parsedState = useParsedSearchState(value); + const qualifierSuggestions = useQualifierSuggestions(parsedState, t); + const tagSuggestions = useTagSuggestions(parsedState); + const listSuggestions = useListSuggestions(parsedState); + const feedSuggestions = useFeedSuggestions(parsedState); + const historyItems = useHistorySuggestions(value, history); + const { activeToken, getActiveToken } = parsedState; + + const suggestionGroups = useMemo<SuggestionGroup[]>(() => { + const groups: SuggestionGroup[] = []; + + if (tagSuggestions.length > 0) { + groups.push({ + id: "tags", + label: t("search.tags"), + items: tagSuggestions, + }); + } + + if (listSuggestions.length > 0) { + groups.push({ + id: "lists", + label: t("search.lists"), + items: listSuggestions, + }); + } + + if (feedSuggestions.length > 0) { + groups.push({ + id: "feeds", + label: t("search.feeds"), + items: feedSuggestions, + }); + } + + // Only suggest qualifiers if no other suggestions are available + if (groups.length === 0 && qualifierSuggestions.length > 0) { + groups.push({ + id: "qualifiers", + label: t("search.filters"), + items: qualifierSuggestions, + }); + } + + if (historyItems.length > 0) { + groups.push({ + id: "history", + label: t("search.history"), + items: historyItems, + }); + } + + return groups; + }, [ + qualifierSuggestions, + tagSuggestions, + listSuggestions, + feedSuggestions, + historyItems, + t, + ]); + + const hasSuggestions = suggestionGroups.length > 0; + const showEmptyState = + isPopoverOpen && !hasSuggestions && activeToken.length > 0; + const isPopoverVisible = isPopoverOpen && (hasSuggestions || showEmptyState); + + const handleSuggestionSelect = useCallback( + (item: AutocompleteSuggestionItem) => { + const input = inputRef.current; + const selectionStart = input?.selectionStart ?? value.length; + const selectionEnd = input?.selectionEnd ?? selectionStart; + const { start } = getActiveToken(selectionStart); + const beforeToken = value.slice(0, start); + const afterToken = value.slice(selectionEnd); + + const needsSpace = + item.appendSpace && + (afterToken.length === 0 || !/^\s/.test(afterToken)); + const baseValue = `${beforeToken}${item.insertText}${afterToken}`; + const finalValue = needsSpace + ? `${beforeToken}${item.insertText} ${afterToken}` + : baseValue; + + onValueChange(finalValue); + + requestAnimationFrame(() => { + const target = inputRef.current; + if (!target) { + return; + } + const cursorPosition = + beforeToken.length + item.insertText.length + (needsSpace ? 1 : 0); + target.focus(); + target.setSelectionRange(cursorPosition, cursorPosition); + }); + + setIsPopoverOpen(true); + }, + [getActiveToken, onValueChange, value, inputRef, setIsPopoverOpen], + ); + + const handleCommandKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + const selectedItem = document.querySelector( + '[cmdk-item][data-selected="true"]', + ); + const isPlaceholderSelected = + selectedItem?.getAttribute("data-value") === "-"; + if (!selectedItem || isPlaceholderSelected) { + e.preventDefault(); + setIsPopoverOpen(false); + inputRef.current?.blur(); + } + } else if (e.key === "Escape") { + e.preventDefault(); + setIsPopoverOpen(false); + inputRef.current?.blur(); + } + }, + [setIsPopoverOpen, inputRef], + ); + + return { + suggestionGroups, + hasSuggestions, + showEmptyState, + isPopoverVisible, + handleSuggestionSelect, + handleCommandKeyDown, + }; +}; |
