diff --git a/src/App.tsx b/src/App.tsx index 1fee88963..f6cd7d663 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -92,7 +92,7 @@ import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; import GlobalTooltip from './components/ui/GlobalTooltip'; import { THEMES, DEFAULT_THEME_ID, ThemeProps } from './utils/themes'; import { SubMask, ToolType } from './components/panel/right/Masks'; -import { ExportState, IMPORT_TIMEOUT, ImportState, Status } from './components/ui/ExportImportProperties'; +import { ExportState, IMPORT_TIMEOUT, ImportState, Status, ExportSettings, FILE_FORMATS } from './components/ui/ExportImportProperties'; import { AppSettings, BrushSettings, @@ -3165,6 +3165,67 @@ function App() { denoiseModalState.isOpen || negativeModalState.isOpen; + const handleNumpadExport = useCallback(() => { + if (selectedImage) { + setMultiSelectedPaths([selectedImage.path]); + setIsLibraryExportPanelVisible(true); + } + }, [selectedImage]); + + const handleInstantExport = useCallback(async (): Promise => { + if (!selectedImage) return false; + + const numpadSettings = appSettings?.numpadSettings; + if (!numpadSettings?.defaultExportPresetId || !numpadSettings?.defaultExportPath) { + toast.error('Please configure default export preset and path in settings'); + return false; + } + + const preset = appSettings?.exportPresets?.find((p) => p.id === numpadSettings.defaultExportPresetId); + if (!preset) { + toast.error('Selected export preset not found'); + return false; + } + + try { + const exportSettings: ExportSettings = { + filenameTemplate: preset.filenameTemplate, + jpegQuality: preset.jpegQuality, + keepMetadata: preset.keepMetadata, + preserveTimestamps: preset.preserveTimestamps, + resize: preset.enableResize ? { mode: preset.resizeMode, value: preset.resizeValue, dontEnlarge: preset.dontEnlarge } : null, + stripGps: preset.stripGps, + exportMasks: preset.exportMasks ?? false, + watermark: + preset.enableWatermark && preset.watermarkPath + ? { + path: preset.watermarkPath, + anchor: preset.watermarkAnchor as any, + scale: preset.watermarkScale, + spacing: preset.watermarkSpacing, + opacity: preset.watermarkOpacity, + } + : null, + preserveFolders: preset.preserveFolders ?? false, + }; + + await invoke(Invokes.BatchExportImages, { + exportSettings, + outputFolder: numpadSettings.defaultExportPath, + outputFormat: FILE_FORMATS.find((f: any) => f.id === preset.fileFormat)?.extensions[0], + paths: [selectedImage.path], + baseOriginFolder: rootPath, + }); + + toast.success('Export successful'); + return true; + } catch (error) { + console.error('Error exporting image:', error); + toast.error(typeof error === 'string' ? error : 'Export failed'); + return false; + } + }, [selectedImage, appSettings, rootPath]); + useKeyboardShortcuts({ isModalOpen: isAnyModalOpen, osPlatform, @@ -3215,6 +3276,12 @@ function App() { originalSize, brushSettings: brushSettings, setBrushSettings: setBrushSettings, + numpadSettings: appSettings?.numpadSettings, + setNumpadSettings: (settings) => handleSettingsChange({ ...appSettings, numpadSettings: settings }), + handleExportCurrent: handleNumpadExport, + handleInstantExport, + adjustments, + setAdjustments, }); useEffect(() => { diff --git a/src/components/panel/SettingsPanel.tsx b/src/components/panel/SettingsPanel.tsx index bb9adc993..2eb6ef292 100644 --- a/src/components/panel/SettingsPanel.tsx +++ b/src/components/panel/SettingsPanel.tsx @@ -29,7 +29,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 } from '../ui/AppProperties'; +import { Invokes, NumpadSettings } from '../ui/AppProperties'; +import { ExportPreset } from '../ui/ExportImportProperties'; import Text from '../ui/Text'; import { TextColors, TextVariants, TextWeights } from '../../types/typography'; import { useOsPlatform } from '../../hooks/useOsPlatform'; @@ -334,6 +335,19 @@ export default function SettingsPanel({ const [tempLensMaker, setTempLensMaker] = useState(''); const [tempLensModel, setTempLensModel] = useState(''); + const [numpadSettings, setNumpadSettings] = useState( + appSettings?.numpadSettings || { + enabled: false, + mode: 'digital', + enterKeyMode: 'next', + stepSizes: { + exposure: 0.1, + contrast: 2, + rgbCmy: 1, + }, + } + ); + const osPlatform = useOsPlatform(); const [processingSettings, setProcessingSettings] = useState({ editorPreviewResolution: appSettings?.editorPreviewResolution || 1920, @@ -1858,6 +1872,285 @@ export default function SettingsPanel({ + +
+ + Numpad + + + Enable numpad shortcuts for rapid color and density adjustments, inspired by the Fuji Frontier SP-3000 scanner workflow. + {numpadSettings.mode === 'film' && ' Film mode provides RGB/CMY color control similar to traditional film scanners.'} + + +
+ + { + const newSettings = { ...numpadSettings, enabled: checked }; + setNumpadSettings(newSettings); + onSettingsChange({ ...appSettings, numpadSettings: newSettings }); + }} + /> + + + {numpadSettings.enabled && ( + <> + +
+ {[ + { id: 'digital', label: 'Digital' }, + { id: 'film', label: 'Film' }, + ].map((mode) => ( + + ))} +
+
+ + {numpadSettings.mode === 'film' && ( +
+ + ⚠️ Film mode is experimental. RGB/CMY color offsets are not yet applied to image rendering. Use Digital mode for full functionality. + +
+ )} + + +
+ {[ + { id: 'next', label: 'Next Image' }, + { id: 'instant-export', label: 'Instant Export' }, + { id: 'skip-move', label: 'Skip & Move' }, + ].map((enterMode) => ( + + ))} +
+
+ + {numpadSettings.enterKeyMode === 'instant-export' && ( + <> + +
+ +
+
+ + +
+
+ {numpadSettings.defaultExportPath || 'Not set'} +
+ +
+
+ + )} + +
+ Step Sizes + + + { + const newSettings = { + ...numpadSettings, + stepSizes: { ...numpadSettings.stepSizes, exposure: parseFloat(e.target.value) }, + }; + setNumpadSettings(newSettings); + onSettingsChange({ ...appSettings, numpadSettings: newSettings }); + }} + step={0.01} + value={numpadSettings.stepSizes.exposure} + /> + + + + { + const newSettings = { + ...numpadSettings, + stepSizes: { ...numpadSettings.stepSizes, contrast: parseFloat(e.target.value) }, + }; + setNumpadSettings(newSettings); + onSettingsChange({ ...appSettings, numpadSettings: newSettings }); + }} + step={0.5} + value={numpadSettings.stepSizes.contrast} + /> + + + {numpadSettings.mode === 'film' && ( + + { + const newSettings = { + ...numpadSettings, + stepSizes: { ...numpadSettings.stepSizes, rgbCmy: parseFloat(e.target.value) }, + }; + setNumpadSettings(newSettings); + onSettingsChange({ ...appSettings, numpadSettings: newSettings }); + }} + step={0.1} + value={numpadSettings.stepSizes.rgbCmy} + /> + + )} +
+ +
+ + Key Mapping + +
+ {numpadSettings.mode === 'film' && } + {numpadSettings.mode === 'film' && } + {numpadSettings.mode === 'film' && ( + + )} + + + + +
+
+ + )} +
+
)} diff --git a/src/components/ui/AppProperties.tsx b/src/components/ui/AppProperties.tsx index cd464b76f..95b313c47 100644 --- a/src/components/ui/AppProperties.tsx +++ b/src/components/ui/AppProperties.tsx @@ -145,6 +145,19 @@ export enum ThumbnailAspectRatio { Contain = 'contain', } +export interface NumpadSettings { + enabled: boolean; + mode: 'digital' | 'film'; + enterKeyMode: 'next' | 'instant-export' | 'skip-move'; + stepSizes: { + exposure: number; + contrast: number; + rgbCmy: number; + }; + defaultExportPresetId?: string; + defaultExportPath?: string; +} + export interface AppSettings { aiConnectorAddress?: string; decorations?: any; @@ -181,6 +194,7 @@ export interface AppSettings { waveformHeight?: number; activeWaveformChannel?: string; useWgpuRenderer?: boolean; + numpadSettings?: NumpadSettings; } export interface BrushSettings { diff --git a/src/hooks/useKeyboardShortcuts.tsx b/src/hooks/useKeyboardShortcuts.tsx index 606c58204..e07d6d80a 100644 --- a/src/hooks/useKeyboardShortcuts.tsx +++ b/src/hooks/useKeyboardShortcuts.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; -import { ImageFile, Panel, SelectedImage } from '../components/ui/AppProperties'; +import { ImageFile, Panel, SelectedImage, NumpadSettings } from '../components/ui/AppProperties'; import { BrushSettings } from '../components/ui/AppProperties'; +import { Adjustments, INITIAL_FILM_OFFSETS } from '../utils/adjustments'; interface KeyboardShortcutsProps { activeAiPatchContainerId?: string | null; @@ -51,8 +52,14 @@ 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; + numpadSettings?: NumpadSettings; + setNumpadSettings?: (settings: NumpadSettings) => void; + handleExportCurrent?: () => void; + handleInstantExport?: () => Promise; + adjustments: Adjustments; + setAdjustments: (adjustments: Partial | ((prev: Adjustments) => Adjustments)) => void; } export const useKeyboardShortcuts = ({ @@ -104,8 +111,14 @@ export const useKeyboardShortcuts = ({ displaySize, baseRenderSize, originalSize, - brushSettings, - setBrushSettings, + brushSettings, + setBrushSettings, + numpadSettings, + setNumpadSettings, + handleExportCurrent, + handleInstantExport, + adjustments, + setAdjustments, }: KeyboardShortcutsProps) => { useEffect(() => { const handleKeyDown = (event: any) => { @@ -242,7 +255,7 @@ export const useKeyboardShortcuts = ({ } } } else { - if ((key === 'enter' || key === ' ') && !isCtrl) { + if ((key === 'enter' || key === ' ') && !isCtrl && code !== 'NumpadEnter') { event.preventDefault(); if (libraryActivePath) { handleImageSelect(libraryActivePath); @@ -314,7 +327,7 @@ export const useKeyboardShortcuts = ({ } } - if (code.startsWith('Digit') && !isCtrl) { + if (code.startsWith('Digit') && !isCtrl && !code.startsWith('Numpad')) { event.preventDefault(); const keyNum = parseInt(code.replace('Digit', ''), 10); @@ -330,7 +343,7 @@ export const useKeyboardShortcuts = ({ handleRate(keyNum); } } - } else if (['0', '1', '2', '3', '4', '5'].includes(key) && !isCtrl) { + } else if (['0', '1', '2', '3', '4', '5'].includes(key) && !isCtrl && !code.startsWith('Numpad')) { event.preventDefault(); handleRate(parseInt(key, 10)); } @@ -364,6 +377,234 @@ export const useKeyboardShortcuts = ({ } } + // Numpad shortcuts for film-style adjustments + if (numpadSettings?.enabled && selectedImage) { + const stepSizes = numpadSettings.stepSizes; + const mode = numpadSettings.mode; + + // Handle numpad keys + if (code.startsWith('Numpad') && !isCtrl) { + event.preventDefault(); + + switch (code) { + case 'Numpad7': + // - Yellow / + Blue + if (mode === 'digital') { + setAdjustments((prev: Adjustments) => ({ + ...prev, + temperature: (prev.temperature || 0) - stepSizes.rgbCmy, + })); + } else { + setAdjustments((prev: Adjustments) => ({ + ...prev, + filmColorOffsets: { + ...(prev.filmColorOffsets || INITIAL_FILM_OFFSETS), + yellowBlue: (prev.filmColorOffsets?.yellowBlue || 0) - stepSizes.rgbCmy, + }, + })); + } + break; + case 'Numpad4': + // + Yellow / - Blue + if (mode === 'digital') { + setAdjustments((prev: Adjustments) => ({ + ...prev, + temperature: (prev.temperature || 0) + stepSizes.rgbCmy, + })); + } else { + setAdjustments((prev: Adjustments) => ({ + ...prev, + filmColorOffsets: { + ...(prev.filmColorOffsets || INITIAL_FILM_OFFSETS), + yellowBlue: (prev.filmColorOffsets?.yellowBlue || 0) + stepSizes.rgbCmy, + }, + })); + } + break; + case 'Numpad8': + // - Magenta / + Green + if (mode === 'digital') { + setAdjustments((prev: Adjustments) => ({ + ...prev, + tint: (prev.tint || 0) - stepSizes.rgbCmy, + })); + } else { + setAdjustments((prev: Adjustments) => ({ + ...prev, + filmColorOffsets: { + ...(prev.filmColorOffsets || INITIAL_FILM_OFFSETS), + magentaGreen: (prev.filmColorOffsets?.magentaGreen || 0) - stepSizes.rgbCmy, + }, + })); + } + break; + case 'Numpad5': + // + Magenta / - Green + if (mode === 'digital') { + setAdjustments((prev: Adjustments) => ({ + ...prev, + tint: (prev.tint || 0) + stepSizes.rgbCmy, + })); + } else { + setAdjustments((prev: Adjustments) => ({ + ...prev, + filmColorOffsets: { + ...(prev.filmColorOffsets || INITIAL_FILM_OFFSETS), + magentaGreen: (prev.filmColorOffsets?.magentaGreen || 0) + stepSizes.rgbCmy, + }, + })); + } + break; + case 'Numpad9': + // - Cyan / + Red (Film mode only) + if (mode === 'film') { + setAdjustments((prev: Adjustments) => ({ + ...prev, + filmColorOffsets: { + ...(prev.filmColorOffsets || INITIAL_FILM_OFFSETS), + cyanRed: (prev.filmColorOffsets?.cyanRed || 0) - stepSizes.rgbCmy, + }, + })); + } + break; + case 'Numpad6': + // + Cyan / - Red (Film mode only) + if (mode === 'film') { + setAdjustments((prev: Adjustments) => ({ + ...prev, + filmColorOffsets: { + ...(prev.filmColorOffsets || INITIAL_FILM_OFFSETS), + cyanRed: (prev.filmColorOffsets?.cyanRed || 0) + stepSizes.rgbCmy, + }, + })); + } + break; + case 'Numpad1': + // - Density (brighten) + if (mode === 'digital') { + setAdjustments((prev: Adjustments) => ({ + ...prev, + exposure: Math.max(-5, Math.min(5, (prev.exposure || 0) + stepSizes.exposure)), + })); + } else { + setAdjustments((prev: Adjustments) => ({ + ...prev, + exposure: Math.max(-5, Math.min(5, (prev.exposure || 0) + stepSizes.exposure)), + blacks: (prev.blacks || 0) + 2, + })); + } + break; + case 'NumpadDecimal': + // + Density (darken) + if (mode === 'digital') { + setAdjustments((prev: Adjustments) => ({ + ...prev, + exposure: Math.max(-5, Math.min(5, (prev.exposure || 0) - stepSizes.exposure)), + })); + } else { + setAdjustments((prev: Adjustments) => ({ + ...prev, + exposure: Math.max(-5, Math.min(5, (prev.exposure || 0) - stepSizes.exposure)), + blacks: (prev.blacks || 0) - 2, + })); + } + break; + case 'Numpad2': + // - Contrast (soften) + setAdjustments((prev: Adjustments) => ({ + ...prev, + contrast: Math.max(-100, Math.min(100, (prev.contrast || 0) - stepSizes.contrast)), + })); + break; + case 'Numpad3': + // + Contrast (harden) + setAdjustments((prev: Adjustments) => ({ + ...prev, + contrast: Math.max(-100, Math.min(100, (prev.contrast || 0) + stepSizes.contrast)), + })); + break; + case 'Numpad0': + // Reset color adjustments + if (mode === 'digital') { + setAdjustments((prev: Adjustments) => ({ + ...prev, + temperature: 0, + tint: 0, + })); + } else { + setAdjustments((prev: Adjustments) => ({ + ...prev, + filmColorOffsets: { ...INITIAL_FILM_OFFSETS }, + })); + } + break; + case 'NumpadEnter': + // Handle based on enterKeyMode + switch (numpadSettings.enterKeyMode) { + case 'next': + // Move to next image + const nextIndex = sortedImageList.findIndex( + (img: ImageFile) => img.path === selectedImage.path + ); + if (nextIndex !== -1) { + let newIndex = nextIndex + 1; + if (newIndex >= sortedImageList.length) { + newIndex = 0; + } + const nextImage = sortedImageList[newIndex]; + if (nextImage) { + handleImageSelect(nextImage.path); + } + } + break; + case 'instant-export': + // Export and move to next after completion + if (handleInstantExport) { + (async () => { + const exportSuccess = await handleInstantExport(); + if (exportSuccess) { + const currentIndex = sortedImageList.findIndex( + (img: ImageFile) => img.path === selectedImage.path + ); + if (currentIndex !== -1) { + let newIndex = currentIndex + 1; + if (newIndex >= sortedImageList.length) { + newIndex = 0; + } + const nextImage = sortedImageList[newIndex]; + if (nextImage) { + handleImageSelect(nextImage.path); + } + } + } + // If export failed, stay on current image (error shown in handleInstantExport) + })(); + } + break; + case 'skip-move': + // Just move to next (revert metadata changes) + const skipIndex = sortedImageList.findIndex( + (img: ImageFile) => img.path === selectedImage.path + ); + if (skipIndex !== -1) { + let newIndex = skipIndex + 1; + if (newIndex >= sortedImageList.length) { + newIndex = 0; + } + const nextImage = sortedImageList[newIndex]; + if (nextImage) { + handleImageSelect(nextImage.path); + } + } + break; + } + break; + default: + break; + } + } + } + if (isCtrl) { const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; const currentPercent = @@ -502,7 +743,12 @@ export const useKeyboardShortcuts = ({ displaySize, baseRenderSize, originalSize, - brushSettings, - setBrushSettings, + brushSettings, + setBrushSettings, + numpadSettings, + setNumpadSettings, + handleExportCurrent, + adjustments, + setAdjustments, ]); }; diff --git a/src/utils/adjustments.tsx b/src/utils/adjustments.tsx index 0ca4e3571..5a0ba5fa5 100644 --- a/src/utils/adjustments.tsx +++ b/src/utils/adjustments.tsx @@ -124,6 +124,12 @@ export interface ColorCalibration { blueSaturation: number; } +export interface FilmColorOffsets { + cyanRed: number; + magentaGreen: number; + yellowBlue: number; +} + export interface Adjustments { [index: string]: any; aiPatches: Array; @@ -185,6 +191,7 @@ export interface Adjustments { shadows: number; sharpness: number; showClipping: boolean; + filmColorOffsets?: FilmColorOffsets; structure: number; temperature: number; tint: number; @@ -346,6 +353,12 @@ const INITIAL_COLOR_CALIBRATION: ColorCalibration = { blueSaturation: 0, }; +export const INITIAL_FILM_OFFSETS: FilmColorOffsets = { + cyanRed: 0, + magentaGreen: 0, + yellowBlue: 0, +}; + export const INITIAL_MASK_ADJUSTMENTS: MaskAdjustments = { blacks: 0, brightness: 0, @@ -496,6 +509,7 @@ export const INITIAL_ADJUSTMENTS: Adjustments = { shadows: 0, sharpness: 0, showClipping: false, + filmColorOffsets: { ...INITIAL_FILM_OFFSETS }, structure: 0, temperature: 0, tint: 0, @@ -590,6 +604,7 @@ export const normalizeLoadedAdjustments = (loadedAdjustments: Adjustments): any colorGrading: { ...INITIAL_ADJUSTMENTS.colorGrading, ...(loadedAdjustments.colorGrading || {}) }, hsl: { ...INITIAL_ADJUSTMENTS.hsl, ...(loadedAdjustments.hsl || {}) }, curves: { ...INITIAL_ADJUSTMENTS.curves, ...(loadedAdjustments.curves || {}) }, + filmColorOffsets: { ...INITIAL_ADJUSTMENTS.filmColorOffsets, ...(loadedAdjustments.filmColorOffsets || {}) }, masks: normalizedMasks, aiPatches: normalizedAiPatches, sectionVisibility: {