From 1b36f0cbf5bc7357677bc6e636f9a51585d99cef Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 2 Oct 2025 06:27:28 -0600 Subject: [PATCH] Make keyboard shortcuts consistent --- .../guides/training_a_classification_model.py | 0 web/src/pages/Exports.tsx | 47 ++++++++- web/src/pages/FaceLibrary.tsx | 52 +++++++++- .../classification/ModelTrainingView.tsx | 95 ++++++++++++++----- web/src/views/events/EventView.tsx | 83 +++++++++------- web/src/views/search/SearchView.tsx | 25 ++++- 6 files changed, 233 insertions(+), 69 deletions(-) create mode 100644 docs/docs/guides/training_a_classification_model.py diff --git a/docs/docs/guides/training_a_classification_model.py b/docs/docs/guides/training_a_classification_model.py new file mode 100644 index 000000000..e69de29bb diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx index e2e834243..5c7b4f5c1 100644 --- a/web/src/pages/Exports.tsx +++ b/web/src/pages/Exports.tsx @@ -13,12 +13,13 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Toaster } from "@/components/ui/sonner"; +import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { useSearchEffect } from "@/hooks/use-overlay-state"; import { cn } from "@/lib/utils"; import { DeleteClipType, Export } from "@/types/export"; import axios from "axios"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isMobile } from "react-device-detect"; import { useTranslation } from "react-i18next"; @@ -109,6 +110,45 @@ function Exports() { [mutate, t], ); + // Keyboard Listener + + const contentRef = useRef(null); + useKeyboardListener( + ["ArrowDown", "ArrowUp", "PageDown", "PageUp"], + (key, modifiers) => { + if (!modifiers.down) { + return; + } + + switch (key) { + case "ArrowDown": + contentRef.current?.scrollBy({ + top: 100, + behavior: "smooth", + }); + break; + case "ArrowUp": + contentRef.current?.scrollBy({ + top: -100, + behavior: "smooth", + }); + break; + case "PageDown": + contentRef.current?.scrollBy({ + top: contentRef.current.clientHeight / 2, + behavior: "smooth", + }); + break; + case "PageUp": + contentRef.current?.scrollBy({ + top: -contentRef.current.clientHeight / 2, + behavior: "smooth", + }); + break; + } + }, + ); + return (
@@ -194,7 +234,10 @@ function Exports() {
{exports && filteredExports && filteredExports.length > 0 ? ( -
+
{Object.values(exports).map((item) => ( (null); useKeyboardListener( - ["a", "Escape"], + ["a", "Escape", "ArrowDown", "ArrowUp", "PageDown", "PageUp"], (key, modifiers) => { - if (modifiers.repeat || !modifiers.down) { + if (!modifiers.down) { return; } switch (key) { case "a": - if (modifiers.ctrl) { + if (modifiers.ctrl && !modifiers.repeat) { if (selectedFaces.length) { setSelectedFaces([]); } else { @@ -285,6 +293,30 @@ export default function FaceLibrary() { case "Escape": setSelectedFaces([]); break; + case "ArrowDown": + contentRef.current?.scrollBy({ + top: 100, + behavior: "smooth", + }); + break; + case "ArrowUp": + contentRef.current?.scrollBy({ + top: -100, + behavior: "smooth", + }); + break; + case "PageDown": + contentRef.current?.scrollBy({ + top: contentRef.current.clientHeight / 2, + behavior: "smooth", + }); + break; + case "PageUp": + contentRef.current?.scrollBy({ + top: -contentRef.current.clientHeight / 2, + behavior: "smooth", + }); + break; } }, !inputFocused, @@ -408,6 +440,7 @@ export default function FaceLibrary() { (pageToggle == "train" ? ( ) : ( ; attemptImages: string[]; faceNames: string[]; selectedFaces: string[]; @@ -618,6 +653,7 @@ type TrainingGridProps = { }; function TrainingGrid({ config, + contentRef, attemptImages, faceNames, selectedFaces, @@ -701,7 +737,10 @@ function TrainingGrid({ setInputFocused={setInputFocused} /> -
+
{Object.entries(faceGroups).map(([key, group]) => { const event = events?.find((ev) => ev.id == key); return ( @@ -1039,6 +1078,7 @@ function FaceAttempt({ } type FaceGridProps = { + contentRef: MutableRefObject; faceImages: string[]; pageToggle: string; selectedFaces: string[]; @@ -1046,6 +1086,7 @@ type FaceGridProps = { onDelete: (name: string, ids: string[]) => void; }; function FaceGrid({ + contentRef, faceImages, pageToggle, selectedFaces, @@ -1070,6 +1111,7 @@ function FaceGrid({ return (
{ - if (modifiers.repeat || !modifiers.down) { - return; - } + const contentRef = useRef(null); + useKeyboardListener( + ["a", "Escape", "ArrowDown", "ArrowUp", "PageDown", "PageUp"], + (key, modifiers) => { + if (modifiers.repeat || !modifiers.down) { + return; + } - switch (key) { - case "a": - if (modifiers.ctrl) { - if (selectedImages.length) { - setSelectedImages([]); - } else { - setSelectedImages([ - ...(pageToggle === "train" - ? trainImages || [] - : dataset?.[pageToggle] || []), - ]); + switch (key) { + case "a": + if (modifiers.ctrl) { + if (selectedImages.length) { + setSelectedImages([]); + } else { + setSelectedImages([ + ...(pageToggle === "train" + ? trainImages || [] + : dataset?.[pageToggle] || []), + ]); + } } - } - break; - case "Escape": - setSelectedImages([]); - break; - } - }); + break; + case "Escape": + setSelectedImages([]); + break; + case "ArrowDown": + contentRef.current?.scrollBy({ + top: 100, + behavior: "smooth", + }); + break; + case "ArrowUp": + contentRef.current?.scrollBy({ + top: -100, + behavior: "smooth", + }); + break; + case "PageDown": + contentRef.current?.scrollBy({ + top: contentRef.current.clientHeight / 2, + behavior: "smooth", + }); + break; + case "PageUp": + contentRef.current?.scrollBy({ + top: -contentRef.current.clientHeight / 2, + behavior: "smooth", + }); + break; + } + }, + ); useEffect(() => { setSelectedImages([]); @@ -370,6 +405,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) { {pageToggle == "train" ? ( ) : ( ; modelName: string; categoryName: string; images: string[]; @@ -587,6 +625,7 @@ type DatasetGridProps = { onDelete: (ids: string[]) => void; }; function DatasetGrid({ + contentRef, modelName, categoryName, images, @@ -602,7 +641,10 @@ function DatasetGrid({ ); return ( -
+
{classData.map((image) => (
; classes: string[]; trainImages: string[]; trainFilter?: TrainFilter; @@ -668,6 +711,7 @@ type TrainGridProps = { }; function TrainGrid({ model, + contentRef, classes, trainImages, trainFilter, @@ -726,8 +770,9 @@ function TrainGrid({ return (
diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 4ce7fcad4..c2e8ee88a 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -650,42 +650,57 @@ function DetectionReview({ // keyboard - useKeyboardListener(["a", "r", "PageDown", "PageUp"], (key, modifiers) => { - if (modifiers.repeat || !modifiers.down) { - return; - } + useKeyboardListener( + ["a", "r", "ArrowDown", "ArrowUp", "PageDown", "PageUp"], + (key, modifiers) => { + if (!modifiers.down) { + return; + } - switch (key) { - case "a": - if (modifiers.ctrl) { - onSelectAllReviews(); - } - break; - case "r": - if (selectedReviews.length > 0) { - currentItems?.forEach((item) => { - if (selectedReviews.includes(item.id)) { - item.has_been_reviewed = true; - markItemAsReviewed(item); - } + switch (key) { + case "a": + if (modifiers.ctrl && !modifiers.repeat) { + onSelectAllReviews(); + } + break; + case "r": + if (selectedReviews.length > 0 && !modifiers.repeat) { + currentItems?.forEach((item) => { + if (selectedReviews.includes(item.id)) { + item.has_been_reviewed = true; + markItemAsReviewed(item); + } + }); + setSelectedReviews([]); + } + break; + case "ArrowDown": + contentRef.current?.scrollBy({ + top: 100, + behavior: "smooth", }); - setSelectedReviews([]); - } - break; - case "PageDown": - contentRef.current?.scrollBy({ - top: contentRef.current.clientHeight / 2, - behavior: "smooth", - }); - break; - case "PageUp": - contentRef.current?.scrollBy({ - top: -contentRef.current.clientHeight / 2, - behavior: "smooth", - }); - break; - } - }); + break; + case "ArrowUp": + contentRef.current?.scrollBy({ + top: -100, + behavior: "smooth", + }); + break; + case "PageDown": + contentRef.current?.scrollBy({ + top: contentRef.current.clientHeight / 2, + behavior: "smooth", + }); + break; + case "PageUp": + contentRef.current?.scrollBy({ + top: -contentRef.current.clientHeight / 2, + behavior: "smooth", + }); + break; + } + }, + ); return ( <> diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index 2bb726bad..b97967043 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -314,7 +314,7 @@ export default function SearchView({ switch (key) { case "a": - if (modifiers.ctrl) { + if (modifiers.ctrl && !modifiers.repeat) { onSelectAllObjects(); } break; @@ -335,7 +335,6 @@ export default function SearchView({ setSearchDetail(uniqueResults[newIndex]); } break; - case "ArrowRight": if (uniqueResults.length > 0) { const currentIndex = searchDetail @@ -352,6 +351,18 @@ export default function SearchView({ setSearchDetail(uniqueResults[newIndex]); } break; + case "ArrowDown": + contentRef.current?.scrollBy({ + top: 100, + behavior: "smooth", + }); + break; + case "ArrowUp": + contentRef.current?.scrollBy({ + top: -100, + behavior: "smooth", + }); + break; case "PageDown": contentRef.current?.scrollBy({ top: contentRef.current.clientHeight / 2, @@ -370,7 +381,15 @@ export default function SearchView({ ); useKeyboardListener( - ["a", "ArrowLeft", "ArrowRight", "PageDown", "PageUp"], + [ + "a", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "PageDown", + "PageUp", + ], onKeyboardShortcut, !inputFocused, );