Skip to content

Commit

Permalink
Add search dialog for currently open caption (#23)
Browse files Browse the repository at this point in the history
  • Loading branch information
KennethWussmann authored Nov 17, 2024
1 parent ba316f2 commit 80bf4f5
Show file tree
Hide file tree
Showing 7 changed files with 181 additions and 6 deletions.
11 changes: 10 additions & 1 deletion src/components/caption/components/caption-list-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<DropdownMenu >
<DropdownMenuTrigger asChild>
<Button variant="ghost" size={"icon"}><EllipsisVertical /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={openDialog}>
<Search />
<span>Search</span>
<DropdownMenuShortcut>
<ShortcutText settingsKey="searchCurrentCaption" />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem disabled={parts.length === 0} onClick={copy}>
<ClipboardCopy />
<span>Copy</span>
Expand Down
37 changes: 37 additions & 0 deletions src/components/search/search-current-caption-dialog-provider.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SearchCurrentCaptionDialogContext.Provider value={{ isDialogOpen, openDialog, closeDialog }}>
{children}
</SearchCurrentCaptionDialogContext.Provider>
);
};

export const useSearchCurrentCaptionDialog = () => {
const context = useContext(SearchCurrentCaptionDialogContext);
if (!context) {
throw new Error("useSearchCurrentCaptionDialog must be used within a SearchCurrentCaptionDialogProvider");
}
return context;
};
117 changes: 117 additions & 0 deletions src/components/search/search-current-caption-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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<CaptionPart[]>([]);
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, "<span class='bg-yellow-300 dark:bg-yellow-700'>$1</span>");
};


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 (
<Dialog
open={isDialogOpen}
onOpenChange={() => {
closeDialog();
reset();
}}
>
<DialogContent hideClose className="p-1">
<DialogTitle className="sr-only">Search</DialogTitle>
<DialogDescription className="sr-only">Search inside captions of current image</DialogDescription>

<div className="flex flex-col gap-2">
<Input
className="h-14 pl-4 text-xl"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder={`Search in current caption ...`}
autoFocus
/>
{searchText.length > 0 && filteredParts.length > 0 && (
<>
<Separator />
<div className="flex flex-col gap-1">
{filteredParts.map((part, index) => (
<div
key={part.id}
className={`p-1 px-2 border rounded-md ${selectedIndex === index ? "bg-muted" : ""
}`}
onClick={() => executeAction(part)}
dangerouslySetInnerHTML={{
__html: highlightText(part.text, searchText),
}}
/>
))}
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const ShortcutsSettingsContent = () => {
<ShortcutRow settingsKey="clearCaption" title="Clear current caption" description="Removes the caption of the currently open image" />
<ShortcutRow settingsKey="copyCaptionParts" title="Copy caption parts" description="Copies the currently open picture's caption parts into an internal clipboard" />
<ShortcutRow settingsKey="pasteCaptionParts" title="Paste caption parts" description="Adds the caption parts on the internal clipboard into the currently open image's caption" />
<ShortcutRow settingsKey="searchCurrentCaption" title="Search in current caption" description="Shows a search dialog to find text in the currently open caption" />
</TableBody>
</Table >
);
Expand Down
5 changes: 3 additions & 2 deletions src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
hideClose?: boolean;
hideOverlay?: boolean;
}
>(({ className, children, hideClose, ...props }, ref) => (
>(({ className, children, hideClose, hideOverlay, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
{!hideOverlay && <DialogOverlay />}
<DialogPrimitive.Content
ref={ref}
className={cn(
Expand Down
5 changes: 5 additions & 0 deletions src/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ export const settings = {
"settings.shortcuts.clearCategories",
"mod+shift+delete"
),

searchCurrentCaption: atomWithStorage(
"settings.shortcuts.searchCurrentCaption",
"mod+f"
),
},
caption: {
strategy: atomWithZod(
Expand Down
11 changes: 8 additions & 3 deletions src/pages/caption/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@ import { LensSettingsToolbar } from "@/components/toolbars/lens-settings-toolbar
import { CaptionEditorProvider } from "@/components/caption/caption-editor-provider";
import { CaptionClipboardProvider } from "@/hooks/provider/use-caption-clipboard-provider";
import { ImageListLayout } from "@/layouts/image-list-layout";
import { SearchCurrentCaptionDialogProvider } from "@/components/search/search-current-caption-dialog-provider";
import { SearchCurrentCaptionDialog } from "@/components/search/search-current-caption-dialog";

export default function Page() {
return (
<CaptionEditorProvider>
<CaptionClipboardProvider>
<ImageListLayout toolbars={[LensSettingsToolbar, ImageNavigationToolbar, CaptionSaveToolbar]}>
<CaptionView />
</ImageListLayout>
<SearchCurrentCaptionDialogProvider>
<SearchCurrentCaptionDialog />
<ImageListLayout toolbars={[LensSettingsToolbar, ImageNavigationToolbar, CaptionSaveToolbar]}>
<CaptionView />
</ImageListLayout>
</SearchCurrentCaptionDialogProvider>
</CaptionClipboardProvider>
</CaptionEditorProvider>
);
Expand Down

0 comments on commit 80bf4f5

Please sign in to comment.