Skip to content

Commit

Permalink
Allow navigation through parts with arrow keys (#29)
Browse files Browse the repository at this point in the history
  • Loading branch information
KennethWussmann authored Nov 20, 2024
1 parent 3c975ab commit f198103
Show file tree
Hide file tree
Showing 5 changed files with 186 additions and 25 deletions.
28 changes: 19 additions & 9 deletions src/components/caption/caption-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="relative flex flex-col h-full gap-4">
<div className="flex flex-grow">
<div className="flex-grow w-full h-full flex items-center justify-center overflow-hidden">
<ImageViewer image={currentImage} />
</div>
<ArrowKeyNavigationProvider onSubmit={editPart}>
<div className="flex flex-grow">
<div className="flex-grow w-full h-full flex items-center justify-center overflow-hidden">
<ImageViewer image={currentImage} />
</div>

<div className="w-1/3 h-full flex flex-col">
<div className="flex-grow overflow-y-auto">
<CaptionList />
<div className="w-1/3 h-full flex flex-col">
<div className="flex-grow overflow-y-auto">
<CaptionList />
</div>
</div>
</div>
</div>
<CaptionInput />
<CaptionInput />
</ArrowKeyNavigationProvider>
</div>
);
};
42 changes: 26 additions & 16 deletions src/components/caption/components/caption-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -38,17 +39,17 @@ export const CaptionInput = () => {
isEditing,
addPart,
updatePart,
enterEditMode,
cancelEditMode,
deletePart,
parts,
} = useCaptionEditor();
const inputFieldRef = useRef<HTMLInputElement>(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())
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -112,10 +106,24 @@ export const CaptionInput = () => {
}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
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();
Expand All @@ -125,21 +133,23 @@ export const CaptionInput = () => {
onChange={(event) => setValue(event.target.value)}
ref={inputFieldRef}
autoFocus
disabled={isEnabled}
/>
</div>
</div>
<div className="flex gap-1 text-xs text-muted-foreground">
Hit <kbd>Enter</kbd> to submit{" "}
Hit <kbd>Enter</kbd>
{isEnabled && !isEditing ? <>to edit highlighted part or <kbd>Escape</kbd> to cancel</> : isEmpty && isEditing ? "to delete part " : "to submit "}
{isEditing && (
<>
or <kbd>Escape</kbd>
to cancel editing
</>
)}
{!isEditing && sanitizeValue(value).length === 0 && (
{!isEditing && !isEnabled && isEmpty && (
<>
or <ArrowUp className="h-4 w-4" />
to edit last caption
or <ArrowUp className="h-4 w-4" /><ArrowDown className="h-4 w-4" />
to navigate caption parts
</>
)}
</div>
Expand Down
4 changes: 4 additions & 0 deletions src/components/caption/components/caption-list-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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={{
Expand Down
6 changes: 6 additions & 0 deletions src/components/caption/components/caption-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -26,9 +27,14 @@ export const CaptionList = () => {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const { setLength } = useArrowKeyNavigation()
const partRefs = useRef<Record<string, HTMLDivElement | null>>({});
const lastLength = useRef(0);

useEffect(() => {
setLength(parts.length)
}, [parts.length, setLength])

useEffect(() => {
const lastPart = parts[parts.length - 1];
if (parts.length === lastLength.current) {
Expand Down
131 changes: 131 additions & 0 deletions src/components/common/arrow-key-navigation-provider.tsx
Original file line number Diff line number Diff line change
@@ -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<ArrowKeyNavigationContextType | null>(null);

type ArrowKeyNavigationProviderProps = {
children: React.ReactNode;
enabled?: boolean;
onSubmit?: (index: number) => void;
};

export const ArrowKeyNavigationProvider: React.FC<ArrowKeyNavigationProviderProps> = ({
children,
enabled: enabledInitially = false,
onSubmit,
}) => {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [length, setLength] = useState<number>(0);
const [enabled, setEnabled] = useState<boolean>(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 (
<ArrowKeyNavigationContext.Provider
value={{
selectedIndex,
setLength,
navigateNext,
navigatePrevious,
submitCurrentItem,
isSelected,
unselect,
selectFirst,
selectLast,
isEnabled: enabled,
enable,
disable,
}}
>
{children}
</ArrowKeyNavigationContext.Provider>
);
};

export const useArrowKeyNavigation = (): ArrowKeyNavigationContextType => {
const context = useContext(ArrowKeyNavigationContext);
if (!context) {
throw new Error(
'useArrowKeyNavigation must be used within an ArrowKeyNavigationProvider'
);
}

return context;
};

0 comments on commit f198103

Please sign in to comment.