mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add Cmd+Enter (process) and Cmd+S (download) keyboard shortcuts
This commit is contained in:
@@ -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" },
|
||||
|
||||
@@ -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();
|
||||
})
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user