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)}
|
||||
|
||||
@@ -3310,6 +3310,8 @@ export const ar: TranslationKeys = {
|
||||
goToWatermarkText: "الانتقال للعلامة المائية النصية",
|
||||
goToStripMetadata: "الانتقال لإزالة البيانات الوصفية",
|
||||
goToImageInfo: "الانتقال لمعلومات الصورة",
|
||||
processFile: "معالجة الملف",
|
||||
downloadResult: "تنزيل النتيجة",
|
||||
},
|
||||
resources: {
|
||||
heading: "الموارد",
|
||||
|
||||
@@ -3340,6 +3340,8 @@ export const de: TranslationKeys = {
|
||||
goToWatermarkText: "Zu Text-Wasserzeichen",
|
||||
goToStripMetadata: "Zu Metadaten entfernen",
|
||||
goToImageInfo: "Zu Bildinformationen",
|
||||
processFile: "Datei verarbeiten",
|
||||
downloadResult: "Ergebnis herunterladen",
|
||||
},
|
||||
resources: {
|
||||
heading: "Ressourcen",
|
||||
|
||||
@@ -3277,6 +3277,8 @@ export const en = {
|
||||
goToWatermarkText: "Go to Watermark Text",
|
||||
goToStripMetadata: "Go to Strip Metadata",
|
||||
goToImageInfo: "Go to Image Info",
|
||||
processFile: "Process file",
|
||||
downloadResult: "Download result",
|
||||
},
|
||||
resources: {
|
||||
heading: "Resources",
|
||||
|
||||
@@ -3317,6 +3317,8 @@ export const es: TranslationKeys = {
|
||||
goToWatermarkText: "Ir a Marca de agua de texto",
|
||||
goToStripMetadata: "Ir a Eliminar metadatos",
|
||||
goToImageInfo: "Ir a Info de imagen",
|
||||
processFile: "Procesar archivo",
|
||||
downloadResult: "Descargar resultado",
|
||||
},
|
||||
resources: {
|
||||
heading: "Recursos",
|
||||
|
||||
@@ -3338,6 +3338,8 @@ export const fr: TranslationKeys = {
|
||||
goToWatermarkText: "Aller au Filigrane texte",
|
||||
goToStripMetadata: "Aller a Supprimer les metadonnees",
|
||||
goToImageInfo: "Aller a Info image",
|
||||
processFile: "Traiter le fichier",
|
||||
downloadResult: "Telecharger le resultat",
|
||||
},
|
||||
resources: {
|
||||
heading: "Ressources",
|
||||
|
||||
@@ -3307,6 +3307,8 @@ export const hi: TranslationKeys = {
|
||||
goToWatermarkText: "टेक्स्ट वॉटरमार्क पर जाएं",
|
||||
goToStripMetadata: "मेटाडेटा हटाएं पर जाएं",
|
||||
goToImageInfo: "इमेज जानकारी पर जाएं",
|
||||
processFile: "फ़ाइल प्रोसेस करें",
|
||||
downloadResult: "परिणाम डाउनलोड करें",
|
||||
},
|
||||
resources: {
|
||||
heading: "संसाधन",
|
||||
|
||||
@@ -3325,6 +3325,8 @@ export const id: TranslationKeys = {
|
||||
goToWatermarkText: "Ke Watermark Teks",
|
||||
goToStripMetadata: "Ke Hapus Metadata",
|
||||
goToImageInfo: "Ke Info Gambar",
|
||||
processFile: "Proses file",
|
||||
downloadResult: "Unduh hasil",
|
||||
},
|
||||
resources: {
|
||||
heading: "Sumber Daya",
|
||||
|
||||
@@ -3332,6 +3332,8 @@ export const it: TranslationKeys = {
|
||||
goToWatermarkText: "Vai a Filigrana testo",
|
||||
goToStripMetadata: "Vai a Rimuovi metadati",
|
||||
goToImageInfo: "Vai a Info immagine",
|
||||
processFile: "Elabora file",
|
||||
downloadResult: "Scarica risultato",
|
||||
},
|
||||
resources: {
|
||||
heading: "Risorse",
|
||||
|
||||
@@ -3281,6 +3281,8 @@ export const ja: TranslationKeys = {
|
||||
goToWatermarkText: "テキストウォーターマークへ移動",
|
||||
goToStripMetadata: "メタデータ削除へ移動",
|
||||
goToImageInfo: "画像情報へ移動",
|
||||
processFile: "ファイルを処理",
|
||||
downloadResult: "結果をダウンロード",
|
||||
},
|
||||
resources: {
|
||||
heading: "リソース",
|
||||
|
||||
@@ -3266,6 +3266,8 @@ export const ko: TranslationKeys = {
|
||||
goToWatermarkText: "텍스트 워터마크로 이동",
|
||||
goToStripMetadata: "메타데이터 제거로 이동",
|
||||
goToImageInfo: "이미지 정보로 이동",
|
||||
processFile: "파일 처리",
|
||||
downloadResult: "결과 다운로드",
|
||||
},
|
||||
resources: {
|
||||
heading: "리소스",
|
||||
|
||||
@@ -3328,6 +3328,8 @@ export const nl: TranslationKeys = {
|
||||
goToWatermarkText: "Naar Tekst-watermerk",
|
||||
goToStripMetadata: "Naar Metadata verwijderen",
|
||||
goToImageInfo: "Naar Beeldinformatie",
|
||||
processFile: "Bestand verwerken",
|
||||
downloadResult: "Resultaat downloaden",
|
||||
},
|
||||
resources: {
|
||||
heading: "Bronnen",
|
||||
|
||||
@@ -3335,6 +3335,8 @@ export const pl: TranslationKeys = {
|
||||
goToWatermarkText: "Przejdź do tekstowego znaku wodnego",
|
||||
goToStripMetadata: "Przejdź do usuwania metadanych",
|
||||
goToImageInfo: "Przejdź do informacji o obrazie",
|
||||
processFile: "Przetwórz plik",
|
||||
downloadResult: "Pobierz wynik",
|
||||
},
|
||||
resources: {
|
||||
heading: "Zasoby",
|
||||
|
||||
@@ -3328,6 +3328,8 @@ export const ptBR: TranslationKeys = {
|
||||
goToWatermarkText: "Ir para Marca d'agua de texto",
|
||||
goToStripMetadata: "Ir para Remover metadados",
|
||||
goToImageInfo: "Ir para Info da imagem",
|
||||
processFile: "Processar arquivo",
|
||||
downloadResult: "Baixar resultado",
|
||||
},
|
||||
resources: {
|
||||
heading: "Recursos",
|
||||
|
||||
@@ -3327,6 +3327,8 @@ export const ru: TranslationKeys = {
|
||||
goToWatermarkText: "Перейти к текстовому водяному знаку",
|
||||
goToStripMetadata: "Перейти к удалению метаданных",
|
||||
goToImageInfo: "Перейти к информации об изображении",
|
||||
processFile: "Обработать файл",
|
||||
downloadResult: "Скачать результат",
|
||||
},
|
||||
resources: {
|
||||
heading: "Ресурсы",
|
||||
|
||||
@@ -3322,6 +3322,8 @@ export const sv: TranslationKeys = {
|
||||
goToWatermarkText: "Ga till Textvattenstampel",
|
||||
goToStripMetadata: "Ga till Ta bort metadata",
|
||||
goToImageInfo: "Ga till Bildinformation",
|
||||
processFile: "Bearbeta fil",
|
||||
downloadResult: "Ladda ner resultat",
|
||||
},
|
||||
resources: {
|
||||
heading: "Resurser",
|
||||
|
||||
@@ -3298,6 +3298,8 @@ export const th: TranslationKeys = {
|
||||
goToWatermarkText: "ไปที่ลายน้ำข้อความ",
|
||||
goToStripMetadata: "ไปที่ลบข้อมูลเมตา",
|
||||
goToImageInfo: "ไปที่ข้อมูลภาพ",
|
||||
processFile: "ประมวลผลไฟล์",
|
||||
downloadResult: "ดาวน์โหลดผลลัพธ์",
|
||||
},
|
||||
resources: {
|
||||
heading: "แหล่งข้อมูล",
|
||||
|
||||
@@ -3332,6 +3332,8 @@ export const tr: TranslationKeys = {
|
||||
goToWatermarkText: "Metin Filigranına git",
|
||||
goToStripMetadata: "Meta Veri Kaldırmaya git",
|
||||
goToImageInfo: "Görüntü Bilgisine git",
|
||||
processFile: "Dosyayi isle",
|
||||
downloadResult: "Sonucu indir",
|
||||
},
|
||||
resources: {
|
||||
heading: "Kaynaklar",
|
||||
|
||||
@@ -3328,6 +3328,8 @@ export const uk: TranslationKeys = {
|
||||
goToWatermarkText: "Перейти до текстового водяного знаку",
|
||||
goToStripMetadata: "Перейти до видалення метаданих",
|
||||
goToImageInfo: "Перейти до інформації про зображення",
|
||||
processFile: "Обробити файл",
|
||||
downloadResult: "Завантажити результат",
|
||||
},
|
||||
resources: {
|
||||
heading: "Ресурси",
|
||||
|
||||
@@ -3322,6 +3322,8 @@ export const vi: TranslationKeys = {
|
||||
goToWatermarkText: "Đến Hình mờ văn bản",
|
||||
goToStripMetadata: "Đến Xóa siêu dữ liệu",
|
||||
goToImageInfo: "Đến Thông tin ảnh",
|
||||
processFile: "Xu ly tep",
|
||||
downloadResult: "Tai ket qua",
|
||||
},
|
||||
resources: {
|
||||
heading: "Tài nguyên",
|
||||
|
||||
@@ -3248,6 +3248,8 @@ export const zhCN: TranslationKeys = {
|
||||
goToWatermarkText: "前往文字水印",
|
||||
goToStripMetadata: "前往移除元数据",
|
||||
goToImageInfo: "前往图片信息",
|
||||
processFile: "处理文件",
|
||||
downloadResult: "下载结果",
|
||||
},
|
||||
resources: {
|
||||
heading: "资源",
|
||||
|
||||
@@ -3247,6 +3247,8 @@ export const zhTW: TranslationKeys = {
|
||||
goToWatermarkText: "前往文字浮水印",
|
||||
goToStripMetadata: "前往移除中繼資料",
|
||||
goToImageInfo: "前往影像資訊",
|
||||
processFile: "處理檔案",
|
||||
downloadResult: "下載結果",
|
||||
},
|
||||
resources: {
|
||||
heading: "資源",
|
||||
|
||||
Reference in New Issue
Block a user