Last active
October 31, 2025 11:27
-
-
Save albingroen/17297bab5e539634359e7b1ad2bb6c6c to your computer and use it in GitHub Desktop.
Global Search
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import Mark from "mark.js"; | |
| import { useEffect, useRef, useState } from "react"; | |
| import { | |
| Dialog, | |
| DialogClose, | |
| DialogContent, | |
| DialogDescription, | |
| DialogTitle, | |
| } from "@radix-ui/react-dialog"; | |
| import { | |
| InputGroup, | |
| InputGroupAddon, | |
| InputGroupButton, | |
| InputGroupInput, | |
| InputGroupText, | |
| } from "./ui/input-group"; | |
| import { ArrowDownIcon, ArrowUpIcon, SearchIcon, XIcon } from "lucide-react"; | |
| import { cn } from "@/lib/utils"; | |
| import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; | |
| import { Kbd, KbdGroup } from "./ui/kbd"; | |
| import { platform } from "@tauri-apps/plugin-os"; | |
| function isMetaKey(event: React.KeyboardEvent | KeyboardEvent) { | |
| const platformName = platform(); | |
| if (platformName === "macos") { | |
| return event.metaKey; | |
| } | |
| return event.ctrlKey; | |
| } | |
| export default function GlobalSearch() { | |
| const [isOpen, setIsOpen] = useState<boolean>(false); | |
| const [searchTerm, setSearchTerm] = useState(""); | |
| const [currentIndex, setCurrentIndex] = useState(0); | |
| const [totalMatches, setTotalMatches] = useState(0); | |
| const markInstance = useRef<Mark | null>(null); | |
| const inputRef = useRef<HTMLInputElement>(null); | |
| useEffect(() => { | |
| markInstance.current = new Mark(document.body); | |
| function handleKeyDown(event: KeyboardEvent) { | |
| if (isMetaKey(event) && event.key === "f") { | |
| if (isOpen) { | |
| inputRef.current?.focus(); | |
| } else { | |
| setIsOpen(true); | |
| } | |
| } | |
| } | |
| window.addEventListener("keydown", handleKeyDown); | |
| return () => { | |
| window.removeEventListener("keydown", handleKeyDown); | |
| }; | |
| }, [isOpen]); | |
| useEffect(() => { | |
| if (isOpen && searchTerm) { | |
| performSearch(searchTerm); | |
| } else if (!isOpen) { | |
| if (markInstance.current) { | |
| markInstance.current.unmark(); | |
| } | |
| } | |
| }, [isOpen]); | |
| const performSearch = (term: string) => { | |
| if (!markInstance.current) return; | |
| markInstance.current.unmark(); | |
| if (!term) { | |
| setTotalMatches(0); | |
| setCurrentIndex(0); | |
| return; | |
| } | |
| markInstance.current.mark(term, { | |
| separateWordSearch: false, | |
| done: (totalMarks) => { | |
| setTotalMatches(totalMarks); | |
| if (totalMarks > 0) { | |
| setCurrentIndex(0); | |
| scrollToMatch(0); | |
| } | |
| }, | |
| className: "search-highlight", | |
| each: (element) => { | |
| element.setAttribute("data-mark-index", ""); | |
| }, | |
| }); | |
| }; | |
| const scrollToMatch = (index: number) => { | |
| const marks = document.querySelectorAll("mark.search-highlight"); | |
| if (marks[index]) { | |
| marks.forEach((m) => m.classList.remove("current-match")); | |
| marks[index].classList.add("current-match"); | |
| marks[index].scrollIntoView({ | |
| behavior: "smooth", | |
| block: "center", | |
| }); | |
| } | |
| }; | |
| const goToNext = () => { | |
| if (totalMatches === 0) return; | |
| const nextIndex = (currentIndex + 1) % totalMatches; | |
| setCurrentIndex(nextIndex); | |
| scrollToMatch(nextIndex); | |
| }; | |
| const goToPrevious = () => { | |
| if (totalMatches === 0) return; | |
| const prevIndex = (currentIndex - 1 + totalMatches) % totalMatches; | |
| setCurrentIndex(prevIndex); | |
| scrollToMatch(prevIndex); | |
| }; | |
| return ( | |
| <Dialog open={isOpen} onOpenChange={setIsOpen} modal={false}> | |
| <DialogContent | |
| onInteractOutside={(e) => { | |
| e.preventDefault(); | |
| }} | |
| className="bg-background shadow-xl flex items-center z-50 fixed top-2 right-2 gap-2 rounded-md" | |
| > | |
| <DialogTitle className="sr-only">Search</DialogTitle> | |
| <DialogDescription className="sr-only"> | |
| Search the page for any word or character | |
| </DialogDescription> | |
| <InputGroup> | |
| <InputGroupAddon> | |
| <SearchIcon /> | |
| </InputGroupAddon> | |
| <InputGroupInput | |
| ref={inputRef} | |
| value={searchTerm} | |
| onChange={(e) => { | |
| setSearchTerm(e.target.value); | |
| performSearch(e.target.value); | |
| }} | |
| placeholder="Find..." | |
| onKeyDown={(e) => { | |
| if ( | |
| (e.shiftKey && e.key === "Enter") || | |
| (isMetaKey(e) && e.shiftKey && e.key === "g") | |
| ) { | |
| goToPrevious(); | |
| } else if (e.key === "Enter" || (isMetaKey(e) && e.key === "g")) { | |
| goToNext(); | |
| } | |
| }} | |
| /> | |
| <InputGroupAddon align="inline-end" className="w-20 justify-end"> | |
| <InputGroupText | |
| className={cn(searchTerm ? "opacity-100" : "opacity-0")} | |
| > | |
| {totalMatches > 0 | |
| ? `${currentIndex + 1} of ${totalMatches}` | |
| : "0 of 0"} | |
| </InputGroupText> | |
| </InputGroupAddon> | |
| <InputGroupAddon align="inline-end" className="gap-0"> | |
| <Tooltip> | |
| <TooltipTrigger asChild> | |
| <InputGroupButton size="icon-xs" onClick={goToNext}> | |
| <ArrowDownIcon /> | |
| </InputGroupButton> | |
| </TooltipTrigger> | |
| <TooltipContent className="pr-1"> | |
| <span>Next result</span> | |
| <KbdGroup className="ml-1.5"> | |
| <Kbd>⏎</Kbd> | |
| </KbdGroup> | |
| </TooltipContent> | |
| </Tooltip> | |
| <Tooltip> | |
| <TooltipTrigger asChild> | |
| <InputGroupButton size="icon-xs" onClick={goToPrevious}> | |
| <ArrowUpIcon /> | |
| </InputGroupButton> | |
| </TooltipTrigger> | |
| <TooltipContent className="pr-1"> | |
| <span>Previous result</span> | |
| <KbdGroup className="ml-1.5"> | |
| <Kbd>⇧</Kbd> | |
| <Kbd>⏎</Kbd> | |
| </KbdGroup> | |
| </TooltipContent> | |
| </Tooltip> | |
| </InputGroupAddon> | |
| <InputGroupAddon align="inline-end"> | |
| <Tooltip> | |
| <TooltipTrigger asChild> | |
| <DialogClose asChild> | |
| <InputGroupButton size="icon-xs"> | |
| <XIcon /> | |
| </InputGroupButton> | |
| </DialogClose> | |
| </TooltipTrigger> | |
| <TooltipContent className="pr-1"> | |
| <span>Close find</span> | |
| <KbdGroup className="ml-1.5"> | |
| <Kbd>␛</Kbd> | |
| </KbdGroup> | |
| </TooltipContent> | |
| </Tooltip> | |
| </InputGroupAddon> | |
| </InputGroup> | |
| </DialogContent> | |
| </Dialog> | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment