From f063838b5fc4712eec46ba64e0db1356d7adbb48 Mon Sep 17 00:00:00 2001 From: sefalkner Date: Wed, 22 Apr 2026 21:14:58 +0200 Subject: [PATCH 01/17] Add data layer --- src-tauri/src/file_management.rs | 8 ++++ src/components/ui/AppProperties.tsx | 59 +++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 51baeaedc..fbf464486 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -457,9 +457,13 @@ pub struct AppSettings { #[serde(default)] pub use_wgpu_renderer: Option, #[serde(default)] +<<<<<<< HEAD pub canvas_input_mode: Option, #[serde(default)] pub zoom_speed_multiplier: Option, +======= + pub keybindings: HashMap>, +>>>>>>> 25a8ddcc (Add data layer) } fn default_adjustment_visibility() -> HashMap { @@ -529,8 +533,12 @@ impl Default for AppSettings { use_wgpu_renderer: Some(false), #[cfg(not(any(target_os = "linux", target_os = "android")))] use_wgpu_renderer: Some(true), +<<<<<<< HEAD canvas_input_mode: Some("mouse".to_string()), zoom_speed_multiplier: Some(1.0), +======= + keybindings: HashMap::new(), +>>>>>>> 25a8ddcc (Add data layer) } } } diff --git a/src/components/ui/AppProperties.tsx b/src/components/ui/AppProperties.tsx index c776e5777..7dbf4b113 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; + keybindings?: { [actionKey: string]: string[] }; } export interface BrushSettings { @@ -333,3 +334,61 @@ export interface CullingSuggestions { blurryImages: ImageAnalysisResult[]; failedPaths: string[]; } + +export interface KeybindingDefinition { + actionKey: string; + description: string; + defaultCombos: string[][]; + section: 'general' | 'editor'; +} + +export interface ActionHandler { + shouldFire?: () => boolean; + execute: (event: KeyboardEvent) => void; +} + +export const KEYBINDING_DEFINITIONS: KeybindingDefinition[] = [ + { actionKey: 'open_image', description: 'Open selected image', defaultCombos: [['enter'], ['space']], section: 'general' }, + { actionKey: 'copy_adjustments', description: 'Copy selected adjustments', defaultCombos: [['ctrl', 'c']], section: 'general' }, + { actionKey: 'paste_adjustments', description: 'Paste copied adjustments', defaultCombos: [['ctrl', 'v']], section: 'general' }, + { actionKey: 'copy_files', description: 'Copy selected file(s)', defaultCombos: [['ctrl', 'shift', 'c']], section: 'general' }, + { actionKey: 'paste_files', description: 'Paste file(s) to current folder', defaultCombos: [['ctrl', 'shift', 'v']], section: 'general' }, + { actionKey: 'select_all', description: 'Select all images', defaultCombos: [['ctrl', 'a']], section: 'general' }, + { actionKey: 'delete_selected', description: 'Delete selected file(s)', defaultCombos: [['delete']], section: 'general' }, + { actionKey: 'prev_next', description: 'Previous / Next image', defaultCombos: [['arrowleft'], ['arrowright']], section: 'editor' }, + { actionKey: 'zoom_arrow', description: 'Zoom in / Zoom out (by step)', defaultCombos: [['arrowup'], ['arrowdown']], section: 'editor' }, + { actionKey: 'cycle_zoom', description: 'Cycle zoom (Fit, 2x Fit, 100%)', defaultCombos: [['space']], section: 'editor' }, + { actionKey: 'zoom_in', description: 'Zoom in', defaultCombos: [['ctrl', '='], ['ctrl', '+']], section: 'editor' }, + { actionKey: 'zoom_out', description: 'Zoom out', defaultCombos: [['ctrl', '-']], section: 'editor' }, + { actionKey: 'zoom_fit', description: 'Zoom to fit', defaultCombos: [['ctrl', '0']], section: 'editor' }, + { actionKey: 'zoom_100', description: 'Zoom to 100%', defaultCombos: [['ctrl', '1']], section: 'editor' }, + { actionKey: 'rotate_left', description: 'Rotate 90° counter-clockwise', defaultCombos: [['[']], section: 'editor' }, + { actionKey: 'rotate_right', description: 'Rotate 90° clockwise', defaultCombos: [[']']], section: 'editor' }, + { actionKey: 'undo', description: 'Undo adjustment', defaultCombos: [['ctrl', 'z']], section: 'editor' }, + { actionKey: 'redo', description: 'Redo adjustment', defaultCombos: [['ctrl', 'y']], section: 'editor' }, + { actionKey: 'toggle_fullscreen', description: 'Toggle fullscreen', defaultCombos: [['f']], section: 'editor' }, + { actionKey: 'show_original', description: 'Show original (before/after)', defaultCombos: [['b']], section: 'editor' }, + { actionKey: 'toggle_crop', description: 'Straighten Image', defaultCombos: [['s']], section: 'editor' }, + { actionKey: 'toggle_adjustments', description: 'Toggle Adjustments panel', defaultCombos: [['d']], section: 'editor' }, + { actionKey: 'toggle_crop_panel', description: 'Toggle Crop panel', defaultCombos: [['r']], section: 'editor' }, + { actionKey: 'toggle_masks', description: 'Toggle Masks panel', defaultCombos: [['m']], section: 'editor' }, + { actionKey: 'toggle_ai', description: 'Toggle AI panel', defaultCombos: [['k']], section: 'editor' }, + { actionKey: 'toggle_presets', description: 'Toggle Presets panel', defaultCombos: [['p']], section: 'editor' }, + { actionKey: 'toggle_metadata', description: 'Toggle Metadata panel', defaultCombos: [['i']], section: 'editor' }, + { actionKey: 'toggle_analytics', description: 'Toggle Analytics display', defaultCombos: [['a']], section: 'editor' }, + { actionKey: 'toggle_export', description: 'Toggle Export panel', defaultCombos: [['e']], section: 'editor' }, + { actionKey: 'rate_0', description: 'Set star rating: 0', defaultCombos: [['0']], section: 'editor' }, + { actionKey: 'rate_1', description: 'Set star rating: 1', defaultCombos: [['1']], section: 'editor' }, + { actionKey: 'rate_2', description: 'Set star rating: 2', defaultCombos: [['2']], section: 'editor' }, + { actionKey: 'rate_3', description: 'Set star rating: 3', defaultCombos: [['3']], section: 'editor' }, + { actionKey: 'rate_4', description: 'Set star rating: 4', defaultCombos: [['4']], section: 'editor' }, + { actionKey: 'rate_5', description: 'Set star rating: 5', defaultCombos: [['5']], section: 'editor' }, + { actionKey: 'color_label_none', description: 'Clear color label', defaultCombos: [['shift', '0']], section: 'editor' }, + { actionKey: 'color_label_red', description: 'Set red color label', defaultCombos: [['shift', '1']], section: 'editor' }, + { actionKey: 'color_label_yellow', description: 'Set yellow color label', defaultCombos: [['shift', '2']], section: 'editor' }, + { actionKey: 'color_label_green', description: 'Set green color label', defaultCombos: [['shift', '3']], section: 'editor' }, + { actionKey: 'color_label_blue', description: 'Set blue color label', defaultCombos: [['shift', '4']], section: 'editor' }, + { actionKey: 'color_label_purple', description: 'Set purple color label', defaultCombos: [['shift', '5']], section: 'editor' }, + { actionKey: 'brush_size_up', description: 'Increase brush size', defaultCombos: [['ctrl', 'arrowup']], section: 'editor' }, + { actionKey: 'brush_size_down', description: 'Decrease brush size', defaultCombos: [['ctrl', 'arrowdown']], section: 'editor' }, +]; From 1651ebc8e7c57bad228e00730f9d8079593e91e8 Mon Sep 17 00:00:00 2001 From: sefalkner Date: Wed, 22 Apr 2026 21:33:36 +0200 Subject: [PATCH 02/17] Add keybind parsers --- src/App.tsx | 1 - src/hooks/useKeyboardShortcuts.tsx | 46 ++++++++++++++++++++++++------ 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1fee88963..6066e9f18 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3193,7 +3193,6 @@ function App() { handleZoomChange, isFullScreen, isStraightenActive, - isViewLoading, libraryActivePath, multiSelectedPaths, redo, diff --git a/src/hooks/useKeyboardShortcuts.tsx b/src/hooks/useKeyboardShortcuts.tsx index 606c58204..d4960d778 100644 --- a/src/hooks/useKeyboardShortcuts.tsx +++ b/src/hooks/useKeyboardShortcuts.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { ImageFile, Panel, SelectedImage } from '../components/ui/AppProperties'; +import { ActionHandler, ImageFile, KeybindingDefinition, Panel, SelectedImage, KEYBINDING_DEFINITIONS } from '../components/ui/AppProperties'; import { BrushSettings } from '../components/ui/AppProperties'; interface KeyboardShortcutsProps { @@ -30,7 +30,7 @@ interface KeyboardShortcutsProps { isFullScreen: boolean; isModalOpen: boolean; isStraightenActive: boolean; - isViewLoading: boolean; + keybindings?: { [actionKey: string]: string[] }; libraryActivePath: string | null; multiSelectedPaths: Array; onSelectPatchContainer?(container: string | null): void; @@ -51,8 +51,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 +83,7 @@ export const useKeyboardShortcuts = ({ isFullScreen, isModalOpen, isStraightenActive, - isViewLoading, + keybindings, libraryActivePath, multiSelectedPaths, onSelectPatchContainer, @@ -104,10 +104,40 @@ export const useKeyboardShortcuts = ({ displaySize, baseRenderSize, originalSize, - brushSettings, - setBrushSettings, + brushSettings, + setBrushSettings, }: KeyboardShortcutsProps) => { useEffect(() => { + function arraysEqual(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]); + } + + function normalizeCombo(event: KeyboardEvent): string[] { + const parts: string[] = []; + if (event.ctrlKey || event.metaKey) parts.push('ctrl'); + if (event.shiftKey) parts.push('shift'); + if (event.altKey) parts.push('alt'); + if (event.code === 'BracketLeft') parts.push('['); + else if (event.code === 'BracketRight') parts.push(']'); + else { + const k = event.key.toLowerCase(); + if (!['ctrl', 'shift', 'alt', 'meta'].includes(k)) + parts.push(k); + } + return parts; + } + + function getEffectiveCombos(def: KeybindingDefinition): string[][] { + if (def.actionKey === 'delete_selected') { + if (osPlatform === 'macos' && !keybindings?.[def.actionKey]) { + return [['ctrl', 'backspace']]; + } + } + const userCombo = keybindings?.[def.actionKey]; + if (userCombo) return [userCombo]; + return def.defaultCombos; + } + const handleKeyDown = (event: any) => { if (isModalOpen) { return; @@ -481,7 +511,7 @@ export const useKeyboardShortcuts = ({ handleZoomChange, isFullScreen, isStraightenActive, - isViewLoading, + keybindings, libraryActivePath, multiSelectedPaths, onSelectPatchContainer, From d17c25b73723ba1c1312deb3c27cd298fcb87617 Mon Sep 17 00:00:00 2001 From: sefalkner Date: Wed, 22 Apr 2026 21:35:33 +0200 Subject: [PATCH 03/17] Split preview navigation keybinds --- src/components/ui/AppProperties.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/ui/AppProperties.tsx b/src/components/ui/AppProperties.tsx index 7dbf4b113..4edd68840 100644 --- a/src/components/ui/AppProperties.tsx +++ b/src/components/ui/AppProperties.tsx @@ -355,7 +355,8 @@ export const KEYBINDING_DEFINITIONS: KeybindingDefinition[] = [ { actionKey: 'paste_files', description: 'Paste file(s) to current folder', defaultCombos: [['ctrl', 'shift', 'v']], section: 'general' }, { actionKey: 'select_all', description: 'Select all images', defaultCombos: [['ctrl', 'a']], section: 'general' }, { actionKey: 'delete_selected', description: 'Delete selected file(s)', defaultCombos: [['delete']], section: 'general' }, - { actionKey: 'prev_next', description: 'Previous / Next image', defaultCombos: [['arrowleft'], ['arrowright']], section: 'editor' }, + { actionKey: 'preview_prev', description: 'Previous image', defaultCombos: [['arrowleft']], section: 'editor' }, + { actionKey: 'preview_next', description: 'Next image', defaultCombos: [['arrowright']], section: 'editor' }, { actionKey: 'zoom_arrow', description: 'Zoom in / Zoom out (by step)', defaultCombos: [['arrowup'], ['arrowdown']], section: 'editor' }, { actionKey: 'cycle_zoom', description: 'Cycle zoom (Fit, 2x Fit, 100%)', defaultCombos: [['space']], section: 'editor' }, { actionKey: 'zoom_in', description: 'Zoom in', defaultCombos: [['ctrl', '='], ['ctrl', '+']], section: 'editor' }, From b1e9f900043229638902477980bee7ec9643bf8b Mon Sep 17 00:00:00 2001 From: sefalkner Date: Wed, 22 Apr 2026 22:03:50 +0200 Subject: [PATCH 04/17] Refactor handleKeyDown for keybinds, reduce nested if/else --- src/hooks/useKeyboardShortcuts.tsx | 577 +++++++++++++++-------------- 1 file changed, 292 insertions(+), 285 deletions(-) diff --git a/src/hooks/useKeyboardShortcuts.tsx b/src/hooks/useKeyboardShortcuts.tsx index d4960d778..09beb6ff3 100644 --- a/src/hooks/useKeyboardShortcuts.tsx +++ b/src/hooks/useKeyboardShortcuts.tsx @@ -138,131 +138,201 @@ export const useKeyboardShortcuts = ({ return def.defaultCombos; } - const handleKeyDown = (event: any) => { - if (isModalOpen) { - return; - } - - const isInputFocused = - document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA'; - if (isInputFocused) { - return; - } - const isCtrl = event.ctrlKey || event.metaKey; - const isShift = event.shiftKey; - const key = event.key.toLowerCase(); - const code = event.code; - - if (selectedImage) { - if (key === 'escape') { + 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_arrow: { + 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; + const isZoomIn = event.key === 'ArrowUp'; + const step = 0.1; + const newPercent = isZoomIn ? currentPercent + step : currentPercent - step; + handleZoomChange(Math.max(0.1, Math.min(newPercent, 2.0))); + }, + }, + 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) { - 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) { + }, + }, + zoom_in: { + shouldFire: () => !!selectedImage, + execute: (event) => { 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); @@ -270,213 +340,150 @@ 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; + const newSize = Math.min((brushSettings.size || 50) + 10, 200); + setBrushSettings({ ...brushSettings, size: newSize }); + }, + }, + brush_size_down: { + shouldFire: () => !!selectedImage && !!brushSettings && activeRightPanel === Panel.Masks, + execute: (event) => { + event.preventDefault(); + const newSize = Math.max((brushSettings.size || 50) - 10, 1); + setBrushSettings({ ...brushSettings, size: newSize }); + }, + }, + }; - // 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]); - } - } - } + const handleKeyDown = (event: any) => { + if (isModalOpen) { + return; } - if (code.startsWith('Digit') && !isCtrl) { - event.preventDefault(); - const keyNum = parseInt(code.replace('Digit', ''), 10); - - if (isShift) { - if (keyNum === 0) { - handleSetColorLabel(null); - } else if (keyNum >= 1 && keyNum <= 5) { - const colors = ['red', 'yellow', 'green', 'blue', 'purple']; - handleSetColorLabel(colors[keyNum - 1]); - } - } 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)); + const isInputFocused = + document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA'; + if (isInputFocused) { + return; } - if (key === '[' && !isCtrl && selectedImage) { - event.preventDefault(); - handleRotate(-90); - } - if (key === ']' && !isCtrl && selectedImage) { + // Escape cascade for now still here + if (event.key === 'escape') { event.preventDefault(); - handleRotate(90); + 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(); + } + 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 isDeletePressed = isMacOS ? event.key === 'backspace' : event.key === 'delete'; 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(); } + 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; + const normalized = normalizeCombo(event); + for (const def of KEYBINDING_DEFINITIONS) { + const effectiveCombos = getEffectiveCombos(def); + const matched = effectiveCombos.some((combo) => arraysEqual(combo, normalized)); + if (matched) { + const handler = actions[def.actionKey]; + if (handler && (!handler.shouldFire || handler.shouldFire())) { + handler.execute(event); + return; + } + } + } - 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; + // Library arrow navigation + if (!selectedImage && ['arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(event.key.toLowerCase())) { + event.preventDefault(); + const isNext = event.key === 'ArrowRight' || event.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]); } } }; From 8e0b695160b589295bda29541a16a1d198971ddf Mon Sep 17 00:00:00 2001 From: sefalkner Date: Thu, 23 Apr 2026 18:21:07 +0200 Subject: [PATCH 05/17] Initial UI implementation --- src/components/panel/SettingsPanel.tsx | 265 ++++++++++++++----------- src/hooks/useKeyboardShortcuts.tsx | 6 +- 2 files changed, 148 insertions(+), 123 deletions(-) diff --git a/src/components/panel/SettingsPanel.tsx b/src/components/panel/SettingsPanel.tsx index a78d00ec4..33e35ff82 100644 --- a/src/components/panel/SettingsPanel.tsx +++ b/src/components/panel/SettingsPanel.tsx @@ -31,7 +31,7 @@ import Switch from '../ui/Switch'; 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 { Invokes, KEYBINDING_DEFINITIONS, KeybindingDefinition } from '../ui/AppProperties'; import Text from '../ui/Text'; import { TextColors, TextVariants, TextWeights } from '../../types/typography'; import { useOsPlatform } from '../../hooks/useOsPlatform'; @@ -56,9 +56,12 @@ interface DataActionItemProps { title: string; } -interface KeybindItemProps { - description: string; - keys: Array; +interface KeybindRowProps { + def: KeybindingDefinition; + currentCombos: string[][]; + onSave: (actionKey: string, combo: string[]) => void; + recordingAction: string | null; + onStartRecording: (actionKey: string) => void; } interface SettingItemProps { @@ -147,25 +150,92 @@ const settingCategories = [ { id: 'shortcuts', label: 'Shortcuts', icon: Keyboard }, ]; -const KeybindItem = ({ keys, description }: KeybindItemProps) => ( -
- {description} -
- {keys.map((key: string, index: number) => ( - { + const map: Record = { + ctrl: 'Ctrl', shift: 'Shift', alt: 'Alt', space: 'Space', + arrowup: '↑', arrowdown: '↓', arrowleft: '←', arrowright: '→', + backspace: '⌫', enter: 'Enter', delete: 'Delete', + }; + return map[key] || (key.length === 1 ? key.toUpperCase() : key); +}; + +const formatCombo = (combo: string[]): string => { + return combo.map(formatKey).join(' + '); +}; + +const KeybindRow = ({ def, currentCombos, onSave, recordingAction, onStartRecording }: KeybindRowProps) => { + const recording = recordingAction === def.actionKey; + + useEffect(() => { + if (!recording) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onStartRecording(''); + return; + } + e.preventDefault(); + const parts: string[] = []; + if (e.ctrlKey || e.metaKey) parts.push('ctrl'); + if (e.shiftKey) parts.push('shift'); + if (e.altKey) parts.push('alt'); + + let nonModifier = false; + if (e.code === 'BracketLeft') { parts.push('['); nonModifier = true; } + else if (e.code === 'BracketRight') { parts.push(']'); nonModifier = true; } + else { + const k = e.key === ' ' ? 'space' : e.key.toLowerCase(); + if (!['ctrl', 'shift', 'alt', 'meta', 'control', ' '].includes(k)) { + parts.push(k); + nonModifier = true; + } + } + + if (nonModifier) { + onSave(def.actionKey, parts); + onStartRecording(''); + } + }; + window.addEventListener('keydown', handler, { capture: true }); + return () => window.removeEventListener('keydown', handler, { capture: true }); + }, [recording, def.actionKey, onSave, onStartRecording]); + + const displayCombos = currentCombos.length > 0 ? currentCombos : def.defaultCombos; + + return ( +
+ {def.description} +
-
-); + ); +}; const SettingItem = ({ children, description, label }: SettingItemProps) => (
@@ -369,6 +439,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 +795,11 @@ export default function SettingsPanel({ } }; + const handleKeybindingSave = (actionKey: string, combo: string[]) => { + const newKeybindings = { ...(appSettings?.keybindings || {}), [actionKey]: combo }; + onSettingsChange({ ...appSettings, keybindings: newKeybindings }); + }; + return ( <> @@ -1843,107 +1919,54 @@ export default function SettingsPanel({ )} - {activeCategory === 'shortcuts' && ( - -
- - Keyboard Shortcuts - -
-
- General -
- - - - - - - - - - -
-
-
- Editor -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- -
- Mouse Controls -
- - - - - - -
-
- -
- Trackpad Controls -
- - - - + {activeCategory === 'shortcuts' && ( + +
+ + Keyboard Shortcuts + +
+ {(['general', 'editor'] as const).map((section) => { + const sectionDefs = KEYBINDING_DEFINITIONS.filter((d) => d.section === section); + const sectionLabel = section === 'general' ? 'General' : 'Editor'; + const userKb = appSettings?.keybindings || {}; + return ( +
+ {sectionLabel} +
+ {sectionDefs.map((def) => ( + + ))} +
+
+ ); + })} +
+
-
-
-
- - )} +
+
+ + )}
diff --git a/src/hooks/useKeyboardShortcuts.tsx b/src/hooks/useKeyboardShortcuts.tsx index 09beb6ff3..ac8320280 100644 --- a/src/hooks/useKeyboardShortcuts.tsx +++ b/src/hooks/useKeyboardShortcuts.tsx @@ -394,16 +394,18 @@ export const useKeyboardShortcuts = ({ shouldFire: () => !!selectedImage && !!brushSettings && activeRightPanel === Panel.Masks, execute: (event) => { event.preventDefault(); + if (!brushSettings) return; const newSize = Math.min((brushSettings.size || 50) + 10, 200); - setBrushSettings({ ...brushSettings, size: newSize }); + 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({ ...brushSettings, size: newSize }); + setBrushSettings({ feather: brushSettings.feather, size: newSize, tool: brushSettings.tool }); }, }, }; From b20bdab2a1592ecf5e5d244d82162d444c02040e Mon Sep 17 00:00:00 2001 From: sefalkner Date: Thu, 23 Apr 2026 18:39:57 +0200 Subject: [PATCH 06/17] Pass keybind settings to useKeyboardShortcuts --- src/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/App.tsx b/src/App.tsx index 6066e9f18..4dccf4f8d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3212,6 +3212,7 @@ function App() { displaySize, baseRenderSize, originalSize, + keybindings: appSettings?.keybindings, brushSettings: brushSettings, setBrushSettings: setBrushSettings, }); From b9162aa9871589cfc5497bf5454ac67d9e9df4ad Mon Sep 17 00:00:00 2001 From: sefalkner Date: Thu, 23 Apr 2026 19:16:23 +0200 Subject: [PATCH 07/17] Switch to single keybind per task (for now) --- src/components/panel/SettingsPanel.tsx | 82 +++++++++++------------ src/components/ui/AppProperties.tsx | 91 +++++++++++++------------- src/hooks/useKeyboardShortcuts.tsx | 32 +++++---- 3 files changed, 104 insertions(+), 101 deletions(-) diff --git a/src/components/panel/SettingsPanel.tsx b/src/components/panel/SettingsPanel.tsx index 33e35ff82..7c65971c5 100644 --- a/src/components/panel/SettingsPanel.tsx +++ b/src/components/panel/SettingsPanel.tsx @@ -58,7 +58,7 @@ interface DataActionItemProps { interface KeybindRowProps { def: KeybindingDefinition; - currentCombos: string[][]; + currentCombo?: string[]; onSave: (actionKey: string, combo: string[]) => void; recordingAction: string | null; onStartRecording: (actionKey: string) => void; @@ -159,12 +159,9 @@ const formatKey = (key: string): string => { return map[key] || (key.length === 1 ? key.toUpperCase() : key); }; -const formatCombo = (combo: string[]): string => { - return combo.map(formatKey).join(' + '); -}; -const KeybindRow = ({ def, currentCombos, onSave, recordingAction, onStartRecording }: KeybindRowProps) => { - const recording = recordingAction === def.actionKey; +const KeybindRow = ({ def, currentCombo, onSave, recordingAction, onStartRecording }: KeybindRowProps) => { + const recording = recordingAction === def.actionKey; useEffect(() => { if (!recording) return; @@ -199,43 +196,40 @@ const KeybindRow = ({ def, currentCombos, onSave, recordingAction, onStartRecord return () => window.removeEventListener('keydown', handler, { capture: true }); }, [recording, def.actionKey, onSave, onStartRecording]); - const displayCombos = currentCombos.length > 0 ? currentCombos : def.defaultCombos; - - return ( -
- {def.description} - -
- ); -}; + const displayCombo = currentCombo ?? def.defaultCombo; + + return ( +
+ {def.description} + +
+ ); + }; const SettingItem = ({ children, description, label }: SettingItemProps) => (
@@ -1945,7 +1939,7 @@ export default function SettingsPanel({ = { @@ -198,7 +198,7 @@ export const useKeyboardShortcuts = ({ handleImageSelect(sortedImageList[nextIndex].path); }, }, - zoom_arrow: { + zoom_in_step: { shouldFire: () => !!selectedImage, execute: (event) => { event.preventDefault(); @@ -207,10 +207,19 @@ export const useKeyboardShortcuts = ({ originalSize && originalSize.width > 0 && displaySize && displaySize.width > 0 ? (displaySize.width * dpr) / originalSize.width : 1.0; - const isZoomIn = event.key === 'ArrowUp'; - const step = 0.1; - const newPercent = isZoomIn ? currentPercent + step : currentPercent - step; - handleZoomChange(Math.max(0.1, Math.min(newPercent, 2.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: { @@ -460,9 +469,8 @@ export const useKeyboardShortcuts = ({ const normalized = normalizeCombo(event); for (const def of KEYBINDING_DEFINITIONS) { - const effectiveCombos = getEffectiveCombos(def); - const matched = effectiveCombos.some((combo) => arraysEqual(combo, normalized)); - if (matched) { + const effectiveCombo = getEffectiveCombo(def); + if (arraysEqual(effectiveCombo, normalized)) { const handler = actions[def.actionKey]; if (handler && (!handler.shouldFire || handler.shouldFire())) { handler.execute(event); From b38ca1fc22501d203247112965d40470343bd4bb Mon Sep 17 00:00:00 2001 From: sefalkner Date: Fri, 24 Apr 2026 13:42:41 +0200 Subject: [PATCH 08/17] Use key code to improve localization --- src/components/panel/SettingsPanel.tsx | 120 +++++++++++-------------- src/components/ui/AppProperties.tsx | 90 +++++++++---------- src/hooks/useKeyboardShortcuts.tsx | 18 +--- src/utils/keyboardUtils.ts | 57 ++++++++++++ 4 files changed, 158 insertions(+), 127 deletions(-) create mode 100644 src/utils/keyboardUtils.ts diff --git a/src/components/panel/SettingsPanel.tsx b/src/components/panel/SettingsPanel.tsx index 7c65971c5..76bc88328 100644 --- a/src/components/panel/SettingsPanel.tsx +++ b/src/components/panel/SettingsPanel.tsx @@ -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, KEYBINDING_DEFINITIONS, KeybindingDefinition } from '../ui/AppProperties'; +import { codeToDisplayLabel, normalizeCombo } from '../../utils/keyboardUtils'; import Text from '../ui/Text'; import { TextColors, TextVariants, TextWeights } from '../../types/typography'; import { useOsPlatform } from '../../hooks/useOsPlatform'; @@ -59,6 +60,7 @@ interface DataActionItemProps { interface KeybindRowProps { def: KeybindingDefinition; currentCombo?: string[]; + osPlatform: string; onSave: (actionKey: string, combo: string[]) => void; recordingAction: string | null; onStartRecording: (actionKey: string) => void; @@ -150,17 +152,17 @@ const settingCategories = [ { id: 'shortcuts', label: 'Shortcuts', icon: Keyboard }, ]; -const formatKey = (key: string): string => { - const map: Record = { - ctrl: 'Ctrl', shift: 'Shift', alt: 'Alt', space: 'Space', - arrowup: '↑', arrowdown: '↓', arrowleft: '←', arrowright: '→', - backspace: '⌫', enter: 'Enter', delete: 'Delete', - }; - return map[key] || (key.length === 1 ? key.toUpperCase() : key); +const formatKey = (key: string, osPlatform: string): string => { + if (key === 'ctrl') return osPlatform === 'macos' ? '⌘' : 'Ctrl'; + if (key === 'shift') return 'Shift'; + if (key === 'alt') return osPlatform === 'macos' ? '⌥' : 'Alt'; + + const label = codeToDisplayLabel(key); + return label || key; }; -const KeybindRow = ({ def, currentCombo, onSave, recordingAction, onStartRecording }: KeybindRowProps) => { +const KeybindRow = ({ def, currentCombo, osPlatform, onSave, recordingAction, onStartRecording }: KeybindRowProps) => { const recording = recordingAction === def.actionKey; useEffect(() => { @@ -171,23 +173,8 @@ const KeybindRow = ({ def, currentCombo, onSave, recordingAction, onStartRecordi return; } e.preventDefault(); - const parts: string[] = []; - if (e.ctrlKey || e.metaKey) parts.push('ctrl'); - if (e.shiftKey) parts.push('shift'); - if (e.altKey) parts.push('alt'); - - let nonModifier = false; - if (e.code === 'BracketLeft') { parts.push('['); nonModifier = true; } - else if (e.code === 'BracketRight') { parts.push(']'); nonModifier = true; } - else { - const k = e.key === ' ' ? 'space' : e.key.toLowerCase(); - if (!['ctrl', 'shift', 'alt', 'meta', 'control', ' '].includes(k)) { - parts.push(k); - nonModifier = true; - } - } - - if (nonModifier) { + const parts = normalizeCombo(e); + if (parts.length > 0 && !['ctrl', 'shift', 'alt'].includes(parts[parts.length - 1])) { onSave(def.actionKey, parts); onStartRecording(''); } @@ -196,40 +183,40 @@ const KeybindRow = ({ def, currentCombo, onSave, recordingAction, onStartRecordi return () => window.removeEventListener('keydown', handler, { capture: true }); }, [recording, def.actionKey, onSave, onStartRecording]); - const displayCombo = currentCombo ?? def.defaultCombo; - - return ( -
- {def.description} - -
- ); - }; + const displayCombo = currentCombo ?? def.defaultCombo; + + return ( +
+ {def.description} + +
+ ); + }; const SettingItem = ({ children, description, label }: SettingItemProps) => (
@@ -1937,13 +1924,14 @@ export default function SettingsPanel({
{sectionDefs.map((def) => ( + key={def.actionKey} + def={def} + currentCombo={userKb[def.actionKey]} + osPlatform={osPlatform} + onSave={handleKeybindingSave} + recordingAction={recordingAction} + onStartRecording={setRecordingAction} + /> ))}
diff --git a/src/components/ui/AppProperties.tsx b/src/components/ui/AppProperties.tsx index 0e654a6eb..dbfbf096d 100644 --- a/src/components/ui/AppProperties.tsx +++ b/src/components/ui/AppProperties.tsx @@ -348,49 +348,49 @@ export interface ActionHandler { } export const KEYBINDING_DEFINITIONS: KeybindingDefinition[] = [ - { actionKey: 'open_image', description: 'Open selected image', defaultCombo: ['enter'], section: 'general' }, - { actionKey: 'copy_adjustments', description: 'Copy selected adjustments', defaultCombo: ['ctrl', 'c'], section: 'general' }, - { actionKey: 'paste_adjustments', description: 'Paste copied adjustments', defaultCombo: ['ctrl', 'v'], section: 'general' }, - { actionKey: 'copy_files', description: 'Copy selected file(s)', defaultCombo: ['ctrl', 'shift', 'c'], section: 'general' }, - { actionKey: 'paste_files', description: 'Paste file(s) to current folder', defaultCombo: ['ctrl', 'shift', 'v'], section: 'general' }, - { actionKey: 'select_all', description: 'Select all images', defaultCombo: ['ctrl', 'a'], section: 'general' }, - { actionKey: 'delete_selected', description: 'Delete selected file(s)', defaultCombo: ['delete'], section: 'general' }, - { actionKey: 'preview_prev', description: 'Previous image', defaultCombo: ['arrowleft'], section: 'editor' }, - { actionKey: 'preview_next', description: 'Next image', defaultCombo: ['arrowright'], section: 'editor' }, - { actionKey: 'zoom_in_step', description: 'Zoom in (by step)', defaultCombo: ['arrowup'], section: 'editor' }, - { actionKey: 'zoom_out_step', description: 'Zoom out (by step)', defaultCombo: ['arrowdown'], section: 'editor' }, - { actionKey: 'cycle_zoom', description: 'Cycle zoom (Fit, 2x Fit, 100%)', defaultCombo: ['space'], section: 'editor' }, - { actionKey: 'zoom_in', description: 'Zoom in', defaultCombo: ['ctrl', '+'], section: 'editor' }, - { actionKey: 'zoom_out', description: 'Zoom out', defaultCombo: ['ctrl', '-'], section: 'editor' }, - { actionKey: 'zoom_fit', description: 'Zoom to fit', defaultCombo: ['ctrl', '0'], section: 'editor' }, - { actionKey: 'zoom_100', description: 'Zoom to 100%', defaultCombo: ['ctrl', '1'], section: 'editor' }, - { actionKey: 'rotate_left', description: 'Rotate 90° counter-clockwise', defaultCombo: ['['], section: 'editor' }, - { actionKey: 'rotate_right', description: 'Rotate 90° clockwise', defaultCombo: [']'], section: 'editor' }, - { actionKey: 'undo', description: 'Undo adjustment', defaultCombo: ['ctrl', 'z'], section: 'editor' }, - { actionKey: 'redo', description: 'Redo adjustment', defaultCombo: ['ctrl', 'y'], section: 'editor' }, - { actionKey: 'toggle_fullscreen', description: 'Toggle fullscreen', defaultCombo: ['f'], section: 'editor' }, - { actionKey: 'show_original', description: 'Show original (before/after)', defaultCombo: ['b'], section: 'editor' }, - { actionKey: 'toggle_crop', description: 'Straighten Image', defaultCombo: ['s'], section: 'editor' }, - { actionKey: 'toggle_adjustments', description: 'Toggle Adjustments panel', defaultCombo: ['d'], section: 'editor' }, - { actionKey: 'toggle_crop_panel', description: 'Toggle Crop panel', defaultCombo: ['r'], section: 'editor' }, - { actionKey: 'toggle_masks', description: 'Toggle Masks panel', defaultCombo: ['m'], section: 'editor' }, - { actionKey: 'toggle_ai', description: 'Toggle AI panel', defaultCombo: ['k'], section: 'editor' }, - { actionKey: 'toggle_presets', description: 'Toggle Presets panel', defaultCombo: ['p'], section: 'editor' }, - { actionKey: 'toggle_metadata', description: 'Toggle Metadata panel', defaultCombo: ['i'], section: 'editor' }, - { actionKey: 'toggle_analytics', description: 'Toggle Analytics display', defaultCombo: ['a'], section: 'editor' }, - { actionKey: 'toggle_export', description: 'Toggle Export panel', defaultCombo: ['e'], section: 'editor' }, - { actionKey: 'rate_0', description: 'Set star rating: 0', defaultCombo: ['0'], section: 'editor' }, - { actionKey: 'rate_1', description: 'Set star rating: 1', defaultCombo: ['1'], section: 'editor' }, - { actionKey: 'rate_2', description: 'Set star rating: 2', defaultCombo: ['2'], section: 'editor' }, - { actionKey: 'rate_3', description: 'Set star rating: 3', defaultCombo: ['3'], section: 'editor' }, - { actionKey: 'rate_4', description: 'Set star rating: 4', defaultCombo: ['4'], section: 'editor' }, - { actionKey: 'rate_5', description: 'Set star rating: 5', defaultCombo: ['5'], section: 'editor' }, - { actionKey: 'color_label_none', description: 'Clear color label', defaultCombo: ['shift', '0'], section: 'editor' }, - { actionKey: 'color_label_red', description: 'Set red color label', defaultCombo: ['shift', '1'], section: 'editor' }, - { actionKey: 'color_label_yellow', description: 'Set yellow color label', defaultCombo: ['shift', '2'], section: 'editor' }, - { actionKey: 'color_label_green', description: 'Set green color label', defaultCombo: ['shift', '3'], section: 'editor' }, - { actionKey: 'color_label_blue', description: 'Set blue color label', defaultCombo: ['shift', '4'], section: 'editor' }, - { actionKey: 'color_label_purple', description: 'Set purple color label', defaultCombo: ['shift', '5'], section: 'editor' }, - { actionKey: 'brush_size_up', description: 'Increase brush size', defaultCombo: ['ctrl', 'arrowup'], section: 'editor' }, - { actionKey: 'brush_size_down', description: 'Decrease brush size', defaultCombo: ['ctrl', 'arrowdown'], section: 'editor' }, + { actionKey: 'open_image', description: 'Open selected image', defaultCombo: ['Enter'], section: 'general' }, + { actionKey: 'copy_adjustments', description: 'Copy selected adjustments', defaultCombo: ['ctrl', 'KeyC'], section: 'general' }, + { actionKey: 'paste_adjustments', description: 'Paste copied adjustments', defaultCombo: ['ctrl', 'KeyV'], section: 'general' }, + { actionKey: 'copy_files', description: 'Copy selected file(s)', defaultCombo: ['ctrl', 'shift', 'KeyC'], section: 'general' }, + { actionKey: 'paste_files', description: 'Paste file(s) to current folder', defaultCombo: ['ctrl', 'shift', 'KeyV'], section: 'general' }, + { actionKey: 'select_all', description: 'Select all images', defaultCombo: ['ctrl', 'KeyA'], section: 'general' }, + { actionKey: 'delete_selected', description: 'Delete selected file(s)', defaultCombo: ['Delete'], section: 'general' }, + { actionKey: 'preview_prev', description: 'Previous image', defaultCombo: ['ArrowLeft'], section: 'editor' }, + { actionKey: 'preview_next', description: 'Next image', defaultCombo: ['ArrowRight'], section: 'editor' }, + { actionKey: 'zoom_in_step', description: 'Zoom in (by step)', defaultCombo: ['ArrowUp'], section: 'editor' }, + { actionKey: 'zoom_out_step', description: 'Zoom out (by step)', defaultCombo: ['ArrowDown'], section: 'editor' }, + { actionKey: 'cycle_zoom', description: 'Cycle zoom (Fit, 2x Fit, 100%)', defaultCombo: ['Space'], section: 'editor' }, + { actionKey: 'zoom_in', description: 'Zoom in', defaultCombo: ['ctrl', 'Equal'], section: 'editor' }, + { actionKey: 'zoom_out', description: 'Zoom out', defaultCombo: ['ctrl', 'Minus'], section: 'editor' }, + { actionKey: 'zoom_fit', description: 'Zoom to fit', defaultCombo: ['ctrl', 'Digit0'], section: 'editor' }, + { actionKey: 'zoom_100', description: 'Zoom to 100%', defaultCombo: ['ctrl', 'Digit1'], section: 'editor' }, + { actionKey: 'rotate_left', description: 'Rotate 90° counter-clockwise', defaultCombo: ['BracketLeft'], section: 'editor' }, + { actionKey: 'rotate_right', description: 'Rotate 90° clockwise', defaultCombo: ['BracketRight'], section: 'editor' }, + { actionKey: 'undo', description: 'Undo adjustment', defaultCombo: ['ctrl', 'KeyZ'], section: 'editor' }, + { actionKey: 'redo', description: 'Redo adjustment', defaultCombo: ['ctrl', 'KeyY'], section: 'editor' }, + { actionKey: 'toggle_fullscreen', description: 'Toggle fullscreen', defaultCombo: ['KeyF'], section: 'editor' }, + { actionKey: 'show_original', description: 'Show original (before/after)', defaultCombo: ['KeyB'], section: 'editor' }, + { actionKey: 'toggle_crop', description: 'Straighten Image', defaultCombo: ['KeyS'], section: 'editor' }, + { actionKey: 'toggle_adjustments', description: 'Toggle Adjustments panel', defaultCombo: ['KeyD'], section: 'editor' }, + { actionKey: 'toggle_crop_panel', description: 'Toggle Crop panel', defaultCombo: ['KeyR'], section: 'editor' }, + { actionKey: 'toggle_masks', description: 'Toggle Masks panel', defaultCombo: ['KeyM'], section: 'editor' }, + { actionKey: 'toggle_ai', description: 'Toggle AI panel', defaultCombo: ['KeyK'], section: 'editor' }, + { actionKey: 'toggle_presets', description: 'Toggle Presets panel', defaultCombo: ['KeyP'], section: 'editor' }, + { actionKey: 'toggle_metadata', description: 'Toggle Metadata panel', defaultCombo: ['KeyI'], section: 'editor' }, + { actionKey: 'toggle_analytics', description: 'Toggle Analytics display', defaultCombo: ['KeyA'], section: 'editor' }, + { actionKey: 'toggle_export', description: 'Toggle Export panel', defaultCombo: ['KeyE'], section: 'editor' }, + { actionKey: 'rate_0', description: 'Set star rating: 0', defaultCombo: ['Digit0'], section: 'editor' }, + { actionKey: 'rate_1', description: 'Set star rating: 1', defaultCombo: ['Digit1'], section: 'editor' }, + { actionKey: 'rate_2', description: 'Set star rating: 2', defaultCombo: ['Digit2'], section: 'editor' }, + { actionKey: 'rate_3', description: 'Set star rating: 3', defaultCombo: ['Digit3'], section: 'editor' }, + { actionKey: 'rate_4', description: 'Set star rating: 4', defaultCombo: ['Digit4'], section: 'editor' }, + { actionKey: 'rate_5', description: 'Set star rating: 5', defaultCombo: ['Digit5'], section: 'editor' }, + { actionKey: 'color_label_none', description: 'Clear color label', defaultCombo: ['shift', 'Digit0'], section: 'editor' }, + { actionKey: 'color_label_red', description: 'Set red color label', defaultCombo: ['shift', 'Digit1'], section: 'editor' }, + { actionKey: 'color_label_yellow', description: 'Set yellow color label', defaultCombo: ['shift', 'Digit2'], section: 'editor' }, + { actionKey: 'color_label_green', description: 'Set green color label', defaultCombo: ['shift', 'Digit3'], section: 'editor' }, + { actionKey: 'color_label_blue', description: 'Set blue color label', defaultCombo: ['shift', 'Digit4'], section: 'editor' }, + { actionKey: 'color_label_purple', description: 'Set purple color label', defaultCombo: ['shift', 'Digit5'], section: 'editor' }, + { actionKey: 'brush_size_up', description: 'Increase brush size', defaultCombo: ['ctrl', 'ArrowUp'], section: 'editor' }, + { actionKey: 'brush_size_down', description: 'Decrease brush size', defaultCombo: ['ctrl', 'ArrowDown'], section: 'editor' }, ]; diff --git a/src/hooks/useKeyboardShortcuts.tsx b/src/hooks/useKeyboardShortcuts.tsx index 0ae7e6cac..4674b210a 100644 --- a/src/hooks/useKeyboardShortcuts.tsx +++ b/src/hooks/useKeyboardShortcuts.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { ActionHandler, ImageFile, KeybindingDefinition, Panel, SelectedImage, KEYBINDING_DEFINITIONS } from '../components/ui/AppProperties'; import { BrushSettings } from '../components/ui/AppProperties'; +import { normalizeCombo } from '../utils/keyboardUtils'; interface KeyboardShortcutsProps { activeAiPatchContainerId?: string | null; @@ -112,25 +113,10 @@ export const useKeyboardShortcuts = ({ return a.length === b.length && a.every((v, i) => v === b[i]); } - function normalizeCombo(event: KeyboardEvent): string[] { - const parts: string[] = []; - if (event.ctrlKey || event.metaKey) parts.push('ctrl'); - if (event.shiftKey) parts.push('shift'); - if (event.altKey) parts.push('alt'); - if (event.code === 'BracketLeft') parts.push('['); - else if (event.code === 'BracketRight') parts.push(']'); - else { - const k = event.key.toLowerCase(); - if (!['ctrl', 'shift', 'alt', 'meta'].includes(k)) - parts.push(k); - } - return parts; - } - function getEffectiveCombo(def: KeybindingDefinition): string[] { if (def.actionKey === 'delete_selected') { if (osPlatform === 'macos' && !keybindings?.[def.actionKey]) { - return ['ctrl', 'backspace']; + return ['ctrl', 'Backspace']; } } const userCombo = keybindings?.[def.actionKey]; diff --git a/src/utils/keyboardUtils.ts b/src/utils/keyboardUtils.ts new file mode 100644 index 000000000..5522fa213 --- /dev/null +++ b/src/utils/keyboardUtils.ts @@ -0,0 +1,57 @@ +export function normalizeCombo(event: KeyboardEvent): string[] { + const parts: string[] = []; + if (event.ctrlKey || event.metaKey) parts.push('ctrl'); + if (event.shiftKey) parts.push('shift'); + if (event.altKey) parts.push('alt'); + const code = 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(); + } + + 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', + }; + return symMap[code] ?? null; +} + +export const ACCEPTED_KEY_CODES = new Set([ + 'Space', 'Enter', 'Tab', 'Backspace', 'Delete', 'Escape', + 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', + 'BracketLeft', 'BracketRight', + 'Comma', 'Period', 'Slash', 'Semicolon', 'Quote', 'Backquote', 'Minus', 'Equal', 'Backslash', + 'Numpad0', 'Numpad1', 'Numpad2', 'Numpad3', 'Numpad4', + 'Numpad5', 'Numpad6', 'Numpad7', 'Numpad8', 'Numpad9', +]); + +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; + return ACCEPTED_KEY_CODES.has(code); +} From c874e6c5eb1ccb3b4b04e2f4c4a81a5b5f254fa8 Mon Sep 17 00:00:00 2001 From: sefalkner Date: Fri, 24 Apr 2026 14:16:43 +0200 Subject: [PATCH 09/17] Add missing keys --- src/utils/keyboardUtils.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/utils/keyboardUtils.ts b/src/utils/keyboardUtils.ts index 5522fa213..e5adb2507 100644 --- a/src/utils/keyboardUtils.ts +++ b/src/utils/keyboardUtils.ts @@ -14,6 +14,9 @@ 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)}`; + } const symMap: Record = { Space: 'Space', @@ -37,17 +40,36 @@ export function codeToDisplayLabel(code: string): string | null { 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', }; return symMap[code] ?? null; } export const ACCEPTED_KEY_CODES = new Set([ - 'Space', 'Enter', 'Tab', 'Backspace', 'Delete', 'Escape', + 'Space', 'Enter', 'Tab', 'Backspace', 'Delete', 'Escape', 'Insert', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', + 'PageUp', 'PageDown', 'Home', 'End', 'BracketLeft', 'BracketRight', 'Comma', 'Period', 'Slash', 'Semicolon', 'Quote', 'Backquote', 'Minus', 'Equal', 'Backslash', 'Numpad0', 'Numpad1', 'Numpad2', 'Numpad3', 'Numpad4', 'Numpad5', 'Numpad6', 'Numpad7', 'Numpad8', 'Numpad9', + 'NumpadAdd', 'NumpadMultiply', 'NumpadDivide', 'NumpadSubtract', + 'NumpadDecimal', 'NumpadComma', 'NumpadEnter', 'NumpadEqual', + 'CapsLock', 'PrintScreen', ]); export function isValidShortcutKey(code: string): boolean { From 62553b34d5a9f7770aa659dce26bbc56e4653d8e Mon Sep 17 00:00:00 2001 From: sefalkner Date: Fri, 24 Apr 2026 14:16:56 +0200 Subject: [PATCH 10/17] Divide in more useful categories --- src/components/panel/SettingsPanel.tsx | 7 +- src/components/ui/AppProperties.tsx | 92 +++++++++++++------------- 2 files changed, 50 insertions(+), 49 deletions(-) diff --git a/src/components/panel/SettingsPanel.tsx b/src/components/panel/SettingsPanel.tsx index 76bc88328..265917013 100644 --- a/src/components/panel/SettingsPanel.tsx +++ b/src/components/panel/SettingsPanel.tsx @@ -1914,9 +1914,10 @@ export default function SettingsPanel({ Keyboard Shortcuts
- {(['general', 'editor'] as const).map((section) => { - const sectionDefs = KEYBINDING_DEFINITIONS.filter((d) => d.section === section); - const sectionLabel = section === 'general' ? 'General' : 'Editor'; +{(['library', 'editing', 'view', 'panels', 'rating'] as const).map((section) => { + const sectionDefs = KEYBINDING_DEFINITIONS.filter((d) => d.section === section); + const sectionLabels: Record = { library: 'Library', editing: 'Editing', view: 'View', rating: 'Rating & Labels', panels: 'Panels' }; + const sectionLabel = sectionLabels[section]; const userKb = appSettings?.keybindings || {}; return (
diff --git a/src/components/ui/AppProperties.tsx b/src/components/ui/AppProperties.tsx index dbfbf096d..35bc03e58 100644 --- a/src/components/ui/AppProperties.tsx +++ b/src/components/ui/AppProperties.tsx @@ -339,7 +339,7 @@ export interface KeybindingDefinition { actionKey: string; description: string; defaultCombo: string[]; - section: 'general' | 'editor'; + section: 'library' | 'view' | 'rating' | 'panels' | 'editing'; } export interface ActionHandler { @@ -348,49 +348,49 @@ export interface ActionHandler { } export const KEYBINDING_DEFINITIONS: KeybindingDefinition[] = [ - { actionKey: 'open_image', description: 'Open selected image', defaultCombo: ['Enter'], section: 'general' }, - { actionKey: 'copy_adjustments', description: 'Copy selected adjustments', defaultCombo: ['ctrl', 'KeyC'], section: 'general' }, - { actionKey: 'paste_adjustments', description: 'Paste copied adjustments', defaultCombo: ['ctrl', 'KeyV'], section: 'general' }, - { actionKey: 'copy_files', description: 'Copy selected file(s)', defaultCombo: ['ctrl', 'shift', 'KeyC'], section: 'general' }, - { actionKey: 'paste_files', description: 'Paste file(s) to current folder', defaultCombo: ['ctrl', 'shift', 'KeyV'], section: 'general' }, - { actionKey: 'select_all', description: 'Select all images', defaultCombo: ['ctrl', 'KeyA'], section: 'general' }, - { actionKey: 'delete_selected', description: 'Delete selected file(s)', defaultCombo: ['Delete'], section: 'general' }, - { actionKey: 'preview_prev', description: 'Previous image', defaultCombo: ['ArrowLeft'], section: 'editor' }, - { actionKey: 'preview_next', description: 'Next image', defaultCombo: ['ArrowRight'], section: 'editor' }, - { actionKey: 'zoom_in_step', description: 'Zoom in (by step)', defaultCombo: ['ArrowUp'], section: 'editor' }, - { actionKey: 'zoom_out_step', description: 'Zoom out (by step)', defaultCombo: ['ArrowDown'], section: 'editor' }, - { actionKey: 'cycle_zoom', description: 'Cycle zoom (Fit, 2x Fit, 100%)', defaultCombo: ['Space'], section: 'editor' }, - { actionKey: 'zoom_in', description: 'Zoom in', defaultCombo: ['ctrl', 'Equal'], section: 'editor' }, - { actionKey: 'zoom_out', description: 'Zoom out', defaultCombo: ['ctrl', 'Minus'], section: 'editor' }, - { actionKey: 'zoom_fit', description: 'Zoom to fit', defaultCombo: ['ctrl', 'Digit0'], section: 'editor' }, - { actionKey: 'zoom_100', description: 'Zoom to 100%', defaultCombo: ['ctrl', 'Digit1'], section: 'editor' }, - { actionKey: 'rotate_left', description: 'Rotate 90° counter-clockwise', defaultCombo: ['BracketLeft'], section: 'editor' }, - { actionKey: 'rotate_right', description: 'Rotate 90° clockwise', defaultCombo: ['BracketRight'], section: 'editor' }, - { actionKey: 'undo', description: 'Undo adjustment', defaultCombo: ['ctrl', 'KeyZ'], section: 'editor' }, - { actionKey: 'redo', description: 'Redo adjustment', defaultCombo: ['ctrl', 'KeyY'], section: 'editor' }, - { actionKey: 'toggle_fullscreen', description: 'Toggle fullscreen', defaultCombo: ['KeyF'], section: 'editor' }, - { actionKey: 'show_original', description: 'Show original (before/after)', defaultCombo: ['KeyB'], section: 'editor' }, - { actionKey: 'toggle_crop', description: 'Straighten Image', defaultCombo: ['KeyS'], section: 'editor' }, - { actionKey: 'toggle_adjustments', description: 'Toggle Adjustments panel', defaultCombo: ['KeyD'], section: 'editor' }, - { actionKey: 'toggle_crop_panel', description: 'Toggle Crop panel', defaultCombo: ['KeyR'], section: 'editor' }, - { actionKey: 'toggle_masks', description: 'Toggle Masks panel', defaultCombo: ['KeyM'], section: 'editor' }, - { actionKey: 'toggle_ai', description: 'Toggle AI panel', defaultCombo: ['KeyK'], section: 'editor' }, - { actionKey: 'toggle_presets', description: 'Toggle Presets panel', defaultCombo: ['KeyP'], section: 'editor' }, - { actionKey: 'toggle_metadata', description: 'Toggle Metadata panel', defaultCombo: ['KeyI'], section: 'editor' }, - { actionKey: 'toggle_analytics', description: 'Toggle Analytics display', defaultCombo: ['KeyA'], section: 'editor' }, - { actionKey: 'toggle_export', description: 'Toggle Export panel', defaultCombo: ['KeyE'], section: 'editor' }, - { actionKey: 'rate_0', description: 'Set star rating: 0', defaultCombo: ['Digit0'], section: 'editor' }, - { actionKey: 'rate_1', description: 'Set star rating: 1', defaultCombo: ['Digit1'], section: 'editor' }, - { actionKey: 'rate_2', description: 'Set star rating: 2', defaultCombo: ['Digit2'], section: 'editor' }, - { actionKey: 'rate_3', description: 'Set star rating: 3', defaultCombo: ['Digit3'], section: 'editor' }, - { actionKey: 'rate_4', description: 'Set star rating: 4', defaultCombo: ['Digit4'], section: 'editor' }, - { actionKey: 'rate_5', description: 'Set star rating: 5', defaultCombo: ['Digit5'], section: 'editor' }, - { actionKey: 'color_label_none', description: 'Clear color label', defaultCombo: ['shift', 'Digit0'], section: 'editor' }, - { actionKey: 'color_label_red', description: 'Set red color label', defaultCombo: ['shift', 'Digit1'], section: 'editor' }, - { actionKey: 'color_label_yellow', description: 'Set yellow color label', defaultCombo: ['shift', 'Digit2'], section: 'editor' }, - { actionKey: 'color_label_green', description: 'Set green color label', defaultCombo: ['shift', 'Digit3'], section: 'editor' }, - { actionKey: 'color_label_blue', description: 'Set blue color label', defaultCombo: ['shift', 'Digit4'], section: 'editor' }, - { actionKey: 'color_label_purple', description: 'Set purple color label', defaultCombo: ['shift', 'Digit5'], section: 'editor' }, - { actionKey: 'brush_size_up', description: 'Increase brush size', defaultCombo: ['ctrl', 'ArrowUp'], section: 'editor' }, - { actionKey: 'brush_size_down', description: 'Decrease brush size', defaultCombo: ['ctrl', 'ArrowDown'], section: 'editor' }, + { actionKey: 'open_image', description: 'Open selected image', defaultCombo: ['Enter'], section: 'library' }, + { actionKey: 'copy_files', description: 'Copy selected file(s)', defaultCombo: ['ctrl', 'shift', 'KeyC'], section: 'library' }, + { actionKey: 'paste_files', description: 'Paste file(s) to current folder', defaultCombo: ['ctrl', 'shift', 'KeyV'], section: 'library' }, + { actionKey: 'select_all', description: 'Select all images', defaultCombo: ['ctrl', 'KeyA'], section: 'library' }, + { actionKey: 'delete_selected', description: 'Delete selected file(s)', defaultCombo: ['Delete'], section: 'library' }, + { actionKey: 'preview_prev', description: 'Previous image', defaultCombo: ['ArrowLeft'], section: 'library' }, + { actionKey: 'preview_next', description: 'Next image', defaultCombo: ['ArrowRight'], section: 'library' }, + { actionKey: 'zoom_in_step', description: 'Zoom in (by step)', defaultCombo: ['ArrowUp'], section: 'view' }, + { actionKey: 'zoom_out_step', description: 'Zoom out (by step)', defaultCombo: ['ArrowDown'], section: 'view' }, + { actionKey: 'cycle_zoom', description: 'Cycle zoom (Fit, 2x Fit, 100%)', defaultCombo: ['Space'], section: 'view' }, + { actionKey: 'zoom_in', description: 'Zoom in', defaultCombo: ['ctrl', 'Equal'], section: 'view' }, + { actionKey: 'zoom_out', description: 'Zoom out', defaultCombo: ['ctrl', 'Minus'], section: 'view' }, + { actionKey: 'zoom_fit', description: 'Zoom to fit', defaultCombo: ['ctrl', 'Digit0'], section: 'view' }, + { actionKey: 'zoom_100', description: 'Zoom to 100%', defaultCombo: ['ctrl', 'Digit1'], section: 'view' }, + { actionKey: 'toggle_fullscreen', description: 'Toggle fullscreen', defaultCombo: ['KeyF'], section: 'view' }, + { actionKey: 'show_original', description: 'Show original (before/after)', defaultCombo: ['KeyB'], section: 'view' }, + { actionKey: 'rate_0', description: 'Star rating: 0', defaultCombo: ['Digit0'], section: 'rating' }, + { actionKey: 'rate_1', description: 'Star rating: 1', defaultCombo: ['Digit1'], section: 'rating' }, + { actionKey: 'rate_2', description: 'Star rating: 2', defaultCombo: ['Digit2'], section: 'rating' }, + { actionKey: 'rate_3', description: 'Star rating: 3', defaultCombo: ['Digit3'], section: 'rating' }, + { actionKey: 'rate_4', description: 'Star rating: 4', defaultCombo: ['Digit4'], section: 'rating' }, + { actionKey: 'rate_5', description: 'Star rating: 5', defaultCombo: ['Digit5'], section: 'rating' }, + { actionKey: 'color_label_none', description: 'Color label: None', defaultCombo: ['shift', 'Digit0'], section: 'rating' }, + { actionKey: 'color_label_red', description: 'Color label: Red', defaultCombo: ['shift', 'Digit1'], section: 'rating' }, + { actionKey: 'color_label_yellow', description: 'Color label: Yellow', defaultCombo: ['shift', 'Digit2'], section: 'rating' }, + { actionKey: 'color_label_green', description: 'Color label: Green', defaultCombo: ['shift', 'Digit3'], section: 'rating' }, + { actionKey: 'color_label_blue', description: 'Color label: Blue', defaultCombo: ['shift', 'Digit4'], section: 'rating' }, + { actionKey: 'color_label_purple', description: 'Color label: Purple', defaultCombo: ['shift', 'Digit5'], section: 'rating' }, + { actionKey: 'toggle_adjustments', description: 'Toggle Adjustments panel', defaultCombo: ['KeyD'], section: 'panels' }, + { actionKey: 'toggle_crop_panel', description: 'Toggle Crop panel', defaultCombo: ['KeyR'], section: 'panels' }, + { actionKey: 'toggle_masks', description: 'Toggle Masks panel', defaultCombo: ['KeyM'], section: 'panels' }, + { actionKey: 'toggle_ai', description: 'Toggle AI panel', defaultCombo: ['KeyK'], section: 'panels' }, + { actionKey: 'toggle_presets', description: 'Toggle Presets panel', defaultCombo: ['KeyP'], section: 'panels' }, + { actionKey: 'toggle_metadata', description: 'Toggle Metadata panel', defaultCombo: ['KeyI'], section: 'panels' }, + { actionKey: 'toggle_analytics', description: 'Toggle Analytics display', defaultCombo: ['KeyA'], section: 'panels' }, + { actionKey: 'toggle_export', description: 'Toggle Export panel', defaultCombo: ['KeyE'], section: 'panels' }, + { actionKey: 'undo', description: 'Undo adjustment', defaultCombo: ['ctrl', 'KeyZ'], section: 'editing' }, + { actionKey: 'redo', description: 'Redo adjustment', defaultCombo: ['ctrl', 'KeyY'], section: 'editing' }, + { actionKey: 'copy_adjustments', description: 'Copy selected adjustments', defaultCombo: ['ctrl', 'KeyC'], section: 'editing' }, + { actionKey: 'paste_adjustments', description: 'Paste copied adjustments', defaultCombo: ['ctrl', 'KeyV'], section: 'editing' }, + { actionKey: 'rotate_left', description: 'Rotate 90° counter-clockwise', defaultCombo: ['BracketLeft'], section: 'editing' }, + { actionKey: 'rotate_right', description: 'Rotate 90° clockwise', defaultCombo: ['BracketRight'], section: 'editing' }, + { actionKey: 'toggle_crop', description: 'Straighten Image', defaultCombo: ['KeyS'], section: 'editing' }, + { actionKey: 'brush_size_up', description: 'Increase brush size', defaultCombo: ['ctrl', 'ArrowUp'], section: 'editing' }, + { actionKey: 'brush_size_down', description: 'Decrease brush size', defaultCombo: ['ctrl', 'ArrowDown'], section: 'editing' }, ]; From 454b3f49c53fe8661c1556598818d38751bff588 Mon Sep 17 00:00:00 2001 From: sefalkner Date: Fri, 24 Apr 2026 14:31:57 +0200 Subject: [PATCH 11/17] Code and description cleanup --- src/components/panel/SettingsPanel.tsx | 35 +++----- src/components/ui/AppProperties.tsx | 13 +++ src/hooks/useKeyboardShortcuts.tsx | 6 +- src/utils/keyboardUtils.ts | 106 ++++++++++++------------- 4 files changed, 78 insertions(+), 82 deletions(-) diff --git a/src/components/panel/SettingsPanel.tsx b/src/components/panel/SettingsPanel.tsx index 265917013..142b159ac 100644 --- a/src/components/panel/SettingsPanel.tsx +++ b/src/components/panel/SettingsPanel.tsx @@ -31,8 +31,8 @@ import Switch from '../ui/Switch'; import Input from '../ui/Input'; import Slider from '../ui/Slider'; import { ThemeProps, THEMES, DEFAULT_THEME_ID } from '../../utils/themes'; -import { Invokes, KEYBINDING_DEFINITIONS, KeybindingDefinition } from '../ui/AppProperties'; -import { codeToDisplayLabel, normalizeCombo } from '../../utils/keyboardUtils'; +import { Invokes, KEYBINDING_DEFINITIONS, KEYBINDING_SECTIONS, KeybindingDefinition } from '../ui/AppProperties'; +import { arraysEqual, codeToDisplayLabel, formatKeyCode, normalizeCombo } from '../../utils/keyboardUtils'; import Text from '../ui/Text'; import { TextColors, TextVariants, TextWeights } from '../../types/typography'; import { useOsPlatform } from '../../hooks/useOsPlatform'; @@ -149,19 +149,9 @@ 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 formatKey = (key: string, osPlatform: string): string => { - if (key === 'ctrl') return osPlatform === 'macos' ? '⌘' : 'Ctrl'; - if (key === 'shift') return 'Shift'; - if (key === 'alt') return osPlatform === 'macos' ? '⌥' : 'Alt'; - - const label = codeToDisplayLabel(key); - return label || key; -}; - - const KeybindRow = ({ def, currentCombo, osPlatform, onSave, recordingAction, onStartRecording }: KeybindRowProps) => { const recording = recordingAction === def.actionKey; @@ -210,7 +200,7 @@ const KeybindRow = ({ def, currentCombo, osPlatform, onSave, recordingAction, on weight={TextWeights.semibold} className="px-2 py-1 font-sans bg-bg-primary border border-border-color rounded-md cursor-pointer hover:border-accent transition-colors" > - {displayCombo.map((k) => formatKey(k, osPlatform)).join(' + ')} + {displayCombo.map((k) => formatKeyCode(k, osPlatform)).join(' + ')} )} @@ -1911,17 +1901,14 @@ export default function SettingsPanel({ >
- Keyboard Shortcuts + Keyboard Controls -
-{(['library', 'editing', 'view', 'panels', 'rating'] as const).map((section) => { - const sectionDefs = KEYBINDING_DEFINITIONS.filter((d) => d.section === section); - const sectionLabels: Record = { library: 'Library', editing: 'Editing', view: 'View', rating: 'Rating & Labels', panels: 'Panels' }; - const sectionLabel = sectionLabels[section]; - const userKb = appSettings?.keybindings || {}; - return ( -
- {sectionLabel} +
{KEYBINDING_SECTIONS.map((section) => { + const sectionDefs = KEYBINDING_DEFINITIONS.filter((d) => d.section === section.id); + const userKb = appSettings?.keybindings || {}; + return ( +
+ {section.label}
{sectionDefs.map((def) => ( void; } +export interface KeybindingSection { + id: KeybindingDefinition['section']; + label: string; +} + +export const KEYBINDING_SECTIONS: KeybindingSection[] = [ + { id: 'library', label: 'Library' }, + { id: 'editing', label: 'Editing' }, + { id: 'view', label: 'View' }, + { id: 'rating', label: 'Rating & Labels' }, + { id: 'panels', label: 'Panels' }, +]; + export const KEYBINDING_DEFINITIONS: KeybindingDefinition[] = [ { actionKey: 'open_image', description: 'Open selected image', defaultCombo: ['Enter'], section: 'library' }, { actionKey: 'copy_files', description: 'Copy selected file(s)', defaultCombo: ['ctrl', 'shift', 'KeyC'], section: 'library' }, diff --git a/src/hooks/useKeyboardShortcuts.tsx b/src/hooks/useKeyboardShortcuts.tsx index 4674b210a..0bfbb1907 100644 --- a/src/hooks/useKeyboardShortcuts.tsx +++ b/src/hooks/useKeyboardShortcuts.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { ActionHandler, ImageFile, KeybindingDefinition, Panel, SelectedImage, KEYBINDING_DEFINITIONS } from '../components/ui/AppProperties'; import { BrushSettings } from '../components/ui/AppProperties'; -import { normalizeCombo } from '../utils/keyboardUtils'; +import { arraysEqual, normalizeCombo } from '../utils/keyboardUtils'; interface KeyboardShortcutsProps { activeAiPatchContainerId?: string | null; @@ -109,10 +109,6 @@ export const useKeyboardShortcuts = ({ setBrushSettings, }: KeyboardShortcutsProps) => { useEffect(() => { - function arraysEqual(a: string[], b: string[]): boolean { - return a.length === b.length && a.every((v, i) => v === b[i]); - } - function getEffectiveCombo(def: KeybindingDefinition): string[] { if (def.actionKey === 'delete_selected') { if (osPlatform === 'macos' && !keybindings?.[def.actionKey]) { diff --git a/src/utils/keyboardUtils.ts b/src/utils/keyboardUtils.ts index e5adb2507..1595e3794 100644 --- a/src/utils/keyboardUtils.ts +++ b/src/utils/keyboardUtils.ts @@ -1,3 +1,42 @@ +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): string[] { const parts: string[] = []; if (event.ctrlKey || event.metaKey) parts.push('ctrl'); @@ -17,63 +56,24 @@ export function codeToDisplayLabel(code: string): string | null { if (/^Numpad[0-9]$/.test(code)) { return `Numpad ${code.slice(-1)}`; } - - 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', - }; return symMap[code] ?? null; } -export const ACCEPTED_KEY_CODES = new Set([ - 'Space', 'Enter', 'Tab', 'Backspace', 'Delete', 'Escape', 'Insert', - 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', - 'PageUp', 'PageDown', 'Home', 'End', - 'BracketLeft', 'BracketRight', - 'Comma', 'Period', 'Slash', 'Semicolon', 'Quote', 'Backquote', 'Minus', 'Equal', 'Backslash', - 'Numpad0', 'Numpad1', 'Numpad2', 'Numpad3', 'Numpad4', - 'Numpad5', 'Numpad6', 'Numpad7', 'Numpad8', 'Numpad9', - 'NumpadAdd', 'NumpadMultiply', 'NumpadDivide', 'NumpadSubtract', - 'NumpadDecimal', 'NumpadComma', 'NumpadEnter', 'NumpadEqual', - 'CapsLock', 'PrintScreen', -]); - 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; - return ACCEPTED_KEY_CODES.has(code); + 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'; + 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]); } From 57f8058620f04d3b55c483e41b9f69169b44f9f1 Mon Sep 17 00:00:00 2001 From: sefalkner Date: Fri, 24 Apr 2026 15:11:48 +0200 Subject: [PATCH 12/17] MacOS fixes --- src/components/panel/SettingsPanel.tsx | 112 +++++++++++++++---------- src/hooks/useKeyboardShortcuts.tsx | 11 +-- src/utils/keyboardUtils.ts | 8 +- 3 files changed, 76 insertions(+), 55 deletions(-) diff --git a/src/components/panel/SettingsPanel.tsx b/src/components/panel/SettingsPanel.tsx index 142b159ac..ec0d85ac5 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, @@ -64,6 +64,7 @@ interface KeybindRowProps { onSave: (actionKey: string, combo: string[]) => void; recordingAction: string | null; onStartRecording: (actionKey: string) => void; + isConflicting: boolean; } interface SettingItemProps { @@ -152,18 +153,19 @@ const settingCategories = [ { id: 'shortcuts', label: 'Controls', icon: Keyboard }, ]; -const KeybindRow = ({ def, currentCombo, osPlatform, onSave, recordingAction, onStartRecording }: KeybindRowProps) => { - const recording = recordingAction === def.actionKey; +const KeybindRow = ({ def, currentCombo, osPlatform, onSave, recordingAction, onStartRecording, isConflicting }: KeybindRowProps) => { + const recording = recordingAction === def.actionKey; useEffect(() => { if (!recording) return; const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') { + onSave(def.actionKey, []); onStartRecording(''); return; } e.preventDefault(); - const parts = normalizeCombo(e); + const parts = normalizeCombo(e, osPlatform); if (parts.length > 0 && !['ctrl', 'shift', 'alt'].includes(parts[parts.length - 1])) { onSave(def.actionKey, parts); onStartRecording(''); @@ -173,37 +175,40 @@ const KeybindRow = ({ def, currentCombo, osPlatform, onSave, recordingAction, on return () => window.removeEventListener('keydown', handler, { capture: true }); }, [recording, def.actionKey, onSave, onStartRecording]); - const displayCombo = currentCombo ?? def.defaultCombo; + const displayCombo = currentCombo !== undefined ? (currentCombo.length ? currentCombo : null) : def.defaultCombo; return (
{def.description} - + + Press a key... (Esc to clear) + + ) : ( + + {displayCombo ? displayCombo.map((k) => formatKeyCode(k, osPlatform)).join(' + ') : Not assigned} + + )} + +
); }; @@ -771,6 +776,24 @@ export default function SettingsPanel({ onSettingsChange({ ...appSettings, keybindings: newKeybindings }); }; + const conflictingKeys = useMemo(() => { + const map = new Map>(); + const userKb = appSettings?.keybindings || {}; + for (const def of KEYBINDING_DEFINITIONS) { + const userCombo = userKb[def.actionKey]; + 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.actionKey); + } + const keys = new Set(); + for (const [, actions] of map) { + if (actions.size > 1) actions.forEach((k) => keys.add(k)); + } + return keys; + }, [appSettings?.keybindings]); + return ( <> @@ -1903,23 +1926,24 @@ export default function SettingsPanel({ Keyboard Controls -
{KEYBINDING_SECTIONS.map((section) => { - const sectionDefs = KEYBINDING_DEFINITIONS.filter((d) => d.section === section.id); - const userKb = appSettings?.keybindings || {}; - return ( -
- {section.label} -
- {sectionDefs.map((def) => ( - +
{KEYBINDING_SECTIONS.map((section) => { + const sectionDefs = KEYBINDING_DEFINITIONS.filter((d) => d.section === section.id); + const userKb = appSettings?.keybindings || {}; + return ( +
+ {section.label} +
+ {sectionDefs.map((def) => ( + ))}
diff --git a/src/hooks/useKeyboardShortcuts.tsx b/src/hooks/useKeyboardShortcuts.tsx index 0bfbb1907..4bf039893 100644 --- a/src/hooks/useKeyboardShortcuts.tsx +++ b/src/hooks/useKeyboardShortcuts.tsx @@ -110,13 +110,8 @@ export const useKeyboardShortcuts = ({ }: KeyboardShortcutsProps) => { useEffect(() => { function getEffectiveCombo(def: KeybindingDefinition): string[] { - if (def.actionKey === 'delete_selected') { - if (osPlatform === 'macos' && !keybindings?.[def.actionKey]) { - return ['ctrl', 'Backspace']; - } - } const userCombo = keybindings?.[def.actionKey]; - if (userCombo) return userCombo; + if (userCombo?.length) return userCombo; return def.defaultCombo; } @@ -438,7 +433,7 @@ export const useKeyboardShortcuts = ({ } const isMacOS = osPlatform === 'macos'; - const isDeletePressed = isMacOS ? event.key === 'backspace' : event.key === 'delete'; + const isDeletePressed = isMacOS ? event.key === 'Backspace' : event.key === 'Delete'; if (isDeletePressed) { event.preventDefault(); if (activeMaskContainerId) { @@ -449,7 +444,7 @@ export const useKeyboardShortcuts = ({ return; } - const normalized = normalizeCombo(event); + const normalized = normalizeCombo(event, osPlatform); for (const def of KEYBINDING_DEFINITIONS) { const effectiveCombo = getEffectiveCombo(def); if (arraysEqual(effectiveCombo, normalized)) { diff --git a/src/utils/keyboardUtils.ts b/src/utils/keyboardUtils.ts index 1595e3794..baca73165 100644 --- a/src/utils/keyboardUtils.ts +++ b/src/utils/keyboardUtils.ts @@ -37,12 +37,13 @@ const symMap: Record = { PrintScreen: 'PrtSc', }; -export function normalizeCombo(event: KeyboardEvent): string[] { +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) parts.push('ctrl'); + if ((event.ctrlKey || event.metaKey) && !isMacDelete) parts.push('ctrl'); if (event.shiftKey) parts.push('shift'); if (event.altKey) parts.push('alt'); - const code = event.code; + const code = isMacDelete ? 'Delete' : event.code; if (isValidShortcutKey(code)) { parts.push(code); } @@ -70,6 +71,7 @@ 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; } From 9b08c9d72675f0cb0370aefdf24fd539986b856e Mon Sep 17 00:00:00 2001 From: sefalkner Date: Fri, 24 Apr 2026 15:36:03 +0200 Subject: [PATCH 13/17] Implement built in keybindings --- src/hooks/useKeyboardShortcuts.tsx | 117 ++++++++++++++--------------- 1 file changed, 58 insertions(+), 59 deletions(-) diff --git a/src/hooks/useKeyboardShortcuts.tsx b/src/hooks/useKeyboardShortcuts.tsx index 4bf039893..7ae20d1e3 100644 --- a/src/hooks/useKeyboardShortcuts.tsx +++ b/src/hooks/useKeyboardShortcuts.tsx @@ -396,52 +396,69 @@ export const useKeyboardShortcuts = ({ }, }; - const handleKeyDown = (event: any) => { - if (isModalOpen) { - return; - } + type BuiltInMatch = (e: KeyboardEvent) => boolean; + type BuiltInExec = (e: KeyboardEvent) => void; + + 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]); + } + }, + }, + ]; + + const handleKeyDown = (event: KeyboardEvent) => { + if (isModalOpen) return; const isInputFocused = document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA'; - if (isInputFocused) { - return; - } - - // Escape cascade for now still here - if (event.key === 'escape') { - 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 if (selectedImage) { - handleBackToLibrary(); - } - return; - } + if (isInputFocused) return; - const isMacOS = osPlatform === 'macos'; - const isDeletePressed = isMacOS ? event.key === 'Backspace' : event.key === 'Delete'; - if (isDeletePressed) { - event.preventDefault(); - if (activeMaskContainerId) { - handleDeleteMaskContainer(activeMaskContainerId); - } else if (activeAiPatchContainerId) { - handleDeleteAiPatch(activeAiPatchContainerId); + for (const builtin of builtinShortcuts) { + if (builtin.match(event)) { + builtin.execute(event); + return; } - return; } const normalized = normalizeCombo(event, osPlatform); @@ -455,24 +472,6 @@ export const useKeyboardShortcuts = ({ } } } - - // Library arrow navigation - if (!selectedImage && ['arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(event.key.toLowerCase())) { - event.preventDefault(); - const isNext = event.key === 'ArrowRight' || event.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]); - } - } }; window.addEventListener('keydown', handleKeyDown); return () => { From 9246b2c69fdd0c8d255108df668f35ca420d8e27 Mon Sep 17 00:00:00 2001 From: sefalkner Date: Fri, 24 Apr 2026 16:26:48 +0200 Subject: [PATCH 14/17] Fix not assigned function, code cleanup --- src/components/panel/SettingsPanel.tsx | 70 +++++++++++++------------- src/components/ui/AppProperties.tsx | 4 +- src/hooks/useKeyboardShortcuts.tsx | 45 ++++++++++------- 3 files changed, 65 insertions(+), 54 deletions(-) diff --git a/src/components/panel/SettingsPanel.tsx b/src/components/panel/SettingsPanel.tsx index ec0d85ac5..1cf44598d 100644 --- a/src/components/panel/SettingsPanel.tsx +++ b/src/components/panel/SettingsPanel.tsx @@ -154,7 +154,7 @@ const settingCategories = [ ]; const KeybindRow = ({ def, currentCombo, osPlatform, onSave, recordingAction, onStartRecording, isConflicting }: KeybindRowProps) => { - const recording = recordingAction === def.actionKey; + const recording = recordingAction === def.actionKey; useEffect(() => { if (!recording) return; @@ -177,41 +177,41 @@ const KeybindRow = ({ def, currentCombo, osPlatform, onSave, recordingAction, on const displayCombo = currentCombo !== undefined ? (currentCombo.length ? currentCombo : null) : def.defaultCombo; - return ( -
- {def.description} -
- {isConflicting && } - -
+ return ( +
+ {def.description} +
+ {isConflicting && } +
- ); - }; +
+ ); +}; const SettingItem = ({ children, description, label }: SettingItemProps) => (
diff --git a/src/components/ui/AppProperties.tsx b/src/components/ui/AppProperties.tsx index 868c31462..25619b60b 100644 --- a/src/components/ui/AppProperties.tsx +++ b/src/components/ui/AppProperties.tsx @@ -382,7 +382,7 @@ export const KEYBINDING_DEFINITIONS: KeybindingDefinition[] = [ { actionKey: 'rate_2', description: 'Star rating: 2', defaultCombo: ['Digit2'], section: 'rating' }, { actionKey: 'rate_3', description: 'Star rating: 3', defaultCombo: ['Digit3'], section: 'rating' }, { actionKey: 'rate_4', description: 'Star rating: 4', defaultCombo: ['Digit4'], section: 'rating' }, - { actionKey: 'rate_5', description: 'Star rating: 5', defaultCombo: ['Digit5'], section: 'rating' }, + { actionKey: 'rate_5', description: 'Star rating: 5', defaultCombo: ['Digit5'], section: 'rating' }, { actionKey: 'color_label_none', description: 'Color label: None', defaultCombo: ['shift', 'Digit0'], section: 'rating' }, { actionKey: 'color_label_red', description: 'Color label: Red', defaultCombo: ['shift', 'Digit1'], section: 'rating' }, { actionKey: 'color_label_yellow', description: 'Color label: Yellow', defaultCombo: ['shift', 'Digit2'], section: 'rating' }, @@ -403,7 +403,7 @@ export const KEYBINDING_DEFINITIONS: KeybindingDefinition[] = [ { actionKey: 'paste_adjustments', description: 'Paste copied adjustments', defaultCombo: ['ctrl', 'KeyV'], section: 'editing' }, { actionKey: 'rotate_left', description: 'Rotate 90° counter-clockwise', defaultCombo: ['BracketLeft'], section: 'editing' }, { actionKey: 'rotate_right', description: 'Rotate 90° clockwise', defaultCombo: ['BracketRight'], section: 'editing' }, - { actionKey: 'toggle_crop', description: 'Straighten Image', defaultCombo: ['KeyS'], section: 'editing' }, + { actionKey: 'toggle_crop', description: 'Toggle Crop / Straighten', defaultCombo: ['KeyS'], section: 'editing' }, { actionKey: 'brush_size_up', description: 'Increase brush size', defaultCombo: ['ctrl', 'ArrowUp'], section: 'editing' }, { actionKey: 'brush_size_down', description: 'Decrease brush size', defaultCombo: ['ctrl', 'ArrowDown'], section: 'editing' }, ]; diff --git a/src/hooks/useKeyboardShortcuts.tsx b/src/hooks/useKeyboardShortcuts.tsx index 7ae20d1e3..4220db7ce 100644 --- a/src/hooks/useKeyboardShortcuts.tsx +++ b/src/hooks/useKeyboardShortcuts.tsx @@ -1,7 +1,7 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { ActionHandler, ImageFile, KeybindingDefinition, Panel, SelectedImage, KEYBINDING_DEFINITIONS } from '../components/ui/AppProperties'; import { BrushSettings } from '../components/ui/AppProperties'; -import { arraysEqual, normalizeCombo } from '../utils/keyboardUtils'; +import { normalizeCombo } from '../utils/keyboardUtils'; interface KeyboardShortcutsProps { activeAiPatchContainerId?: string | null; @@ -108,13 +108,26 @@ export const useKeyboardShortcuts = ({ brushSettings, setBrushSettings, }: KeyboardShortcutsProps) => { - useEffect(() => { - function getEffectiveCombo(def: KeybindingDefinition): string[] { - const userCombo = keybindings?.[def.actionKey]; - if (userCombo?.length) return userCombo; - return def.defaultCombo; + function getEffectiveCombo(def: KeybindingDefinition): string[] | null { + const userCombo = keybindings?.[def.actionKey]; + if (userCombo !== undefined) { + return userCombo.length > 0 ? userCombo : null; + } + return def.defaultCombo; + } + + const comboMap = useMemo(() => { + const map = new Map(); + for (const def of KEYBINDING_DEFINITIONS) { + const effective = getEffectiveCombo(def); + if (effective) { + map.set(effective.join('+'), def.actionKey); + } } + return map; + }, [keybindings]); + useEffect(() => { const actions: Record = { open_image: { shouldFire: () => !selectedImage && libraryActivePath !== null, @@ -462,14 +475,12 @@ export const useKeyboardShortcuts = ({ } const normalized = normalizeCombo(event, osPlatform); - for (const def of KEYBINDING_DEFINITIONS) { - const effectiveCombo = getEffectiveCombo(def); - if (arraysEqual(effectiveCombo, normalized)) { - const handler = actions[def.actionKey]; - if (handler && (!handler.shouldFire || handler.shouldFire())) { - handler.execute(event); - return; - } + const actionKey = comboMap.get(normalized.join('+')); + if (actionKey) { + const handler = actions[actionKey]; + if (handler && (!handler.shouldFire || handler.shouldFire())) { + handler.execute(event); + return; } } }; @@ -525,7 +536,7 @@ export const useKeyboardShortcuts = ({ displaySize, baseRenderSize, originalSize, - brushSettings, - setBrushSettings, + brushSettings, + setBrushSettings, ]); }; From dbfd6d4c8ad159729c98a2a48afe1bf426ae3a4b Mon Sep 17 00:00:00 2001 From: sefalkner Date: Fri, 24 Apr 2026 18:28:10 +0200 Subject: [PATCH 15/17] Code cleanup --- src/components/panel/SettingsPanel.tsx | 4 +- src/components/ui/AppProperties.tsx | 68 -------------------------- src/hooks/useKeyboardShortcuts.tsx | 4 +- src/utils/keyboardUtils.ts | 68 ++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 72 deletions(-) diff --git a/src/components/panel/SettingsPanel.tsx b/src/components/panel/SettingsPanel.tsx index 1cf44598d..766c9847b 100644 --- a/src/components/panel/SettingsPanel.tsx +++ b/src/components/panel/SettingsPanel.tsx @@ -31,8 +31,8 @@ import Switch from '../ui/Switch'; import Input from '../ui/Input'; import Slider from '../ui/Slider'; import { ThemeProps, THEMES, DEFAULT_THEME_ID } from '../../utils/themes'; -import { Invokes, KEYBINDING_DEFINITIONS, KEYBINDING_SECTIONS, KeybindingDefinition } from '../ui/AppProperties'; -import { arraysEqual, codeToDisplayLabel, formatKeyCode, normalizeCombo } from '../../utils/keyboardUtils'; +import { Invokes } from '../ui/AppProperties'; +import { arraysEqual, codeToDisplayLabel, formatKeyCode, KeybindingDefinition, KEYBINDING_DEFINITIONS, KEYBINDING_SECTIONS, normalizeCombo } from '../../utils/keyboardUtils'; import Text from '../ui/Text'; import { TextColors, TextVariants, TextWeights } from '../../types/typography'; import { useOsPlatform } from '../../hooks/useOsPlatform'; diff --git a/src/components/ui/AppProperties.tsx b/src/components/ui/AppProperties.tsx index 25619b60b..5a442a0fa 100644 --- a/src/components/ui/AppProperties.tsx +++ b/src/components/ui/AppProperties.tsx @@ -335,75 +335,7 @@ export interface CullingSuggestions { failedPaths: string[]; } -export interface KeybindingDefinition { - actionKey: string; - description: string; - defaultCombo: string[]; - section: 'library' | 'view' | 'rating' | 'panels' | 'editing'; -} - export interface ActionHandler { shouldFire?: () => boolean; execute: (event: KeyboardEvent) => void; } - -export interface KeybindingSection { - id: KeybindingDefinition['section']; - label: string; -} - -export const KEYBINDING_SECTIONS: KeybindingSection[] = [ - { id: 'library', label: 'Library' }, - { id: 'editing', label: 'Editing' }, - { id: 'view', label: 'View' }, - { id: 'rating', label: 'Rating & Labels' }, - { id: 'panels', label: 'Panels' }, -]; - -export const KEYBINDING_DEFINITIONS: KeybindingDefinition[] = [ - { actionKey: 'open_image', description: 'Open selected image', defaultCombo: ['Enter'], section: 'library' }, - { actionKey: 'copy_files', description: 'Copy selected file(s)', defaultCombo: ['ctrl', 'shift', 'KeyC'], section: 'library' }, - { actionKey: 'paste_files', description: 'Paste file(s) to current folder', defaultCombo: ['ctrl', 'shift', 'KeyV'], section: 'library' }, - { actionKey: 'select_all', description: 'Select all images', defaultCombo: ['ctrl', 'KeyA'], section: 'library' }, - { actionKey: 'delete_selected', description: 'Delete selected file(s)', defaultCombo: ['Delete'], section: 'library' }, - { actionKey: 'preview_prev', description: 'Previous image', defaultCombo: ['ArrowLeft'], section: 'library' }, - { actionKey: 'preview_next', description: 'Next image', defaultCombo: ['ArrowRight'], section: 'library' }, - { actionKey: 'zoom_in_step', description: 'Zoom in (by step)', defaultCombo: ['ArrowUp'], section: 'view' }, - { actionKey: 'zoom_out_step', description: 'Zoom out (by step)', defaultCombo: ['ArrowDown'], section: 'view' }, - { actionKey: 'cycle_zoom', description: 'Cycle zoom (Fit, 2x Fit, 100%)', defaultCombo: ['Space'], section: 'view' }, - { actionKey: 'zoom_in', description: 'Zoom in', defaultCombo: ['ctrl', 'Equal'], section: 'view' }, - { actionKey: 'zoom_out', description: 'Zoom out', defaultCombo: ['ctrl', 'Minus'], section: 'view' }, - { actionKey: 'zoom_fit', description: 'Zoom to fit', defaultCombo: ['ctrl', 'Digit0'], section: 'view' }, - { actionKey: 'zoom_100', description: 'Zoom to 100%', defaultCombo: ['ctrl', 'Digit1'], section: 'view' }, - { actionKey: 'toggle_fullscreen', description: 'Toggle fullscreen', defaultCombo: ['KeyF'], section: 'view' }, - { actionKey: 'show_original', description: 'Show original (before/after)', defaultCombo: ['KeyB'], section: 'view' }, - { actionKey: 'rate_0', description: 'Star rating: 0', defaultCombo: ['Digit0'], section: 'rating' }, - { actionKey: 'rate_1', description: 'Star rating: 1', defaultCombo: ['Digit1'], section: 'rating' }, - { actionKey: 'rate_2', description: 'Star rating: 2', defaultCombo: ['Digit2'], section: 'rating' }, - { actionKey: 'rate_3', description: 'Star rating: 3', defaultCombo: ['Digit3'], section: 'rating' }, - { actionKey: 'rate_4', description: 'Star rating: 4', defaultCombo: ['Digit4'], section: 'rating' }, - { actionKey: 'rate_5', description: 'Star rating: 5', defaultCombo: ['Digit5'], section: 'rating' }, - { actionKey: 'color_label_none', description: 'Color label: None', defaultCombo: ['shift', 'Digit0'], section: 'rating' }, - { actionKey: 'color_label_red', description: 'Color label: Red', defaultCombo: ['shift', 'Digit1'], section: 'rating' }, - { actionKey: 'color_label_yellow', description: 'Color label: Yellow', defaultCombo: ['shift', 'Digit2'], section: 'rating' }, - { actionKey: 'color_label_green', description: 'Color label: Green', defaultCombo: ['shift', 'Digit3'], section: 'rating' }, - { actionKey: 'color_label_blue', description: 'Color label: Blue', defaultCombo: ['shift', 'Digit4'], section: 'rating' }, - { actionKey: 'color_label_purple', description: 'Color label: Purple', defaultCombo: ['shift', 'Digit5'], section: 'rating' }, - { actionKey: 'toggle_adjustments', description: 'Toggle Adjustments panel', defaultCombo: ['KeyD'], section: 'panels' }, - { actionKey: 'toggle_crop_panel', description: 'Toggle Crop panel', defaultCombo: ['KeyR'], section: 'panels' }, - { actionKey: 'toggle_masks', description: 'Toggle Masks panel', defaultCombo: ['KeyM'], section: 'panels' }, - { actionKey: 'toggle_ai', description: 'Toggle AI panel', defaultCombo: ['KeyK'], section: 'panels' }, - { actionKey: 'toggle_presets', description: 'Toggle Presets panel', defaultCombo: ['KeyP'], section: 'panels' }, - { actionKey: 'toggle_metadata', description: 'Toggle Metadata panel', defaultCombo: ['KeyI'], section: 'panels' }, - { actionKey: 'toggle_analytics', description: 'Toggle Analytics display', defaultCombo: ['KeyA'], section: 'panels' }, - { actionKey: 'toggle_export', description: 'Toggle Export panel', defaultCombo: ['KeyE'], section: 'panels' }, - { actionKey: 'undo', description: 'Undo adjustment', defaultCombo: ['ctrl', 'KeyZ'], section: 'editing' }, - { actionKey: 'redo', description: 'Redo adjustment', defaultCombo: ['ctrl', 'KeyY'], section: 'editing' }, - { actionKey: 'copy_adjustments', description: 'Copy selected adjustments', defaultCombo: ['ctrl', 'KeyC'], section: 'editing' }, - { actionKey: 'paste_adjustments', description: 'Paste copied adjustments', defaultCombo: ['ctrl', 'KeyV'], section: 'editing' }, - { actionKey: 'rotate_left', description: 'Rotate 90° counter-clockwise', defaultCombo: ['BracketLeft'], section: 'editing' }, - { actionKey: 'rotate_right', description: 'Rotate 90° clockwise', defaultCombo: ['BracketRight'], section: 'editing' }, - { actionKey: 'toggle_crop', description: 'Toggle Crop / Straighten', defaultCombo: ['KeyS'], section: 'editing' }, - { actionKey: 'brush_size_up', description: 'Increase brush size', defaultCombo: ['ctrl', 'ArrowUp'], section: 'editing' }, - { actionKey: 'brush_size_down', description: 'Decrease brush size', defaultCombo: ['ctrl', 'ArrowDown'], section: 'editing' }, -]; diff --git a/src/hooks/useKeyboardShortcuts.tsx b/src/hooks/useKeyboardShortcuts.tsx index 4220db7ce..ef97aedc8 100644 --- a/src/hooks/useKeyboardShortcuts.tsx +++ b/src/hooks/useKeyboardShortcuts.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo } from 'react'; -import { ActionHandler, ImageFile, KeybindingDefinition, Panel, SelectedImage, KEYBINDING_DEFINITIONS } from '../components/ui/AppProperties'; +import { ActionHandler, ImageFile, Panel, SelectedImage } from '../components/ui/AppProperties'; import { BrushSettings } from '../components/ui/AppProperties'; -import { normalizeCombo } from '../utils/keyboardUtils'; +import { KeybindingDefinition, KEYBINDING_DEFINITIONS, normalizeCombo } from '../utils/keyboardUtils'; interface KeyboardShortcutsProps { activeAiPatchContainerId?: string | null; diff --git a/src/utils/keyboardUtils.ts b/src/utils/keyboardUtils.ts index baca73165..0752b8ee5 100644 --- a/src/utils/keyboardUtils.ts +++ b/src/utils/keyboardUtils.ts @@ -1,3 +1,71 @@ +export interface KeybindingDefinition { + actionKey: string; + description: string; + defaultCombo: string[]; + section: 'library' | 'view' | 'rating' | 'panels' | 'editing'; +} + +export interface KeybindingSection { + id: KeybindingDefinition['section']; + label: string; +} + +export const KEYBINDING_SECTIONS: KeybindingSection[] = [ + { id: 'library', label: 'Library' }, + { id: 'editing', label: 'Editing' }, + { id: 'view', label: 'View' }, + { id: 'rating', label: 'Rating & Labels' }, + { id: 'panels', label: 'Panels' }, +]; + +export const KEYBINDING_DEFINITIONS: KeybindingDefinition[] = [ + { actionKey: 'open_image', description: 'Open selected image', defaultCombo: ['Enter'], section: 'library' }, + { actionKey: 'copy_files', description: 'Copy selected file(s)', defaultCombo: ['ctrl', 'shift', 'KeyC'], section: 'library' }, + { actionKey: 'paste_files', description: 'Paste file(s) to current folder', defaultCombo: ['ctrl', 'shift', 'KeyV'], section: 'library' }, + { actionKey: 'select_all', description: 'Select all images', defaultCombo: ['ctrl', 'KeyA'], section: 'library' }, + { actionKey: 'delete_selected', description: 'Delete selected file(s)', defaultCombo: ['Delete'], section: 'library' }, + { actionKey: 'preview_prev', description: 'Previous image', defaultCombo: ['ArrowLeft'], section: 'library' }, + { actionKey: 'preview_next', description: 'Next image', defaultCombo: ['ArrowRight'], section: 'library' }, + { actionKey: 'zoom_in_step', description: 'Zoom in (by step)', defaultCombo: ['ArrowUp'], section: 'view' }, + { actionKey: 'zoom_out_step', description: 'Zoom out (by step)', defaultCombo: ['ArrowDown'], section: 'view' }, + { actionKey: 'cycle_zoom', description: 'Cycle zoom (Fit, 2x Fit, 100%)', defaultCombo: ['Space'], section: 'view' }, + { actionKey: 'zoom_in', description: 'Zoom in', defaultCombo: ['ctrl', 'Equal'], section: 'view' }, + { actionKey: 'zoom_out', description: 'Zoom out', defaultCombo: ['ctrl', 'Minus'], section: 'view' }, + { actionKey: 'zoom_fit', description: 'Zoom to fit', defaultCombo: ['ctrl', 'Digit0'], section: 'view' }, + { actionKey: 'zoom_100', description: 'Zoom to 100%', defaultCombo: ['ctrl', 'Digit1'], section: 'view' }, + { actionKey: 'toggle_fullscreen', description: 'Toggle fullscreen', defaultCombo: ['KeyF'], section: 'view' }, + { actionKey: 'show_original', description: 'Show original (before/after)', defaultCombo: ['KeyB'], section: 'view' }, + { actionKey: 'rate_0', description: 'Star rating: 0', defaultCombo: ['Digit0'], section: 'rating' }, + { actionKey: 'rate_1', description: 'Star rating: 1', defaultCombo: ['Digit1'], section: 'rating' }, + { actionKey: 'rate_2', description: 'Star rating: 2', defaultCombo: ['Digit2'], section: 'rating' }, + { actionKey: 'rate_3', description: 'Star rating: 3', defaultCombo: ['Digit3'], section: 'rating' }, + { actionKey: 'rate_4', description: 'Star rating: 4', defaultCombo: ['Digit4'], section: 'rating' }, + { actionKey: 'rate_5', description: 'Star rating: 5', defaultCombo: ['Digit5'], section: 'rating' }, + { actionKey: 'color_label_none', description: 'Color label: None', defaultCombo: ['shift', 'Digit0'], section: 'rating' }, + { actionKey: 'color_label_red', description: 'Color label: Red', defaultCombo: ['shift', 'Digit1'], section: 'rating' }, + { actionKey: 'color_label_yellow', description: 'Color label: Yellow', defaultCombo: ['shift', 'Digit2'], section: 'rating' }, + { actionKey: 'color_label_green', description: 'Color label: Green', defaultCombo: ['shift', 'Digit3'], section: 'rating' }, + { actionKey: 'color_label_blue', description: 'Color label: Blue', defaultCombo: ['shift', 'Digit4'], section: 'rating' }, + { actionKey: 'color_label_purple', description: 'Color label: Purple', defaultCombo: ['shift', 'Digit5'], section: 'rating' }, + { actionKey: 'toggle_adjustments', description: 'Toggle Adjustments panel', defaultCombo: ['KeyD'], section: 'panels' }, + { actionKey: 'toggle_crop_panel', description: 'Toggle Crop panel', defaultCombo: ['KeyR'], section: 'panels' }, + { actionKey: 'toggle_masks', description: 'Toggle Masks panel', defaultCombo: ['KeyM'], section: 'panels' }, + { actionKey: 'toggle_ai', description: 'Toggle AI panel', defaultCombo: ['KeyK'], section: 'panels' }, + { actionKey: 'toggle_presets', description: 'Toggle Presets panel', defaultCombo: ['KeyP'], section: 'panels' }, + { actionKey: 'toggle_metadata', description: 'Toggle Metadata panel', defaultCombo: ['KeyI'], section: 'panels' }, + { actionKey: 'toggle_analytics', description: 'Toggle Analytics display', defaultCombo: ['KeyA'], section: 'panels' }, + { actionKey: 'toggle_export', description: 'Toggle Export panel', defaultCombo: ['KeyE'], section: 'panels' }, + { actionKey: 'undo', description: 'Undo adjustment', defaultCombo: ['ctrl', 'KeyZ'], section: 'editing' }, + { actionKey: 'redo', description: 'Redo adjustment', defaultCombo: ['ctrl', 'KeyY'], section: 'editing' }, + { actionKey: 'copy_adjustments', description: 'Copy selected adjustments', defaultCombo: ['ctrl', 'KeyC'], section: 'editing' }, + { actionKey: 'paste_adjustments', description: 'Paste copied adjustments', defaultCombo: ['ctrl', 'KeyV'], section: 'editing' }, + { actionKey: 'rotate_left', description: 'Rotate 90° counter-clockwise', defaultCombo: ['BracketLeft'], section: 'editing' }, + { actionKey: 'rotate_right', description: 'Rotate 90° clockwise', defaultCombo: ['BracketRight'], section: 'editing' }, + { actionKey: 'toggle_crop', description: 'Toggle Crop / Straighten', defaultCombo: ['KeyS'], section: 'editing' }, + { actionKey: 'brush_size_up', description: 'Increase brush size', defaultCombo: ['ctrl', 'ArrowUp'], section: 'editing' }, + { actionKey: 'brush_size_down', description: 'Decrease brush size', defaultCombo: ['ctrl', 'ArrowDown'], section: 'editing' }, +]; + const symMap: Record = { Space: 'Space', Backspace: '⌫', From d23ebec334b6fb0b4de8cbe55d1496ebcdc145fb Mon Sep 17 00:00:00 2001 From: sefalkner Date: Fri, 24 Apr 2026 18:53:47 +0200 Subject: [PATCH 16/17] Rename keybind fields --- src-tauri/src/file_management.rs | 4 +- src/App.tsx | 2 +- src/components/panel/SettingsPanel.tsx | 82 ++++++++++---------- src/components/ui/AppProperties.tsx | 4 +- src/hooks/useKeyboardShortcuts.tsx | 28 +++---- src/utils/keyboardUtils.ts | 102 ++++++++++++------------- 6 files changed, 111 insertions(+), 111 deletions(-) diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index fbf464486..a7c79ed2f 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -462,7 +462,7 @@ pub struct AppSettings { #[serde(default)] pub zoom_speed_multiplier: Option, ======= - pub keybindings: HashMap>, + pub keybinds: HashMap>, >>>>>>> 25a8ddcc (Add data layer) } @@ -537,7 +537,7 @@ impl Default for AppSettings { canvas_input_mode: Some("mouse".to_string()), zoom_speed_multiplier: Some(1.0), ======= - keybindings: HashMap::new(), + keybinds: HashMap::new(), >>>>>>> 25a8ddcc (Add data layer) } } diff --git a/src/App.tsx b/src/App.tsx index 4dccf4f8d..17662e130 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3212,7 +3212,7 @@ function App() { displaySize, baseRenderSize, originalSize, - keybindings: appSettings?.keybindings, + keybinds: appSettings?.keybinds, brushSettings: brushSettings, setBrushSettings: setBrushSettings, }); diff --git a/src/components/panel/SettingsPanel.tsx b/src/components/panel/SettingsPanel.tsx index 766c9847b..b4e01caba 100644 --- a/src/components/panel/SettingsPanel.tsx +++ b/src/components/panel/SettingsPanel.tsx @@ -32,7 +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, KeybindingDefinition, KEYBINDING_DEFINITIONS, KEYBINDING_SECTIONS, normalizeCombo } from '../../utils/keyboardUtils'; +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'; @@ -58,12 +58,12 @@ interface DataActionItemProps { } interface KeybindRowProps { - def: KeybindingDefinition; + def: KeybindDefinition; currentCombo?: string[]; osPlatform: string; - onSave: (actionKey: string, combo: string[]) => void; + onSave: (action: string, combo: string[]) => void; recordingAction: string | null; - onStartRecording: (actionKey: string) => void; + onStartRecording: (action: string) => void; isConflicting: boolean; } @@ -154,26 +154,26 @@ const settingCategories = [ ]; const KeybindRow = ({ def, currentCombo, osPlatform, onSave, recordingAction, onStartRecording, isConflicting }: KeybindRowProps) => { - const recording = recordingAction === def.actionKey; + const recording = recordingAction === def.action; useEffect(() => { if (!recording) return; const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') { - onSave(def.actionKey, []); + 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.actionKey, parts); + onSave(def.action, parts); onStartRecording(''); } }; window.addEventListener('keydown', handler, { capture: true }); return () => window.removeEventListener('keydown', handler, { capture: true }); - }, [recording, def.actionKey, onSave, onStartRecording]); + }, [recording, def.action, onSave, onStartRecording]); const displayCombo = currentCombo !== undefined ? (currentCombo.length ? currentCombo : null) : def.defaultCombo; @@ -183,7 +183,7 @@ const KeybindRow = ({ def, currentCombo, osPlatform, onSave, recordingAction, on
{isConflicting && } diff --git a/src/components/ui/AppProperties.tsx b/src/components/ui/AppProperties.tsx index 5a442a0fa..0f7e61d78 100644 --- a/src/components/ui/AppProperties.tsx +++ b/src/components/ui/AppProperties.tsx @@ -183,7 +183,7 @@ export interface AppSettings { useWgpuRenderer?: boolean; canvasInputMode?: 'mouse' | 'trackpad'; zoomSpeedMultiplier?: number; - keybindings?: { [actionKey: string]: string[] }; + keybinds?: { [action: string]: string[] }; } export interface BrushSettings { @@ -335,7 +335,7 @@ export interface CullingSuggestions { failedPaths: string[]; } -export interface ActionHandler { +export interface KeybindHandler { shouldFire?: () => boolean; execute: (event: KeyboardEvent) => void; } diff --git a/src/hooks/useKeyboardShortcuts.tsx b/src/hooks/useKeyboardShortcuts.tsx index ef97aedc8..4e9d0940b 100644 --- a/src/hooks/useKeyboardShortcuts.tsx +++ b/src/hooks/useKeyboardShortcuts.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo } from 'react'; -import { ActionHandler, ImageFile, Panel, SelectedImage } from '../components/ui/AppProperties'; +import { KeybindHandler, ImageFile, Panel, SelectedImage } from '../components/ui/AppProperties'; import { BrushSettings } from '../components/ui/AppProperties'; -import { KeybindingDefinition, KEYBINDING_DEFINITIONS, normalizeCombo } from '../utils/keyboardUtils'; +import { KeybindDefinition, KEYBIND_DEFINITIONS, normalizeCombo } from '../utils/keyboardUtils'; interface KeyboardShortcutsProps { activeAiPatchContainerId?: string | null; @@ -31,7 +31,7 @@ interface KeyboardShortcutsProps { isFullScreen: boolean; isModalOpen: boolean; isStraightenActive: boolean; - keybindings?: { [actionKey: string]: string[] }; + keybinds?: { [action: string]: string[] }; libraryActivePath: string | null; multiSelectedPaths: Array; onSelectPatchContainer?(container: string | null): void; @@ -84,7 +84,7 @@ export const useKeyboardShortcuts = ({ isFullScreen, isModalOpen, isStraightenActive, - keybindings, + keybinds, libraryActivePath, multiSelectedPaths, onSelectPatchContainer, @@ -108,8 +108,8 @@ export const useKeyboardShortcuts = ({ brushSettings, setBrushSettings, }: KeyboardShortcutsProps) => { - function getEffectiveCombo(def: KeybindingDefinition): string[] | null { - const userCombo = keybindings?.[def.actionKey]; + function getEffectiveCombo(def: KeybindDefinition): string[] | null { + const userCombo = keybinds?.[def.action]; if (userCombo !== undefined) { return userCombo.length > 0 ? userCombo : null; } @@ -118,17 +118,17 @@ export const useKeyboardShortcuts = ({ const comboMap = useMemo(() => { const map = new Map(); - for (const def of KEYBINDING_DEFINITIONS) { + for (const def of KEYBIND_DEFINITIONS) { const effective = getEffectiveCombo(def); if (effective) { - map.set(effective.join('+'), def.actionKey); + map.set(effective.join('+'), def.action); } } return map; - }, [keybindings]); + }, [keybinds]); useEffect(() => { - const actions: Record = { + const actions: Record = { open_image: { shouldFire: () => !selectedImage && libraryActivePath !== null, execute: (event) => { event.preventDefault(); handleImageSelect(libraryActivePath!); }, @@ -475,9 +475,9 @@ export const useKeyboardShortcuts = ({ } const normalized = normalizeCombo(event, osPlatform); - const actionKey = comboMap.get(normalized.join('+')); - if (actionKey) { - const handler = actions[actionKey]; + const action = comboMap.get(normalized.join('+')); + if (action) { + const handler = actions[action]; if (handler && (!handler.shouldFire || handler.shouldFire())) { handler.execute(event); return; @@ -515,7 +515,7 @@ export const useKeyboardShortcuts = ({ handleZoomChange, isFullScreen, isStraightenActive, - keybindings, + keybinds, libraryActivePath, multiSelectedPaths, onSelectPatchContainer, diff --git a/src/utils/keyboardUtils.ts b/src/utils/keyboardUtils.ts index 0752b8ee5..37733a1e4 100644 --- a/src/utils/keyboardUtils.ts +++ b/src/utils/keyboardUtils.ts @@ -1,16 +1,16 @@ -export interface KeybindingDefinition { - actionKey: string; +export interface KeybindDefinition { + action: string; description: string; defaultCombo: string[]; section: 'library' | 'view' | 'rating' | 'panels' | 'editing'; } -export interface KeybindingSection { - id: KeybindingDefinition['section']; +export interface KeybindSection { + id: KeybindDefinition['section']; label: string; } -export const KEYBINDING_SECTIONS: KeybindingSection[] = [ +export const KEYBIND_SECTIONS: KeybindSection[] = [ { id: 'library', label: 'Library' }, { id: 'editing', label: 'Editing' }, { id: 'view', label: 'View' }, @@ -18,52 +18,52 @@ export const KEYBINDING_SECTIONS: KeybindingSection[] = [ { id: 'panels', label: 'Panels' }, ]; -export const KEYBINDING_DEFINITIONS: KeybindingDefinition[] = [ - { actionKey: 'open_image', description: 'Open selected image', defaultCombo: ['Enter'], section: 'library' }, - { actionKey: 'copy_files', description: 'Copy selected file(s)', defaultCombo: ['ctrl', 'shift', 'KeyC'], section: 'library' }, - { actionKey: 'paste_files', description: 'Paste file(s) to current folder', defaultCombo: ['ctrl', 'shift', 'KeyV'], section: 'library' }, - { actionKey: 'select_all', description: 'Select all images', defaultCombo: ['ctrl', 'KeyA'], section: 'library' }, - { actionKey: 'delete_selected', description: 'Delete selected file(s)', defaultCombo: ['Delete'], section: 'library' }, - { actionKey: 'preview_prev', description: 'Previous image', defaultCombo: ['ArrowLeft'], section: 'library' }, - { actionKey: 'preview_next', description: 'Next image', defaultCombo: ['ArrowRight'], section: 'library' }, - { actionKey: 'zoom_in_step', description: 'Zoom in (by step)', defaultCombo: ['ArrowUp'], section: 'view' }, - { actionKey: 'zoom_out_step', description: 'Zoom out (by step)', defaultCombo: ['ArrowDown'], section: 'view' }, - { actionKey: 'cycle_zoom', description: 'Cycle zoom (Fit, 2x Fit, 100%)', defaultCombo: ['Space'], section: 'view' }, - { actionKey: 'zoom_in', description: 'Zoom in', defaultCombo: ['ctrl', 'Equal'], section: 'view' }, - { actionKey: 'zoom_out', description: 'Zoom out', defaultCombo: ['ctrl', 'Minus'], section: 'view' }, - { actionKey: 'zoom_fit', description: 'Zoom to fit', defaultCombo: ['ctrl', 'Digit0'], section: 'view' }, - { actionKey: 'zoom_100', description: 'Zoom to 100%', defaultCombo: ['ctrl', 'Digit1'], section: 'view' }, - { actionKey: 'toggle_fullscreen', description: 'Toggle fullscreen', defaultCombo: ['KeyF'], section: 'view' }, - { actionKey: 'show_original', description: 'Show original (before/after)', defaultCombo: ['KeyB'], section: 'view' }, - { actionKey: 'rate_0', description: 'Star rating: 0', defaultCombo: ['Digit0'], section: 'rating' }, - { actionKey: 'rate_1', description: 'Star rating: 1', defaultCombo: ['Digit1'], section: 'rating' }, - { actionKey: 'rate_2', description: 'Star rating: 2', defaultCombo: ['Digit2'], section: 'rating' }, - { actionKey: 'rate_3', description: 'Star rating: 3', defaultCombo: ['Digit3'], section: 'rating' }, - { actionKey: 'rate_4', description: 'Star rating: 4', defaultCombo: ['Digit4'], section: 'rating' }, - { actionKey: 'rate_5', description: 'Star rating: 5', defaultCombo: ['Digit5'], section: 'rating' }, - { actionKey: 'color_label_none', description: 'Color label: None', defaultCombo: ['shift', 'Digit0'], section: 'rating' }, - { actionKey: 'color_label_red', description: 'Color label: Red', defaultCombo: ['shift', 'Digit1'], section: 'rating' }, - { actionKey: 'color_label_yellow', description: 'Color label: Yellow', defaultCombo: ['shift', 'Digit2'], section: 'rating' }, - { actionKey: 'color_label_green', description: 'Color label: Green', defaultCombo: ['shift', 'Digit3'], section: 'rating' }, - { actionKey: 'color_label_blue', description: 'Color label: Blue', defaultCombo: ['shift', 'Digit4'], section: 'rating' }, - { actionKey: 'color_label_purple', description: 'Color label: Purple', defaultCombo: ['shift', 'Digit5'], section: 'rating' }, - { actionKey: 'toggle_adjustments', description: 'Toggle Adjustments panel', defaultCombo: ['KeyD'], section: 'panels' }, - { actionKey: 'toggle_crop_panel', description: 'Toggle Crop panel', defaultCombo: ['KeyR'], section: 'panels' }, - { actionKey: 'toggle_masks', description: 'Toggle Masks panel', defaultCombo: ['KeyM'], section: 'panels' }, - { actionKey: 'toggle_ai', description: 'Toggle AI panel', defaultCombo: ['KeyK'], section: 'panels' }, - { actionKey: 'toggle_presets', description: 'Toggle Presets panel', defaultCombo: ['KeyP'], section: 'panels' }, - { actionKey: 'toggle_metadata', description: 'Toggle Metadata panel', defaultCombo: ['KeyI'], section: 'panels' }, - { actionKey: 'toggle_analytics', description: 'Toggle Analytics display', defaultCombo: ['KeyA'], section: 'panels' }, - { actionKey: 'toggle_export', description: 'Toggle Export panel', defaultCombo: ['KeyE'], section: 'panels' }, - { actionKey: 'undo', description: 'Undo adjustment', defaultCombo: ['ctrl', 'KeyZ'], section: 'editing' }, - { actionKey: 'redo', description: 'Redo adjustment', defaultCombo: ['ctrl', 'KeyY'], section: 'editing' }, - { actionKey: 'copy_adjustments', description: 'Copy selected adjustments', defaultCombo: ['ctrl', 'KeyC'], section: 'editing' }, - { actionKey: 'paste_adjustments', description: 'Paste copied adjustments', defaultCombo: ['ctrl', 'KeyV'], section: 'editing' }, - { actionKey: 'rotate_left', description: 'Rotate 90° counter-clockwise', defaultCombo: ['BracketLeft'], section: 'editing' }, - { actionKey: 'rotate_right', description: 'Rotate 90° clockwise', defaultCombo: ['BracketRight'], section: 'editing' }, - { actionKey: 'toggle_crop', description: 'Toggle Crop / Straighten', defaultCombo: ['KeyS'], section: 'editing' }, - { actionKey: 'brush_size_up', description: 'Increase brush size', defaultCombo: ['ctrl', 'ArrowUp'], section: 'editing' }, - { actionKey: 'brush_size_down', description: 'Decrease brush size', defaultCombo: ['ctrl', 'ArrowDown'], section: 'editing' }, +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 = { From 1739aa55b05fb43a73eaadaf86fb2501b1a16239 Mon Sep 17 00:00:00 2001 From: sefalkner Date: Fri, 24 Apr 2026 19:17:51 +0200 Subject: [PATCH 17/17] Move mouse controls to controls section --- src-tauri/src/file_management.rs | 7 +- src/components/panel/SettingsPanel.tsx | 88 +++++++++++++------------- 2 files changed, 45 insertions(+), 50 deletions(-) diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index a7c79ed2f..4618f51ee 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -457,13 +457,11 @@ pub struct AppSettings { #[serde(default)] pub use_wgpu_renderer: Option, #[serde(default)] -<<<<<<< HEAD pub canvas_input_mode: Option, #[serde(default)] pub zoom_speed_multiplier: Option, -======= + #[serde(default)] pub keybinds: HashMap>, ->>>>>>> 25a8ddcc (Add data layer) } fn default_adjustment_visibility() -> HashMap { @@ -533,12 +531,9 @@ impl Default for AppSettings { use_wgpu_renderer: Some(false), #[cfg(not(any(target_os = "linux", target_os = "android")))] use_wgpu_renderer: Some(true), -<<<<<<< HEAD canvas_input_mode: Some("mouse".to_string()), zoom_speed_multiplier: Some(1.0), -======= keybinds: HashMap::new(), ->>>>>>> 25a8ddcc (Add data layer) } } } diff --git a/src/components/panel/SettingsPanel.tsx b/src/components/panel/SettingsPanel.tsx index b4e01caba..0ea93ab16 100644 --- a/src/components/panel/SettingsPanel.tsx +++ b/src/components/panel/SettingsPanel.tsx @@ -939,44 +939,6 @@ const handleKeybindSave = (action: string, combo: string[]) => {
-
- - 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 @@ -1919,12 +1881,50 @@ const handleKeybindSave = (action: string, combo: string[]) => { initial={{ opacity: 0, x: 10 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -10 }} - transition={{ duration: 0.2 }} - className="space-y-10" - > -
- - Keyboard Controls + transition={{ duration: 0.2 }} + className="space-y-10" + > +
+ + Mouse Controls + +
+
+ + 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" + /> + +
+
+ +
+ + Keyboard Controls
{KEYBIND_SECTIONS.map((section) => { const sectionDefs = KEYBIND_DEFINITIONS.filter((d) => d.section === section.id);