feat: add Cmd+Enter (process) and Cmd+S (download) keyboard shortcuts

This commit is contained in:
SnapOtter
2026-06-14 19:17:34 +08:00
parent 6ea515ccdd
commit 7c9ca85952
25 changed files with 94 additions and 6 deletions
@@ -81,6 +81,7 @@ export function ReviewPanel({
{/* Download button with format + size */}
<button
type="button"
data-download-button
onClick={handleDownload}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium text-sm flex items-center justify-center gap-2 hover:bg-primary/90"
>
@@ -13,6 +13,8 @@ interface HelpDialogProps {
const SHORTCUTS = [
{ keys: "mod+k", description: "Focus search bar" },
{ keys: "mod+/", description: "Go to tools" },
{ keys: "mod+enter", description: "Process file" },
{ keys: "mod+s", description: "Download result" },
{ keys: "mod+shift+d", description: "Toggle theme" },
{ keys: "mod+alt+1", description: "Go to Resize" },
{ keys: "mod+alt+2", description: "Go to Crop" },
+36 -5
View File
@@ -55,11 +55,37 @@ export function useKeyboardShortcuts() {
const { toggleTheme } = useTheme();
const focusSearchBar = useCallback(() => {
const searchInput = document.querySelector<HTMLInputElement>('input[placeholder*="Search"]');
const searchInput = document.querySelector<HTMLInputElement>(
'[data-search-input], input[placeholder*="Search"]',
);
if (searchInput) {
searchInput.focus();
searchInput.select();
} else {
// Navigate to home with focus param so the search input gets focused on mount
navigate("/?focus=search");
}
}, [navigate]);
const triggerProcess = useCallback(() => {
// Try submitting a form inside the settings panel first
const form = document.querySelector<HTMLFormElement>(".settings-container form");
if (form) {
form.requestSubmit();
return;
}
// Fall back to clicking a submit-like button inside the settings panel
const btn = document.querySelector<HTMLButtonElement>(
'.settings-container button[data-testid$="-submit"]',
);
if (btn && !btn.disabled) {
btn.click();
}
}, []);
const triggerDownload = useCallback(() => {
const el = document.querySelector<HTMLElement>("[data-download-button]");
if (el) el.click();
}, []);
useEffect(() => {
@@ -67,6 +93,8 @@ export function useKeyboardShortcuts() {
{ keys: "mod+k", description: "Focus search bar", action: focusSearchBar },
{ keys: "mod+/", description: "Go to tools", action: () => navigate("/") },
{ keys: "mod+shift+d", description: "Toggle theme", action: toggleTheme },
{ keys: "mod+enter", description: "Process file", action: triggerProcess },
{ keys: "mod+s", description: "Download result", action: triggerDownload },
{ keys: "mod+alt+1", description: "Go to Resize", action: () => navigate("/resize") },
{ keys: "mod+alt+2", description: "Go to Crop", action: () => navigate("/crop") },
{ keys: "mod+alt+3", description: "Go to Compress", action: () => navigate("/compress") },
@@ -89,15 +117,17 @@ export function useKeyboardShortcuts() {
{ keys: "mod+alt+8", description: "Go to Image Info", action: () => navigate("/info") },
];
// Shortcuts that should work even when focused on an input/textarea
const inputSafeKeys = new Set(["mod+k", "mod+s", "mod+enter"]);
function handler(e: KeyboardEvent) {
// Don't intercept when typing in inputs/textareas (except Cmd+K for search)
// Don't intercept when typing in inputs/textareas (except input-safe shortcuts)
const tag = (e.target as HTMLElement)?.tagName;
const isInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
for (const shortcut of shortcuts) {
if (matchesShortcut(e, shortcut.keys)) {
// Allow Cmd+K even in inputs (it focuses search)
if (isInput && shortcut.keys !== "mod+k") continue;
if (isInput && !inputSafeKeys.has(shortcut.keys)) continue;
e.preventDefault();
e.stopPropagation();
shortcut.action();
@@ -108,7 +138,7 @@ export function useKeyboardShortcuts() {
window.addEventListener("keydown", handler, { capture: true });
return () => window.removeEventListener("keydown", handler, { capture: true });
}, [navigate, toggleTheme, focusSearchBar]);
}, [navigate, toggleTheme, focusSearchBar, triggerProcess, triggerDownload]);
}
/**
@@ -123,6 +153,7 @@ export function formatShortcut(keys: string): string {
if (lk === "mod") return mac ? "\u2318" : "Ctrl";
if (lk === "shift") return mac ? "\u21E7" : "Shift";
if (lk === "alt") return mac ? "\u2325" : "Alt";
if (lk === "enter") return mac ? "↩" : "Enter";
if (lk === "/") return "/";
return k.trim().toUpperCase();
})
+13 -1
View File
@@ -2,7 +2,7 @@ import type { Tool } from "@snapotter/shared";
import { CATEGORIES, MODALITIES, TOOLS } from "@snapotter/shared";
import { FileImage, Search, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { ToolCard } from "@/components/common/tool-card.js";
import { AppLayout } from "@/components/layout/app-layout.js";
import { Footer } from "@/components/layout/footer.js";
@@ -215,12 +215,24 @@ function HomeSearchBar({
placeholder: string;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const location = useLocation();
const navigate = useNavigate();
// Auto-focus when navigated here with ?focus=search (e.g. from Cmd+K on a tool page)
useEffect(() => {
const params = new URLSearchParams(location.search);
if (params.get("focus") === "search") {
inputRef.current?.focus();
navigate("/", { replace: true });
}
}, [location.search, navigate]);
return (
<div className="relative max-w-xl mx-auto mb-6">
<Search className="absolute start-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground pointer-events-none" />
<input
ref={inputRef}
data-search-input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}