diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 51baeaedc..4618f51ee 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -460,6 +460,8 @@ pub struct AppSettings { pub canvas_input_mode: Option, #[serde(default)] pub zoom_speed_multiplier: Option, + #[serde(default)] + pub keybinds: HashMap>, } fn default_adjustment_visibility() -> HashMap { @@ -531,6 +533,7 @@ impl Default for AppSettings { use_wgpu_renderer: Some(true), canvas_input_mode: Some("mouse".to_string()), zoom_speed_multiplier: Some(1.0), + keybinds: HashMap::new(), } } } diff --git a/src/App.tsx b/src/App.tsx index 1fee88963..17662e130 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3193,7 +3193,6 @@ function App() { handleZoomChange, isFullScreen, isStraightenActive, - isViewLoading, libraryActivePath, multiSelectedPaths, redo, @@ -3213,6 +3212,7 @@ function App() { displaySize, baseRenderSize, originalSize, + keybinds: appSettings?.keybinds, brushSettings: brushSettings, setBrushSettings: setBrushSettings, }); diff --git a/src/components/panel/SettingsPanel.tsx b/src/components/panel/SettingsPanel.tsx index a78d00ec4..0ea93ab16 100644 --- a/src/components/panel/SettingsPanel.tsx +++ b/src/components/panel/SettingsPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { ArrowLeft, Cloud, @@ -32,6 +32,7 @@ import Input from '../ui/Input'; import Slider from '../ui/Slider'; import { ThemeProps, THEMES, DEFAULT_THEME_ID } from '../../utils/themes'; import { Invokes } from '../ui/AppProperties'; +import { arraysEqual, codeToDisplayLabel, formatKeyCode, KeybindDefinition, KEYBIND_DEFINITIONS, KEYBIND_SECTIONS, normalizeCombo } from '../../utils/keyboardUtils'; import Text from '../ui/Text'; import { TextColors, TextVariants, TextWeights } from '../../types/typography'; import { useOsPlatform } from '../../hooks/useOsPlatform'; @@ -56,9 +57,14 @@ interface DataActionItemProps { title: string; } -interface KeybindItemProps { - description: string; - keys: Array; +interface KeybindRowProps { + def: KeybindDefinition; + currentCombo?: string[]; + osPlatform: string; + onSave: (action: string, combo: string[]) => void; + recordingAction: string | null; + onStartRecording: (action: string) => void; + isConflicting: boolean; } interface SettingItemProps { @@ -144,28 +150,68 @@ const linearRawOptions: OptionItem[] = [ const settingCategories = [ { id: 'general', label: 'General', icon: SlidersHorizontal }, { id: 'processing', label: 'Processing', icon: Cpu }, - { id: 'shortcuts', label: 'Shortcuts', icon: Keyboard }, + { id: 'shortcuts', label: 'Controls', icon: Keyboard }, ]; -const KeybindItem = ({ keys, description }: KeybindItemProps) => ( -
- {description} -
- {keys.map((key: string, index: number) => ( - { + const recording = recordingAction === def.action; + + useEffect(() => { + if (!recording) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onSave(def.action, []); + onStartRecording(''); + return; + } + e.preventDefault(); + const parts = normalizeCombo(e, osPlatform); + if (parts.length > 0 && !['ctrl', 'shift', 'alt'].includes(parts[parts.length - 1])) { + onSave(def.action, parts); + onStartRecording(''); + } + }; + window.addEventListener('keydown', handler, { capture: true }); + return () => window.removeEventListener('keydown', handler, { capture: true }); + }, [recording, def.action, onSave, onStartRecording]); + + const displayCombo = currentCombo !== undefined ? (currentCombo.length ? currentCombo : null) : def.defaultCombo; + + return ( +
+ {def.description} +
+ {isConflicting && } + +
-
-); + ); +}; const SettingItem = ({ children, description, label }: SettingItemProps) => (
@@ -369,6 +415,7 @@ export default function SettingsPanel({ }); const [testStatus, setTestStatus] = useState({ message: '', success: null, testing: false }); const [hasInteractedWithLivePreview, setHasInteractedWithLivePreview] = useState(false); + const [recordingAction, setRecordingAction] = useState(null); const [aiProvider, setAiProvider] = useState(appSettings?.aiProvider || 'cpu'); const [aiConnectorAddress, setAiConnectorAddress] = useState(appSettings?.aiConnectorAddress || ''); @@ -724,6 +771,29 @@ export default function SettingsPanel({ } }; +const handleKeybindSave = (action: string, combo: string[]) => { + const newKeybinds = { ...(appSettings?.keybinds || {}), [action]: combo }; + onSettingsChange({ ...appSettings, keybinds: newKeybinds }); + }; + + const conflictingKeys = useMemo(() => { + const map = new Map>(); + const userKb = appSettings?.keybinds || {}; + for (const def of KEYBIND_DEFINITIONS) { + const userCombo = userKb[def.action]; + const effective = userCombo?.length ? userCombo : (userCombo === undefined ? def.defaultCombo : null); + if (!effective) continue; + const key = effective.join('+'); + if (!map.has(key)) map.set(key, new Set()); + map.get(key)!.add(def.action); + } + const keys = new Set(); + for (const [, actions] of map) { + if (actions.size > 1) actions.forEach((k) => keys.add(k)); + } + return keys; + }, [appSettings?.keybinds]); + return ( <> @@ -869,44 +939,6 @@ export default function SettingsPanel({
-
- - Canvas Interaction - -
-
- - Input Device Optimization - - - Choose the primary input device you use to pan and zoom the canvas. - - onSettingsChange({ ...appSettings, canvasInputMode: value })} - /> -
- - - - onSettingsChange({ ...appSettings, zoomSpeedMultiplier: parseFloat(e.target.value) }) - } - fillOrigin="min" - /> - -
-
-
Adjustments Visibility @@ -1843,107 +1875,92 @@ export default function SettingsPanel({ )} - {activeCategory === 'shortcuts' && ( - -
- - Keyboard Shortcuts - -
-
- General -
- - - - - - - - - - -
-
-
- Editor -
- - - - - - - - - +
+ + Mouse Controls + +
+
+ + Input Device Optimization + + + Choose the primary input device you use to pan and zoom the canvas. + + onSettingsChange({ ...appSettings, canvasInputMode: value })} /> - - - - - - - - - - - - - - - - -
-
-
- Mouse Controls -
- - - - - - -
+ + + onSettingsChange({ ...appSettings, zoomSpeedMultiplier: parseFloat(e.target.value) }) + } + fillOrigin="min" + /> +
+
-
- Trackpad Controls -
- - - - +
+ + Keyboard Controls + +
{KEYBIND_SECTIONS.map((section) => { + const sectionDefs = KEYBIND_DEFINITIONS.filter((d) => d.section === section.id); + const userKb = appSettings?.keybinds || {}; + return ( +
+ {section.label} +
+ {sectionDefs.map((def) => ( + + ))} +
+
+ ); + })} +
+
-
-
-
- - )} +
+
+ + )}
diff --git a/src/components/ui/AppProperties.tsx b/src/components/ui/AppProperties.tsx index c776e5777..0f7e61d78 100644 --- a/src/components/ui/AppProperties.tsx +++ b/src/components/ui/AppProperties.tsx @@ -183,6 +183,7 @@ export interface AppSettings { useWgpuRenderer?: boolean; canvasInputMode?: 'mouse' | 'trackpad'; zoomSpeedMultiplier?: number; + keybinds?: { [action: string]: string[] }; } export interface BrushSettings { @@ -333,3 +334,8 @@ export interface CullingSuggestions { blurryImages: ImageAnalysisResult[]; failedPaths: string[]; } + +export interface KeybindHandler { + shouldFire?: () => boolean; + execute: (event: KeyboardEvent) => void; +} diff --git a/src/hooks/useKeyboardShortcuts.tsx b/src/hooks/useKeyboardShortcuts.tsx index 606c58204..4e9d0940b 100644 --- a/src/hooks/useKeyboardShortcuts.tsx +++ b/src/hooks/useKeyboardShortcuts.tsx @@ -1,6 +1,7 @@ -import { useEffect } from 'react'; -import { ImageFile, Panel, SelectedImage } from '../components/ui/AppProperties'; +import { useEffect, useMemo } from 'react'; +import { KeybindHandler, ImageFile, Panel, SelectedImage } from '../components/ui/AppProperties'; import { BrushSettings } from '../components/ui/AppProperties'; +import { KeybindDefinition, KEYBIND_DEFINITIONS, normalizeCombo } from '../utils/keyboardUtils'; interface KeyboardShortcutsProps { activeAiPatchContainerId?: string | null; @@ -30,7 +31,7 @@ interface KeyboardShortcutsProps { isFullScreen: boolean; isModalOpen: boolean; isStraightenActive: boolean; - isViewLoading: boolean; + keybinds?: { [action: string]: string[] }; libraryActivePath: string | null; multiSelectedPaths: Array; onSelectPatchContainer?(container: string | null): void; @@ -51,8 +52,8 @@ interface KeyboardShortcutsProps { displaySize?: { width: number; height: number }; baseRenderSize?: { width: number; height: number }; originalSize?: { width: number; height: number }; - brushSettings: BrushSettings | null; - setBrushSettings: (settings: BrushSettings) => void; + brushSettings: BrushSettings | null; + setBrushSettings: (settings: BrushSettings) => void; } export const useKeyboardShortcuts = ({ @@ -83,7 +84,7 @@ export const useKeyboardShortcuts = ({ isFullScreen, isModalOpen, isStraightenActive, - isViewLoading, + keybinds, libraryActivePath, multiSelectedPaths, onSelectPatchContainer, @@ -104,135 +105,233 @@ export const useKeyboardShortcuts = ({ displaySize, baseRenderSize, originalSize, - brushSettings, - setBrushSettings, + brushSettings, + setBrushSettings, }: KeyboardShortcutsProps) => { - useEffect(() => { - const handleKeyDown = (event: any) => { - if (isModalOpen) { - return; - } + function getEffectiveCombo(def: KeybindDefinition): string[] | null { + const userCombo = keybinds?.[def.action]; + if (userCombo !== undefined) { + return userCombo.length > 0 ? userCombo : null; + } + return def.defaultCombo; + } - const isInputFocused = - document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA'; - if (isInputFocused) { - return; + const comboMap = useMemo(() => { + const map = new Map(); + for (const def of KEYBIND_DEFINITIONS) { + const effective = getEffectiveCombo(def); + if (effective) { + map.set(effective.join('+'), def.action); } - const isCtrl = event.ctrlKey || event.metaKey; - const isShift = event.shiftKey; - const key = event.key.toLowerCase(); - const code = event.code; + } + return map; + }, [keybinds]); - if (selectedImage) { - if (key === 'escape') { + useEffect(() => { + const actions: Record = { + open_image: { + shouldFire: () => !selectedImage && libraryActivePath !== null, + execute: (event) => { event.preventDefault(); handleImageSelect(libraryActivePath!); }, + }, + copy_adjustments: { + shouldFire: () => true, + execute: (event) => { event.preventDefault(); handleCopyAdjustments(); }, + }, + paste_adjustments: { + shouldFire: () => true, + execute: (event) => { event.preventDefault(); handlePasteAdjustments(); }, + }, + copy_files: { + shouldFire: () => multiSelectedPaths.length > 0, + execute: (event) => { event.preventDefault(); setCopiedFilePaths(multiSelectedPaths); }, + }, + paste_files: { + shouldFire: () => true, + execute: (event) => { event.preventDefault(); handlePasteFiles('copy'); }, + }, + select_all: { + shouldFire: () => sortedImageList.length > 0, + execute: (event) => { event.preventDefault(); - if (isStraightenActive) { - setIsStraightenActive(false); - } else if (customEscapeHandler) { - customEscapeHandler(); - } else if (activeAiSubMaskId) { - setActiveAiSubMaskId(null); - } else if (activeAiPatchContainerId && onSelectPatchContainer) { - onSelectPatchContainer(null); - } else if (activeMaskId) { - setActiveMaskId(null); - } else if (activeMaskContainerId) { - setActiveMaskContainerId(null); - } else if (activeRightPanel === Panel.Crop) { - handleRightPanelSelect(Panel.Adjustments); - } else if (isFullScreen) { - handleToggleFullScreen(); - } else { - handleBackToLibrary(); + setMultiSelectedPaths(sortedImageList.map((f: ImageFile) => f.path)); + if (!selectedImage) { + setLibraryActivePath(sortedImageList[sortedImageList.length - 1].path); } - return; - } - if (key === ' ' && !isCtrl) { + }, + }, + delete_selected: { + shouldFire: () => { + if (activeMaskContainerId || activeAiPatchContainerId) return false; + return true; + }, + execute: (event) => { event.preventDefault(); handleDeleteSelected(); }, + }, + preview_prev: { + shouldFire: () => !!selectedImage, + execute: (event) => { + event.preventDefault(); + const currentIndex = sortedImageList.findIndex((img: ImageFile) => img.path === selectedImage!.path); + if (currentIndex === -1) return; + let nextIndex = currentIndex - 1; + if (nextIndex < 0) nextIndex = sortedImageList.length - 1; + handleImageSelect(sortedImageList[nextIndex].path); + }, + }, + preview_next: { + shouldFire: () => !!selectedImage, + execute: (event) => { + event.preventDefault(); + const currentIndex = sortedImageList.findIndex((img: ImageFile) => img.path === selectedImage!.path); + if (currentIndex === -1) return; + let nextIndex = currentIndex + 1; + if (nextIndex >= sortedImageList.length) nextIndex = 0; + handleImageSelect(sortedImageList[nextIndex].path); + }, + }, + zoom_in_step: { + shouldFire: () => !!selectedImage, + execute: (event) => { + event.preventDefault(); + const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; + const currentPercent = + originalSize && originalSize.width > 0 && displaySize && displaySize.width > 0 + ? (displaySize.width * dpr) / originalSize.width + : 1.0; + handleZoomChange(Math.min(currentPercent + 0.1, 2.0)); + }, + }, + zoom_out_step: { + shouldFire: () => !!selectedImage, + execute: (event) => { + event.preventDefault(); + const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; + const currentPercent = + originalSize && originalSize.width > 0 && displaySize && displaySize.width > 0 + ? (displaySize.width * dpr) / originalSize.width + : 1.0; + handleZoomChange(Math.max(currentPercent - 0.1, 0.1)); + }, + }, + cycle_zoom: { + shouldFire: () => !!selectedImage, + execute: (event) => { event.preventDefault(); - - // Calculate current zoom percentage relative to original const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; const currentPercent = originalSize && originalSize.width > 0 && displaySize && displaySize.width > 0 ? Math.round(((displaySize.width * dpr) / originalSize.width) * 100) : 100; - - // Toggle between fit-to-window, 2x fit-to-window (if < 100%), and 100% let fitPercent = 100; - if ( - originalSize && - originalSize.width > 0 && - originalSize.height > 0 && - baseRenderSize && - baseRenderSize.width > 0 && - baseRenderSize.height > 0 - ) { + if (originalSize && originalSize.width > 0 && originalSize.height > 0 && baseRenderSize && baseRenderSize.width > 0 && baseRenderSize.height > 0) { const originalAspect = originalSize.width / originalSize.height; const baseAspect = baseRenderSize.width / baseRenderSize.height; - if (originalAspect > baseAspect) { - // Width is limiting (landscape) fitPercent = Math.round(((baseRenderSize.width * dpr) / originalSize.width) * 100); } else { - // Height is limiting (portrait) fitPercent = Math.round(((baseRenderSize.height * dpr) / originalSize.height) * 100); } } - const doubleFitPercent = fitPercent * 2; if (Math.abs(currentPercent - fitPercent) < 5) { - // Zoom 2x FitToWindows handleZoomChange(doubleFitPercent < 100 ? doubleFitPercent / 100 : 1.0); } else if (Math.abs(currentPercent - doubleFitPercent) < 5 && doubleFitPercent < 100) { - // Zoom 100% handleZoomChange(1.0); } else { - // Zoom FitToWindows handleZoomChange(0, true); } - return; - } - if (key === 'f' && !isCtrl) { - event.preventDefault(); - handleToggleFullScreen(); - } - if (key === 'b' && !isCtrl) { - event.preventDefault(); - setShowOriginal((prev: boolean) => !prev); - } - if (key === 'd' && !isCtrl) { - event.preventDefault(); - handleRightPanelSelect(Panel.Adjustments); - } - if (key === 'r' && !isCtrl) { - event.preventDefault(); - handleRightPanelSelect(Panel.Crop); - } - if (key === 'm' && !isCtrl) { - event.preventDefault(); - handleRightPanelSelect(Panel.Masks); - } - if (key === 'k' && !isCtrl) { + }, + }, + zoom_in: { + shouldFire: () => !!selectedImage, + execute: (event) => { event.preventDefault(); - handleRightPanelSelect(Panel.Ai); - } - if (key === 'p' && !isCtrl) { - event.preventDefault(); - handleRightPanelSelect(Panel.Presets); - } - if (key === 'i' && !isCtrl) { - event.preventDefault(); - handleRightPanelSelect(Panel.Metadata); - } - if (key === 'e' && !isCtrl) { - event.preventDefault(); - handleRightPanelSelect(Panel.Export); - } - if (key === 'a' && !isCtrl) { + const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; + const currentPercent = + originalSize && originalSize.width > 0 && displaySize && displaySize.width > 0 + ? (displaySize.width * dpr) / originalSize.width + : 1.0; + handleZoomChange(Math.min(currentPercent * 1.2, 2.0)); + }, + }, + zoom_out: { + shouldFire: () => !!selectedImage, + execute: (event) => { event.preventDefault(); - setIsWaveformVisible((prev: boolean) => !prev); - } - if (key === 's' && !isCtrl) { + const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; + const currentPercent = + originalSize && originalSize.width > 0 && displaySize && displaySize.width > 0 + ? (displaySize.width * dpr) / originalSize.width + : 1.0; + handleZoomChange(Math.max(currentPercent / 1.2, 0.1)); + }, + }, + zoom_fit: { + shouldFire: () => !!selectedImage, + execute: (event) => { event.preventDefault(); handleZoomChange(0, true); }, + }, + zoom_100: { + shouldFire: () => !!selectedImage, + execute: (event) => { event.preventDefault(); handleZoomChange(1.0); }, + }, + rotate_left: { + shouldFire: () => !!selectedImage, + execute: (event) => { event.preventDefault(); handleRotate(-90); }, + }, + rotate_right: { + shouldFire: () => !!selectedImage, + execute: (event) => { event.preventDefault(); handleRotate(90); }, + }, + undo: { + shouldFire: () => !!selectedImage && canUndo, + execute: (event) => { event.preventDefault(); undo(); }, + }, + redo: { + shouldFire: () => !!selectedImage && canRedo, + execute: (event) => { event.preventDefault(); redo(); }, + }, + toggle_fullscreen: { + shouldFire: () => !!selectedImage, + execute: (event) => { event.preventDefault(); handleToggleFullScreen(); }, + }, + show_original: { + shouldFire: () => !!selectedImage, + execute: (event) => { event.preventDefault(); setShowOriginal((prev: boolean) => !prev); }, + }, + toggle_adjustments: { + shouldFire: () => !!selectedImage, + execute: (event) => { event.preventDefault(); handleRightPanelSelect(Panel.Adjustments); }, + }, + toggle_crop_panel: { + shouldFire: () => !!selectedImage, + execute: (event) => { event.preventDefault(); handleRightPanelSelect(Panel.Crop); }, + }, + toggle_masks: { + shouldFire: () => !!selectedImage, + execute: (event) => { event.preventDefault(); handleRightPanelSelect(Panel.Masks); }, + }, + toggle_ai: { + shouldFire: () => !!selectedImage, + execute: (event) => { event.preventDefault(); handleRightPanelSelect(Panel.Ai); }, + }, + toggle_presets: { + shouldFire: () => !!selectedImage, + execute: (event) => { event.preventDefault(); handleRightPanelSelect(Panel.Presets); }, + }, + toggle_metadata: { + shouldFire: () => !!selectedImage, + execute: (event) => { event.preventDefault(); handleRightPanelSelect(Panel.Metadata); }, + }, + toggle_analytics: { + shouldFire: () => !!selectedImage, + execute: (event) => { event.preventDefault(); setIsWaveformVisible((prev: boolean) => !prev); }, + }, + toggle_export: { + shouldFire: () => !!selectedImage, + execute: (event) => { event.preventDefault(); handleRightPanelSelect(Panel.Export); }, + }, + toggle_crop: { + shouldFire: () => !!selectedImage, + execute: (event) => { event.preventDefault(); if (activeRightPanel === Panel.Crop) { setIsStraightenActive((prev: boolean) => !prev); @@ -240,213 +339,148 @@ export const useKeyboardShortcuts = ({ handleRightPanelSelect(Panel.Crop); setIsStraightenActive(true); } - } - } else { - if ((key === 'enter' || key === ' ') && !isCtrl) { + }, + }, + rate_0: { + shouldFire: () => true, + execute: (event) => { event.preventDefault(); handleRate(0); }, + }, + rate_1: { + shouldFire: () => true, + execute: (event) => { event.preventDefault(); handleRate(1); }, + }, + rate_2: { + shouldFire: () => true, + execute: (event) => { event.preventDefault(); handleRate(2); }, + }, + rate_3: { + shouldFire: () => true, + execute: (event) => { event.preventDefault(); handleRate(3); }, + }, + rate_4: { + shouldFire: () => true, + execute: (event) => { event.preventDefault(); handleRate(4); }, + }, + rate_5: { + shouldFire: () => true, + execute: (event) => { event.preventDefault(); handleRate(5); }, + }, + color_label_none: { + shouldFire: () => true, + execute: (event) => { event.preventDefault(); handleSetColorLabel(null); }, + }, + color_label_red: { + shouldFire: () => true, + execute: (event) => { event.preventDefault(); handleSetColorLabel('red'); }, + }, + color_label_yellow: { + shouldFire: () => true, + execute: (event) => { event.preventDefault(); handleSetColorLabel('yellow'); }, + }, + color_label_green: { + shouldFire: () => true, + execute: (event) => { event.preventDefault(); handleSetColorLabel('green'); }, + }, + color_label_blue: { + shouldFire: () => true, + execute: (event) => { event.preventDefault(); handleSetColorLabel('blue'); }, + }, + color_label_purple: { + shouldFire: () => true, + execute: (event) => { event.preventDefault(); handleSetColorLabel('purple'); }, + }, + brush_size_up: { + shouldFire: () => !!selectedImage && !!brushSettings && activeRightPanel === Panel.Masks, + execute: (event) => { event.preventDefault(); - if (libraryActivePath) { - handleImageSelect(libraryActivePath); - } - return; - } - } - - if (['arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(key)) { - event.preventDefault(); - - if (!isCtrl) { - if (selectedImage) { - if (key === 'arrowup' || key === 'arrowdown') { - const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; - // Calculate current zoom percentage relative to original - const currentPercent = - originalSize && originalSize.width > 0 && displaySize && displaySize.width > 0 - ? (displaySize.width * dpr) / originalSize.width - : 1.0; - - const step = 0.1; // 10% steps - const newPercent = key === 'arrowup' ? currentPercent + step : currentPercent - step; - - // Clamp to 10%-200% of original size - const clampedPercent = Math.max(0.1, Math.min(newPercent, 2.0)); - handleZoomChange(clampedPercent); - } else { - const isNext = key === 'arrowright'; - const currentIndex = sortedImageList.findIndex((img: ImageFile) => img.path === selectedImage.path); - if (currentIndex === -1) { - return; - } - let nextIndex = isNext ? currentIndex + 1 : currentIndex - 1; - if (nextIndex >= sortedImageList.length) { - nextIndex = 0; - } - if (nextIndex < 0) { - nextIndex = sortedImageList.length - 1; - } - const nextImage = sortedImageList[nextIndex]; - if (nextImage) { - handleImageSelect(nextImage.path); - } - } - } else { - const isNext = key === 'arrowright' || key === 'arrowdown'; - const activePath = libraryActivePath; - if (!activePath || sortedImageList.length === 0) { - return; - } - const currentIndex = sortedImageList.findIndex((img: ImageFile) => img.path === activePath); - if (currentIndex === -1) { - return; - } - let nextIndex = isNext ? currentIndex + 1 : currentIndex - 1; - if (nextIndex >= sortedImageList.length) { - nextIndex = 0; - } - if (nextIndex < 0) { - nextIndex = sortedImageList.length - 1; - } - const nextImage = sortedImageList[nextIndex]; - if (nextImage) { - setLibraryActivePath(nextImage.path); - setMultiSelectedPaths([nextImage.path]); - } - } - } - } + if (!brushSettings) return; + const newSize = Math.min((brushSettings.size || 50) + 10, 200); + setBrushSettings({ feather: brushSettings.feather, size: newSize, tool: brushSettings.tool }); + }, + }, + brush_size_down: { + shouldFire: () => !!selectedImage && !!brushSettings && activeRightPanel === Panel.Masks, + execute: (event) => { + event.preventDefault(); + if (!brushSettings) return; + const newSize = Math.max((brushSettings.size || 50) - 10, 1); + setBrushSettings({ feather: brushSettings.feather, size: newSize, tool: brushSettings.tool }); + }, + }, + }; - if (code.startsWith('Digit') && !isCtrl) { - event.preventDefault(); - const keyNum = parseInt(code.replace('Digit', ''), 10); + type BuiltInMatch = (e: KeyboardEvent) => boolean; + type BuiltInExec = (e: KeyboardEvent) => void; - if (isShift) { - if (keyNum === 0) { - handleSetColorLabel(null); - } else if (keyNum >= 1 && keyNum <= 5) { - const colors = ['red', 'yellow', 'green', 'blue', 'purple']; - handleSetColorLabel(colors[keyNum - 1]); + const builtinShortcuts: Array<{ match: BuiltInMatch; execute: BuiltInExec }> = [ + { + match: (e) => e.code === 'Escape', + execute: (e) => { + e.preventDefault(); + if (isStraightenActive) setIsStraightenActive(false); + else if (customEscapeHandler) customEscapeHandler(); + else if (activeAiSubMaskId) setActiveAiSubMaskId(null); + else if (activeAiPatchContainerId && onSelectPatchContainer) onSelectPatchContainer(null); + else if (activeMaskId) setActiveMaskId(null); + else if (activeMaskContainerId) setActiveMaskContainerId(null); + else if (activeRightPanel === Panel.Crop) handleRightPanelSelect(Panel.Adjustments); + else if (isFullScreen) handleToggleFullScreen(); + else if (selectedImage) handleBackToLibrary(); + }, + }, + { + match: (e) => { + if (osPlatform === 'macos') return e.code === 'Backspace'; + return e.code === 'Delete'; + }, + execute: (e) => { + e.preventDefault(); + if (activeMaskContainerId) handleDeleteMaskContainer(activeMaskContainerId); + else if (activeAiPatchContainerId) handleDeleteAiPatch(activeAiPatchContainerId); + }, + }, + { + match: (e) => !selectedImage && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code), + execute: (e) => { + e.preventDefault(); + const isNext = e.code === 'ArrowRight' || e.code === 'ArrowDown'; + const activePath = libraryActivePath; + if (!activePath || sortedImageList.length === 0) return; + const currentIndex = sortedImageList.findIndex((img: ImageFile) => img.path === activePath); + if (currentIndex === -1) return; + let nextIndex = isNext ? currentIndex + 1 : currentIndex - 1; + if (nextIndex >= sortedImageList.length) nextIndex = 0; + if (nextIndex < 0) nextIndex = sortedImageList.length - 1; + const nextImage = sortedImageList[nextIndex]; + if (nextImage) { + setLibraryActivePath(nextImage.path); + setMultiSelectedPaths([nextImage.path]); } - } else { - if (keyNum >= 0 && keyNum <= 5) { - handleRate(keyNum); - } - } - } else if (['0', '1', '2', '3', '4', '5'].includes(key) && !isCtrl) { - event.preventDefault(); - handleRate(parseInt(key, 10)); - } + }, + }, + ]; - if (key === '[' && !isCtrl && selectedImage) { - event.preventDefault(); - handleRotate(-90); - } - if (key === ']' && !isCtrl && selectedImage) { - event.preventDefault(); - handleRotate(90); - } + const handleKeyDown = (event: KeyboardEvent) => { + if (isModalOpen) return; - // On macOS the physical ⌫ key sends Backspace, not Delete. - // File deletion follows macOS convention: Cmd + Delete (i.e. Cmd + Backspace). - // Non-destructive mask/patch deletion uses plain Backspace on macOS. - // On all other platforms the existing plain Delete behaviour is preserved. - const isMacOS = osPlatform === 'macos'; - const isDeletePressed = isMacOS ? key === 'backspace' : key === 'delete'; + const isInputFocused = + document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA'; + if (isInputFocused) return; - if (isDeletePressed) { - event.preventDefault(); - if (activeMaskContainerId) { - handleDeleteMaskContainer(activeMaskContainerId); - } else if (activeAiPatchContainerId) { - handleDeleteAiPatch(activeAiPatchContainerId); - } else if (!isMacOS || isCtrl) { - // macOS: Cmd modifier required for (destructive) file deletion - // Other platforms: plain Delete triggers file deletion - handleDeleteSelected(); + for (const builtin of builtinShortcuts) { + if (builtin.match(event)) { + builtin.execute(event); + return; } } - if (isCtrl) { - const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; - const currentPercent = - originalSize && originalSize.width > 0 && displaySize && displaySize.width > 0 - ? (displaySize.width * dpr) / originalSize.width - : 1.0; - - switch (key) { - case 'c': - event.preventDefault(); - if (event.shiftKey) { - if (multiSelectedPaths.length > 0) { - setCopiedFilePaths(multiSelectedPaths); - } - } else { - handleCopyAdjustments(); - } - break; - case 'v': - event.preventDefault(); - if (event.shiftKey) { - handlePasteFiles('copy'); - } else { - handlePasteAdjustments(); - } - break; - case 'a': - event.preventDefault(); - if (sortedImageList.length > 0) { - setMultiSelectedPaths(sortedImageList.map((f: ImageFile) => f.path)); - if (!selectedImage) { - setLibraryActivePath(sortedImageList[sortedImageList.length - 1].path); - } - } - break; - case 'z': - if (selectedImage) { - event.preventDefault(); - undo(); - } - break; - case 'y': - if (selectedImage) { - event.preventDefault(); - redo(); - } - break; - case '0': - case ')': - event.preventDefault(); - handleZoomChange(0, true); // Fit to window - break; - case '1': - case '!': - event.preventDefault(); - handleZoomChange(1.0); // 100% - break; - case '=': - case '+': - event.preventDefault(); - handleZoomChange(Math.min(currentPercent * 1.2, 2.0)); - break; - case '-': - case '_': - event.preventDefault(); - handleZoomChange(Math.max(currentPercent / 1.2, 0.1)); - break; - case 'arrowup': - event.preventDefault(); - if (brushSettings && activeRightPanel === Panel.Masks) { - const newSize = Math.min((brushSettings.size || 50) + 10, 200); - setBrushSettings({ ...brushSettings, size: newSize }); - } - break; - case 'arrowdown': - event.preventDefault(); - if (brushSettings && activeRightPanel === Panel.Masks) { - const newSize = Math.max((brushSettings.size || 50) - 10, 1); - setBrushSettings({ ...brushSettings, size: newSize }); - } - break; - default: - break; + const normalized = normalizeCombo(event, osPlatform); + const action = comboMap.get(normalized.join('+')); + if (action) { + const handler = actions[action]; + if (handler && (!handler.shouldFire || handler.shouldFire())) { + handler.execute(event); + return; } } }; @@ -481,7 +515,7 @@ export const useKeyboardShortcuts = ({ handleZoomChange, isFullScreen, isStraightenActive, - isViewLoading, + keybinds, libraryActivePath, multiSelectedPaths, onSelectPatchContainer, @@ -502,7 +536,7 @@ export const useKeyboardShortcuts = ({ displaySize, baseRenderSize, originalSize, - brushSettings, - setBrushSettings, + brushSettings, + setBrushSettings, ]); }; diff --git a/src/utils/keyboardUtils.ts b/src/utils/keyboardUtils.ts new file mode 100644 index 000000000..37733a1e4 --- /dev/null +++ b/src/utils/keyboardUtils.ts @@ -0,0 +1,149 @@ +export interface KeybindDefinition { + action: string; + description: string; + defaultCombo: string[]; + section: 'library' | 'view' | 'rating' | 'panels' | 'editing'; +} + +export interface KeybindSection { + id: KeybindDefinition['section']; + label: string; +} + +export const KEYBIND_SECTIONS: KeybindSection[] = [ + { id: 'library', label: 'Library' }, + { id: 'editing', label: 'Editing' }, + { id: 'view', label: 'View' }, + { id: 'rating', label: 'Rating & Labels' }, + { id: 'panels', label: 'Panels' }, +]; + +export const KEYBIND_DEFINITIONS: KeybindDefinition[] = [ + { action: 'open_image', description: 'Open selected image', defaultCombo: ['Enter'], section: 'library' }, + { action: 'copy_files', description: 'Copy selected file(s)', defaultCombo: ['ctrl', 'shift', 'KeyC'], section: 'library' }, + { action: 'paste_files', description: 'Paste file(s) to current folder', defaultCombo: ['ctrl', 'shift', 'KeyV'], section: 'library' }, + { action: 'select_all', description: 'Select all images', defaultCombo: ['ctrl', 'KeyA'], section: 'library' }, + { action: 'delete_selected', description: 'Delete selected file(s)', defaultCombo: ['Delete'], section: 'library' }, + { action: 'preview_prev', description: 'Previous image', defaultCombo: ['ArrowLeft'], section: 'library' }, + { action: 'preview_next', description: 'Next image', defaultCombo: ['ArrowRight'], section: 'library' }, + { action: 'zoom_in_step', description: 'Zoom in (by step)', defaultCombo: ['ArrowUp'], section: 'view' }, + { action: 'zoom_out_step', description: 'Zoom out (by step)', defaultCombo: ['ArrowDown'], section: 'view' }, + { action: 'cycle_zoom', description: 'Cycle zoom (Fit, 2x Fit, 100%)', defaultCombo: ['Space'], section: 'view' }, + { action: 'zoom_in', description: 'Zoom in', defaultCombo: ['ctrl', 'Equal'], section: 'view' }, + { action: 'zoom_out', description: 'Zoom out', defaultCombo: ['ctrl', 'Minus'], section: 'view' }, + { action: 'zoom_fit', description: 'Zoom to fit', defaultCombo: ['ctrl', 'Digit0'], section: 'view' }, + { action: 'zoom_100', description: 'Zoom to 100%', defaultCombo: ['ctrl', 'Digit1'], section: 'view' }, + { action: 'toggle_fullscreen', description: 'Toggle fullscreen', defaultCombo: ['KeyF'], section: 'view' }, + { action: 'show_original', description: 'Show original (before/after)', defaultCombo: ['KeyB'], section: 'view' }, + { action: 'rate_0', description: 'Star rating: 0', defaultCombo: ['Digit0'], section: 'rating' }, + { action: 'rate_1', description: 'Star rating: 1', defaultCombo: ['Digit1'], section: 'rating' }, + { action: 'rate_2', description: 'Star rating: 2', defaultCombo: ['Digit2'], section: 'rating' }, + { action: 'rate_3', description: 'Star rating: 3', defaultCombo: ['Digit3'], section: 'rating' }, + { action: 'rate_4', description: 'Star rating: 4', defaultCombo: ['Digit4'], section: 'rating' }, + { action: 'rate_5', description: 'Star rating: 5', defaultCombo: ['Digit5'], section: 'rating' }, + { action: 'color_label_none', description: 'Color label: None', defaultCombo: ['shift', 'Digit0'], section: 'rating' }, + { action: 'color_label_red', description: 'Color label: Red', defaultCombo: ['shift', 'Digit1'], section: 'rating' }, + { action: 'color_label_yellow', description: 'Color label: Yellow', defaultCombo: ['shift', 'Digit2'], section: 'rating' }, + { action: 'color_label_green', description: 'Color label: Green', defaultCombo: ['shift', 'Digit3'], section: 'rating' }, + { action: 'color_label_blue', description: 'Color label: Blue', defaultCombo: ['shift', 'Digit4'], section: 'rating' }, + { action: 'color_label_purple', description: 'Color label: Purple', defaultCombo: ['shift', 'Digit5'], section: 'rating' }, + { action: 'toggle_adjustments', description: 'Toggle Adjustments panel', defaultCombo: ['KeyD'], section: 'panels' }, + { action: 'toggle_crop_panel', description: 'Toggle Crop panel', defaultCombo: ['KeyR'], section: 'panels' }, + { action: 'toggle_masks', description: 'Toggle Masks panel', defaultCombo: ['KeyM'], section: 'panels' }, + { action: 'toggle_ai', description: 'Toggle AI panel', defaultCombo: ['KeyK'], section: 'panels' }, + { action: 'toggle_presets', description: 'Toggle Presets panel', defaultCombo: ['KeyP'], section: 'panels' }, + { action: 'toggle_metadata', description: 'Toggle Metadata panel', defaultCombo: ['KeyI'], section: 'panels' }, + { action: 'toggle_analytics', description: 'Toggle Analytics display', defaultCombo: ['KeyA'], section: 'panels' }, + { action: 'toggle_export', description: 'Toggle Export panel', defaultCombo: ['KeyE'], section: 'panels' }, + { action: 'undo', description: 'Undo adjustment', defaultCombo: ['ctrl', 'KeyZ'], section: 'editing' }, + { action: 'redo', description: 'Redo adjustment', defaultCombo: ['ctrl', 'KeyY'], section: 'editing' }, + { action: 'copy_adjustments', description: 'Copy selected adjustments', defaultCombo: ['ctrl', 'KeyC'], section: 'editing' }, + { action: 'paste_adjustments', description: 'Paste copied adjustments', defaultCombo: ['ctrl', 'KeyV'], section: 'editing' }, + { action: 'rotate_left', description: 'Rotate 90° counter-clockwise', defaultCombo: ['BracketLeft'], section: 'editing' }, + { action: 'rotate_right', description: 'Rotate 90° clockwise', defaultCombo: ['BracketRight'], section: 'editing' }, + { action: 'toggle_crop', description: 'Toggle Crop / Straighten', defaultCombo: ['KeyS'], section: 'editing' }, + { action: 'brush_size_up', description: 'Increase brush size', defaultCombo: ['ctrl', 'ArrowUp'], section: 'editing' }, + { action: 'brush_size_down', description: 'Decrease brush size', defaultCombo: ['ctrl', 'ArrowDown'], section: 'editing' }, +]; + +const symMap: Record = { + Space: 'Space', + Backspace: '⌫', + Enter: 'Enter', + Delete: 'Delete', + ArrowUp: '↑', + ArrowDown: '↓', + ArrowLeft: '←', + ArrowRight: '→', + BracketLeft: '[', + BracketRight: ']', + Minus: '-', + Equal: '+', + Comma: ',', + Period: '.', + Slash: '/', + Semicolon: ';', + Quote: "'", + Backquote: '`', + Backslash: '\\', + Tab: 'Tab', + Escape: 'Esc', + PageUp: 'Page Up', + PageDown: 'Page Down', + Home: 'Home', + End: 'End', + Insert: 'Insert', + NumpadAdd: 'Numpad +', + NumpadMultiply: 'Numpad *', + NumpadDivide: 'Numpad /', + NumpadSubtract: 'Numpad -', + NumpadDecimal: 'Numpad .', + NumpadComma: 'Numpad ,', + NumpadEnter: 'Numpad Enter', + NumpadEqual: 'Numpad =', + CapsLock: 'Caps Lock', + PrintScreen: 'PrtSc', +}; + +export function normalizeCombo(event: KeyboardEvent, osPlatform?: string): string[] { + const isMacDelete = osPlatform === 'macos' && event.code === 'Backspace' && (event.ctrlKey || event.metaKey); + const parts: string[] = []; + if ((event.ctrlKey || event.metaKey) && !isMacDelete) parts.push('ctrl'); + if (event.shiftKey) parts.push('shift'); + if (event.altKey) parts.push('alt'); + const code = isMacDelete ? 'Delete' : event.code; + if (isValidShortcutKey(code)) { + parts.push(code); + } + return parts; +} + +export function codeToDisplayLabel(code: string): string | null { + if (/^Key[A-Z]$/.test(code) || /^Digit[0-9]$/.test(code)) { + return code[code.length - 1].toUpperCase(); + } + if (/^Numpad[0-9]$/.test(code)) { + return `Numpad ${code.slice(-1)}`; + } + return symMap[code] ?? null; +} + +export function isValidShortcutKey(code: string): boolean { + if (code.startsWith('Key') || code.startsWith('Digit')) return true; + if (code.startsWith('F') && /^\d+$/.test(code.slice(1))) return true; + if (/^Numpad[0-9]$/.test(code)) return true; + return code in symMap; +} + +export function formatKeyCode(key: string, osPlatform: string): string { + if (key === 'ctrl') return osPlatform === 'macos' ? '⌘' : 'Ctrl'; + if (key === 'shift') return 'Shift'; + if (key === 'alt') return osPlatform === 'macos' ? '⌥' : 'Alt'; + if (key === 'Delete' && osPlatform === 'macos') return 'Delete / ⌘+⌫'; + const label = codeToDisplayLabel(key); + return label || key; +} + +export function arraysEqual(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]); +}