From 80bf4f53334a25e43a5ac43400a67e57e3dc378d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kenneth=20Wu=C3=9Fmann?= Date: Sun, 17 Nov 2024 16:02:38 +0100 Subject: [PATCH] Add search dialog for currently open caption (#23) --- .../components/caption-list-dropdown.tsx | 11 +- ...search-current-caption-dialog-provider.tsx | 37 ++++++ .../search/search-current-caption-dialog.tsx | 117 ++++++++++++++++++ .../shortcuts-settings-content.tsx | 1 + src/components/ui/dialog.tsx | 5 +- src/lib/settings.ts | 5 + src/pages/caption/page.tsx | 11 +- 7 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 src/components/search/search-current-caption-dialog-provider.tsx create mode 100644 src/components/search/search-current-caption-dialog.tsx diff --git a/src/components/caption/components/caption-list-dropdown.tsx b/src/components/caption/components/caption-list-dropdown.tsx index cfb9de1..97dc635 100644 --- a/src/components/caption/components/caption-list-dropdown.tsx +++ b/src/components/caption/components/caption-list-dropdown.tsx @@ -2,17 +2,26 @@ import { ShortcutText } from "@/components/common/shortcut-text" import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger } from "@/components/ui" import { useCaptionEditor } from "@/components/caption/caption-editor-provider"; import { useCaptionClipboard } from "@/hooks/provider/use-caption-clipboard-provider"; -import { ClipboardCopy, ClipboardPaste, EllipsisVertical, Trash } from "lucide-react" +import { ClipboardCopy, ClipboardPaste, EllipsisVertical, Search, Trash } from "lucide-react" +import { useSearchCurrentCaptionDialog } from "@/components/search/search-current-caption-dialog-provider"; export const CaptionListDropdown = () => { const { parts, clearParts } = useCaptionEditor(); const { copy, paste, hasContent } = useCaptionClipboard(); + const { openDialog } = useSearchCurrentCaptionDialog(); return ( + + + Search + + + + Copy diff --git a/src/components/search/search-current-caption-dialog-provider.tsx b/src/components/search/search-current-caption-dialog-provider.tsx new file mode 100644 index 0000000..dec218a --- /dev/null +++ b/src/components/search/search-current-caption-dialog-provider.tsx @@ -0,0 +1,37 @@ +import { createContext, useContext, useState, ReactNode } from "react"; +import { useShortcut } from "@/hooks/use-shortcut"; + +type SearchCurrentCaptionDialogContextProps = { + isDialogOpen: boolean; + openDialog: () => void; + closeDialog: () => void; +}; + +const SearchCurrentCaptionDialogContext = createContext< + SearchCurrentCaptionDialogContextProps | undefined +>(undefined); + +export const SearchCurrentCaptionDialogProvider = ({ children }: { children: ReactNode }) => { + const [isDialogOpen, setDialogOpen] = useState(false); + + const openDialog = () => setDialogOpen(true); + const closeDialog = () => setDialogOpen(false); + + useShortcut("searchCurrentCaption", () => { + setDialogOpen((current) => !current); + }); + + return ( + + {children} + + ); +}; + +export const useSearchCurrentCaptionDialog = () => { + const context = useContext(SearchCurrentCaptionDialogContext); + if (!context) { + throw new Error("useSearchCurrentCaptionDialog must be used within a SearchCurrentCaptionDialogProvider"); + } + return context; +}; diff --git a/src/components/search/search-current-caption-dialog.tsx b/src/components/search/search-current-caption-dialog.tsx new file mode 100644 index 0000000..f8aa2bc --- /dev/null +++ b/src/components/search/search-current-caption-dialog.tsx @@ -0,0 +1,117 @@ +import { Dialog, DialogContent, DialogDescription, DialogTitle, Input, Separator } from "@/components/ui"; +import { useState, useEffect, useCallback } from "react"; +import { useCaptionEditor } from "../caption/caption-editor-provider"; +import { CaptionPart } from "@/lib/types"; +import { useSearchCurrentCaptionDialog } from "./search-current-caption-dialog-provider"; + +export const SearchCurrentCaptionDialog = () => { + const { isDialogOpen, closeDialog } = useSearchCurrentCaptionDialog(); + const { parts, enterEditMode } = useCaptionEditor(); + const [searchText, setSearchText] = useState(""); + const [filteredParts, setFilteredParts] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + + const reset = () => { + setSearchText(""); + setFilteredParts([]); + setSelectedIndex(0); + }; + + const fuzzySearch = (query: string, items: CaptionPart[]) => { + if (!query) return items; + const lowerQuery = query.toLowerCase(); + return items.filter((item) => + item.text.toLowerCase().includes(lowerQuery) + ); + }; + + useEffect(() => { + const results = fuzzySearch(searchText, parts); + setFilteredParts(results); + setSelectedIndex(0); + }, [searchText, parts]); + + const highlightText = (text: string, query: string) => { + if (!query) return text; + const regex = new RegExp(`(${query})`, "gi"); + return text.replace(regex, "$1"); + }; + + + const executeAction = useCallback((part: CaptionPart) => { + enterEditMode(part); + closeDialog(); + reset(); + }, [closeDialog, enterEditMode]); + + const handleArrowNavigation = useCallback( + (e: KeyboardEvent) => { + if (!isDialogOpen || !filteredParts.length) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((prev) => (prev + 1) % filteredParts.length); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((prev) => + (prev - 1 + filteredParts.length) % filteredParts.length + ); + } else if (e.key === "Enter") { + e.preventDefault(); + executeAction(filteredParts[selectedIndex]); + } + }, + [isDialogOpen, filteredParts, executeAction, selectedIndex] + ); + + useEffect(() => { + if (!isDialogOpen) { + return; + } + window.addEventListener("keydown", handleArrowNavigation); + return () => window.removeEventListener("keydown", handleArrowNavigation); + }, [handleArrowNavigation, isDialogOpen]); + + return ( + { + closeDialog(); + reset(); + }} + > + + Search + Search inside captions of current image + +
+ setSearchText(e.target.value)} + placeholder={`Search in current caption ...`} + autoFocus + /> + {searchText.length > 0 && filteredParts.length > 0 && ( + <> + +
+ {filteredParts.map((part, index) => ( +
executeAction(part)} + dangerouslySetInnerHTML={{ + __html: highlightText(part.text, searchText), + }} + /> + ))} +
+ + )} +
+ +
+ ); +}; diff --git a/src/components/settings/content/shortcuts-settings/shortcuts-settings-content.tsx b/src/components/settings/content/shortcuts-settings/shortcuts-settings-content.tsx index aba12a1..dc20025 100644 --- a/src/components/settings/content/shortcuts-settings/shortcuts-settings-content.tsx +++ b/src/components/settings/content/shortcuts-settings/shortcuts-settings-content.tsx @@ -17,6 +17,7 @@ const ShortcutsSettingsContent = () => { + ); diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index c67faec..da3d4e6 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -31,10 +31,11 @@ const DialogContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { hideClose?: boolean; + hideOverlay?: boolean; } ->(({ className, children, hideClose, ...props }, ref) => ( +>(({ className, children, hideClose, hideOverlay, ...props }, ref) => ( - + {!hideOverlay && } - - - + + + + + + );