"use client"; import React, { useCallback, useEffect, useImperativeHandle, useRef, useState, } from "react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { useDoBookmarkSearch } from "@/lib/hooks/bookmark-search"; import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; import { useSearchHistory } from "@karakeep/shared-react/hooks/search-history"; import { EditListModal } from "../lists/EditListModal"; import QueryExplainerTooltip from "./QueryExplainerTooltip"; import { useSearchAutocomplete } from "./useSearchAutocomplete"; function useFocusSearchOnKeyPress( inputRef: React.RefObject, value: string, setValue: (value: string) => void, setPopoverOpen: React.Dispatch>, ) { useEffect(() => { function handleKeyPress(e: KeyboardEvent) { if (!inputRef.current) { return; } if ((e.metaKey || e.ctrlKey) && e.code === "KeyK") { e.preventDefault(); inputRef.current.focus(); // Move the cursor to the end of the input field, so you can continue typing const length = inputRef.current.value.length; inputRef.current.setSelectionRange(length, length); setPopoverOpen(true); } if (e.code === "Escape" && e.target == inputRef.current && value !== "") { e.preventDefault(); inputRef.current.blur(); setValue(""); } } document.addEventListener("keydown", handleKeyPress); return () => { document.removeEventListener("keydown", handleKeyPress); }; }, [inputRef, value, setValue, setPopoverOpen]); } const SearchInput = React.forwardRef< HTMLInputElement, React.HTMLAttributes & { loading?: boolean } >(({ className, ...props }, ref) => { const { t } = useTranslation(); const { debounceSearch, searchQuery, doSearch, parsedSearchQuery, isInSearchPage, } = useDoBookmarkSearch(); const { addTerm, history } = useSearchHistory({ getItem: (k: string) => localStorage.getItem(k), setItem: (k: string, v: string) => localStorage.setItem(k, v), removeItem: (k: string) => localStorage.removeItem(k), }); const [value, setValue] = useState(searchQuery); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false); const inputRef = useRef(null); const isHistorySelected = useRef(false); const isComposing = useRef(false); const handleValueChange = useCallback( (newValue: string) => { setValue(newValue); // Only trigger debounced search if not in IME composition mode if (!isComposing.current) { debounceSearch(newValue); } isHistorySelected.current = false; // Reset flag when user types }, [debounceSearch], ); const handleCompositionStart = useCallback(() => { isComposing.current = true; }, []); const handleCompositionEnd = useCallback( (e: React.CompositionEvent) => { isComposing.current = false; // Trigger search with the final composed value const target = e.target as HTMLInputElement; debounceSearch(target.value); }, [debounceSearch], ); const { suggestionGroups, hasSuggestions, isPopoverVisible, handleSuggestionSelect, handleCommandKeyDown, } = useSearchAutocomplete({ value, onValueChange: handleValueChange, inputRef, isPopoverOpen, setIsPopoverOpen, t, history, }); const handleHistorySelect = useCallback( (term: string) => { isHistorySelected.current = true; setValue(term); doSearch(term); addTerm(term); setIsPopoverOpen(false); inputRef.current?.blur(); }, [doSearch, addTerm], ); useFocusSearchOnKeyPress(inputRef, value, setValue, setIsPopoverOpen); useImperativeHandle(ref, () => inputRef.current!); useEffect(() => { if (!isInSearchPage) { setValue(""); } }, [isInSearchPage]); const handleFocus = useCallback(() => { setIsPopoverOpen(true); }, []); const handleBlur = useCallback(() => { // Only add to history if it wasn't a history selection if (value && !isHistorySelected.current) { addTerm(value); } // Reset the flag isHistorySelected.current = false; setIsPopoverOpen(false); }, [value, addTerm]); return (
{parsedSearchQuery.result === "full" && parsedSearchQuery.text.length == 0 && ( )}
e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()} > {t("search.no_suggestions")} {hasSuggestions && } {suggestionGroups.map((group) => ( {group.items.map((item) => { if (item.type === "history") { return ( handleHistorySelect(item.term)} onMouseDown={() => { isHistorySelected.current = true; }} className="cursor-pointer" > {item.label} ); } return ( handleSuggestionSelect(item)} className="cursor-pointer" >
{item.label} {item.description && ( {item.description} )}
); })}
))}
); }); SearchInput.displayName = "SearchInput"; export { SearchInput };