diff --git a/src/components/caption/caption-view.tsx b/src/components/caption/caption-view.tsx index 94bf831..544b2f2 100644 --- a/src/components/caption/caption-view.tsx +++ b/src/components/caption/caption-view.tsx @@ -2,26 +2,36 @@ import { CaptionInput } from "./components/caption-input"; import { CaptionList } from "./components/caption-list"; import { ImageViewer } from "../common/image-viewer"; import { useImageNavigation } from "@/hooks/provider/image-navigation-provider"; +import { ArrowKeyNavigationProvider } from "../common/arrow-key-navigation-provider"; +import { useCaptionEditor } from "./caption-editor-provider"; export const CaptionView = () => { + const { enterEditMode, parts } = useCaptionEditor() const { currentImage } = useImageNavigation(); + + const editPart = (index: number) => { + enterEditMode(parts[index]) + } + if (!currentImage) { return null; } return (
-
-
- -
+ +
+
+ +
-
-
- +
+
+ +
-
- + +
); }; diff --git a/src/components/caption/components/caption-input.tsx b/src/components/caption/components/caption-input.tsx index 0cbfc33..4d7c29d 100644 --- a/src/components/caption/components/caption-input.tsx +++ b/src/components/caption/components/caption-input.tsx @@ -3,10 +3,11 @@ import { useCaptionEditor } from "@/components/caption/caption-editor-provider"; import { settings } from "@/lib/settings"; import clsx from "clsx"; import { useAtom } from "jotai/react"; -import { ArrowUp, Pencil, X } from "lucide-react"; +import { ArrowDown, ArrowUp, Pencil, X } from "lucide-react"; import { KeyboardEvent, useEffect, useRef } from "react"; import { useApplyTextReplacements } from "@/hooks/use-apply-text-replacements"; import { useShortcut } from "@/hooks/use-shortcut"; +import { useArrowKeyNavigation } from "@/components/common/arrow-key-navigation-provider"; export const EditBanner = ({ onCancel }: { onCancel?: VoidFunction }) => { const { isEditing, cancelEditMode } = useCaptionEditor(); @@ -38,17 +39,17 @@ export const CaptionInput = () => { isEditing, addPart, updatePart, - enterEditMode, cancelEditMode, deletePart, - parts, } = useCaptionEditor(); const inputFieldRef = useRef(null); useShortcut("focusInput", () => { inputFieldRef.current?.focus(); }); + const { enable, isEnabled, selectFirst, selectLast } = useArrowKeyNavigation() const sanitizeValue = (value: string) => value.trim(); + const isEmpty = sanitizeValue(value).length === 0 const splitIntoParts = (value: string) => value.split(separator.trim()) .map((text) => text.trim()) @@ -78,13 +79,6 @@ export const CaptionInput = () => { setValue(""); }; - const onEditLast = () => { - if (isEditing || value.trim() !== "" || parts.length === 0) { - return; - } - enterEditMode(parts[parts.length - 1]); - }; - const onCancelEditing = () => { setValue(""); cancelEditMode(); @@ -112,10 +106,24 @@ export const CaptionInput = () => { } onKeyDown={(event: KeyboardEvent) => { if (event.key === "Enter") { + if (isEnabled) { + return + } onSubmit(event.shiftKey); } - if (event.key === "ArrowUp") { - onEditLast(); + if (event.key === "ArrowUp" && !isEditing) { + if (!isEnabled) { + event.preventDefault() + enable() + setTimeout(selectLast, 10) + } + } + if (event.key === "ArrowDown" && !isEditing) { + if (!isEnabled) { + event.preventDefault() + enable() + setTimeout(selectFirst, 10) + } } if (event.key === "Escape" && isEditing) { onCancelEditing(); @@ -125,21 +133,23 @@ export const CaptionInput = () => { onChange={(event) => setValue(event.target.value)} ref={inputFieldRef} autoFocus + disabled={isEnabled} />
- Hit Enter to submit{" "} + Hit Enter + {isEnabled && !isEditing ? <>to edit highlighted part or Escape to cancel : isEmpty && isEditing ? "to delete part " : "to submit "} {isEditing && ( <> or Escape to cancel editing )} - {!isEditing && sanitizeValue(value).length === 0 && ( + {!isEditing && !isEnabled && isEmpty && ( <> - or - to edit last caption + or + to navigate caption parts )}
diff --git a/src/components/caption/components/caption-list-item.tsx b/src/components/caption/components/caption-list-item.tsx index b9c0586..b7084d6 100644 --- a/src/components/caption/components/caption-list-item.tsx +++ b/src/components/caption/components/caption-list-item.tsx @@ -14,6 +14,7 @@ import { CaptionPart } from "@/lib/types"; import { forwardRef, useImperativeHandle, useRef } from "react"; import { motion } from "framer-motion"; import { useCaptionEditor } from "@/components/caption/caption-editor-provider"; +import { useArrowKeyNavigation } from "@/components/common/arrow-key-navigation-provider"; export const CaptionListItem = forwardRef< HTMLDivElement, @@ -29,6 +30,7 @@ export const CaptionListItem = forwardRef< transition, isDragging, } = useSortable({ id: part.id }); + const { isSelected } = useArrowKeyNavigation(); const customTransitions = "box-shadow 0.3s ease, margin 0.3s ease, background-color 0.3s ease"; const finalTransition = transition @@ -50,6 +52,8 @@ export const CaptionListItem = forwardRef< { "border-blue-600 border-2 border-dashed bg-blue-50 dark:bg-blue-600 dark:bg-opacity-40": isCurrentItemEditing, + "border-blue-600 border": + isSelected(part.index), } )} style={{ diff --git a/src/components/caption/components/caption-list.tsx b/src/components/caption/components/caption-list.tsx index 03751f1..7daca6f 100644 --- a/src/components/caption/components/caption-list.tsx +++ b/src/components/caption/components/caption-list.tsx @@ -17,6 +17,7 @@ import { AnimatedGroup } from "@/components/ui/animation/animated-group"; import { useEffect, useRef } from "react"; import { useCaptionEditor } from "@/components/caption/caption-editor-provider"; import { CaptionListDropdown } from "./caption-list-dropdown"; +import { useArrowKeyNavigation } from "@/components/common/arrow-key-navigation-provider"; export const CaptionList = () => { const { parts, handleDragEnd } = useCaptionEditor(); @@ -26,9 +27,14 @@ export const CaptionList = () => { coordinateGetter: sortableKeyboardCoordinates, }) ); + const { setLength } = useArrowKeyNavigation() const partRefs = useRef>({}); const lastLength = useRef(0); + useEffect(() => { + setLength(parts.length) + }, [parts.length, setLength]) + useEffect(() => { const lastPart = parts[parts.length - 1]; if (parts.length === lastLength.current) { diff --git a/src/components/common/arrow-key-navigation-provider.tsx b/src/components/common/arrow-key-navigation-provider.tsx new file mode 100644 index 0000000..51bc417 --- /dev/null +++ b/src/components/common/arrow-key-navigation-provider.tsx @@ -0,0 +1,131 @@ +import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; + +type ArrowKeyNavigationContextType = { + selectedIndex: number | null; + setLength: (length: number) => void; + selectLast: () => void; + selectFirst: () => void; + navigateNext: () => void; + navigatePrevious: () => void; + submitCurrentItem: () => void; + isSelected: (index: number) => boolean; + unselect: () => void; + isEnabled: boolean; + enable: () => void; + disable: () => void; +}; + +const ArrowKeyNavigationContext = createContext(null); + +type ArrowKeyNavigationProviderProps = { + children: React.ReactNode; + enabled?: boolean; + onSubmit?: (index: number) => void; +}; + +export const ArrowKeyNavigationProvider: React.FC = ({ + children, + enabled: enabledInitially = false, + onSubmit, +}) => { + const [selectedIndex, setSelectedIndex] = useState(null); + const [length, setLength] = useState(0); + const [enabled, setEnabled] = useState(enabledInitially); + + const navigateNext = useCallback(() => { + if (!enabled || length === 0) return; + setSelectedIndex((prev) => (prev === null ? 0 : (prev + 1) % length)); + }, [enabled, length]); + + const navigatePrevious = useCallback(() => { + if (!enabled || length === 0) return; + setSelectedIndex((prev) => + prev === null ? length - 1 : (prev - 1 + length) % length + ); + }, [enabled, length]); + + const isSelected = useCallback( + (index: number) => selectedIndex === index && enabled, + [selectedIndex, enabled] + ); + + const unselect = useCallback(() => setSelectedIndex(null), []); + const selectFirst = useCallback(() => setSelectedIndex(0), []); + const selectLast = useCallback(() => { + setSelectedIndex(length - 1) + }, [length]); + + const enable = useCallback(() => setEnabled(true), []); + const disable = useCallback(() => setEnabled(false), []); + + const submitCurrentItem = useCallback(() => { + if (!enabled || selectedIndex === null || !onSubmit) { + return; + } + disable() + onSubmit(selectedIndex); + }, [enabled, disable, onSubmit, selectedIndex]); + + useEffect(() => { + if (!enabled) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case 'ArrowDown': + navigateNext(); + break; + case 'ArrowUp': + navigatePrevious(); + break; + case 'Enter': + event.preventDefault(); + submitCurrentItem(); + break; + case 'Escape': + event.preventDefault(); + disable(); + setSelectedIndex(null); + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [disable, enabled, navigateNext, navigatePrevious, submitCurrentItem]); + + return ( + + {children} + + ); +}; + +export const useArrowKeyNavigation = (): ArrowKeyNavigationContextType => { + const context = useContext(ArrowKeyNavigationContext); + if (!context) { + throw new Error( + 'useArrowKeyNavigation must be used within an ArrowKeyNavigationProvider' + ); + } + + return context; +};