diff options
Diffstat (limited to 'apps/web/components')
| -rw-r--r-- | apps/web/components/dashboard/search/SearchInput.tsx | 122 | ||||
| -rw-r--r-- | apps/web/components/dashboard/search/useSearchAutocomplete.ts | 555 |
2 files changed, 618 insertions, 59 deletions
diff --git a/apps/web/components/dashboard/search/SearchInput.tsx b/apps/web/components/dashboard/search/SearchInput.tsx index 3f49a53c..644468e8 100644 --- a/apps/web/components/dashboard/search/SearchInput.tsx +++ b/apps/web/components/dashboard/search/SearchInput.tsx @@ -4,7 +4,6 @@ import React, { useCallback, useEffect, useImperativeHandle, - useMemo, useRef, useState, } from "react"; @@ -12,6 +11,7 @@ import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Command, + CommandEmpty, CommandGroup, CommandInput, CommandItem, @@ -25,14 +25,12 @@ import { import { useDoBookmarkSearch } from "@/lib/hooks/bookmark-search"; import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; -import { History } from "lucide-react"; import { useSearchHistory } from "@karakeep/shared-react/hooks/search-history"; import { EditListModal } from "../lists/EditListModal"; import QueryExplainerTooltip from "./QueryExplainerTooltip"; - -const MAX_DISPLAY_SUGGESTIONS = 5; +import { useSearchAutocomplete } from "./useSearchAutocomplete"; function useFocusSearchOnKeyPress( inputRef: React.RefObject<HTMLInputElement | null>, @@ -85,7 +83,7 @@ const SearchInput = React.forwardRef< removeItem: (k: string) => localStorage.removeItem(k), }); - const [value, setValue] = React.useState(searchQuery); + const [value, setValue] = useState(searchQuery); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false); @@ -119,19 +117,22 @@ const SearchInput = React.forwardRef< [debounceSearch], ); - const suggestions = useMemo(() => { - if (value.trim() === "") { - // Show recent items when not typing - return history.slice(0, MAX_DISPLAY_SUGGESTIONS); - } else { - // Show filtered items when typing - return history - .filter((item) => item.toLowerCase().includes(value.toLowerCase())) - .slice(0, MAX_DISPLAY_SUGGESTIONS); - } - }, [history, value]); + const { + suggestionGroups, + hasSuggestions, + isPopoverVisible, + handleSuggestionSelect, + handleCommandKeyDown, + } = useSearchAutocomplete({ + value, + onValueChange: handleValueChange, + inputRef, + isPopoverOpen, + setIsPopoverOpen, + t, + history, + }); - const isPopoverVisible = isPopoverOpen && suggestions.length > 0; const handleHistorySelect = useCallback( (term: string) => { isHistorySelected.current = true; @@ -141,28 +142,9 @@ const SearchInput = React.forwardRef< setIsPopoverOpen(false); inputRef.current?.blur(); }, - [doSearch], + [doSearch, addTerm], ); - 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(); - } - }, []); - useFocusSearchOnKeyPress(inputRef, value, setValue, setIsPopoverOpen); useImperativeHandle(ref, () => inputRef.current!); @@ -242,28 +224,50 @@ const SearchInput = React.forwardRef< onOpenAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()} > - <CommandList> - <CommandGroup - heading={t("search.history")} - className="max-h-60 overflow-y-auto" - > - {/* prevent cmdk auto select the first suggestion -> https://github.com/pacocoursey/cmdk/issues/171*/} - <CommandItem value="-" className="hidden" /> - {suggestions.map((term) => ( - <CommandItem - key={term} - value={term} - onSelect={() => handleHistorySelect(term)} - onMouseDown={() => { - isHistorySelected.current = true; - }} - className="cursor-pointer" - > - <History className="mr-2 h-4 w-4" /> - <span>{term}</span> - </CommandItem> - ))} - </CommandGroup> + <CommandList className="max-h-96 overflow-y-auto"> + <CommandEmpty>{t("search.no_suggestions")}</CommandEmpty> + {hasSuggestions && <CommandItem value="-" className="hidden" />} + {suggestionGroups.map((group) => ( + <CommandGroup key={group.id} heading={group.label}> + {group.items.map((item) => { + if (item.type === "history") { + return ( + <CommandItem + key={item.id} + value={item.label} + onSelect={() => handleHistorySelect(item.term)} + onMouseDown={() => { + isHistorySelected.current = true; + }} + className="cursor-pointer" + > + <item.Icon className="mr-2 h-4 w-4" /> + <span>{item.label}</span> + </CommandItem> + ); + } + + return ( + <CommandItem + key={item.id} + value={item.label} + onSelect={() => handleSuggestionSelect(item)} + className="cursor-pointer" + > + <item.Icon className="mr-2 h-4 w-4" /> + <div className="flex flex-col"> + <span>{item.label}</span> + {item.description && ( + <span className="text-xs text-muted-foreground"> + {item.description} + </span> + )} + </div> + </CommandItem> + ); + })} + </CommandGroup> + ))} </CommandList> </PopoverContent> </Popover> 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, + }; +}; |
