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;
+};