diff --git a/src/App.tsx b/src/App.tsx index 8da2a292a..bc4f407fb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,45 +1,7 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { invoke } from '@tauri-apps/api/core'; -import { listen } from '@tauri-apps/api/event'; -import { open } from '@tauri-apps/plugin-dialog'; -import { homeDir } from '@tauri-apps/api/path'; -import { getCurrentWindow } from '@tauri-apps/api/window'; -import debounce from 'lodash.debounce'; -import throttle from 'lodash.throttle'; -import { ClerkProvider } from '@clerk/clerk-react'; -import { ToastContainer, toast, Slide } from 'react-toastify'; +import { ToastContainer, Slide } from 'react-toastify'; import clsx from 'clsx'; -import { - Aperture, - Check, - ClipboardPaste, - Copy, - CopyPlus, - Edit, - FileEdit, - Folder, - FolderInput, - FolderPlus, - Images, - LayoutTemplate, - Redo, - RotateCcw, - Star, - Save, - SquaresUnite, - Palette, - Tag, - Trash2, - Undo, - X, - Pin, - PinOff, - Users, - Gauge, - Grip, - Film, -} from 'lucide-react'; import TitleBar from './window/TitleBar'; import CommunityPage from './components/panel/CommunityPage'; import MainLibrary from './components/panel/MainLibrary'; @@ -47,4375 +9,469 @@ import FolderTree from './components/panel/FolderTree'; import Editor from './components/panel/Editor'; import Controls from './components/panel/right/ControlsPanel'; import { useThumbnails } from './hooks/useThumbnails'; -import { ImageDimensions } from './hooks/useImageRenderSize'; import RightPanelSwitcher from './components/panel/right/RightPanelSwitcher'; import MetadataPanel from './components/panel/right/MetadataPanel'; -import CropPanel, { type OverlayMode } from './components/panel/right/CropPanel'; +import CropPanel from './components/panel/right/CropPanel'; import PresetsPanel from './components/panel/right/PresetsPanel'; import AIPanel from './components/panel/right/AIPanel'; import ExportPanel from './components/panel/right/ExportPanel'; import LibraryExportPanel from './components/panel/right/LibraryExportPanel'; import MasksPanel from './components/panel/right/MasksPanel'; -import BottomBar from './components/panel/BottomBar'; -import { ContextMenuProvider, useContextMenu } from './context/ContextMenuContext'; -import TaggingSubMenu from './context/TaggingSubMenu'; -import CreateFolderModal from './components/modals/CreateFolderModal'; -import RenameFolderModal from './components/modals/RenameFolderModal'; -import ConfirmModal from './components/modals/ConfirmModal'; -import ImportSettingsModal from './components/modals/ImportSettingsModal'; -import RenameFileModal from './components/modals/RenameFileModal'; -import PanoramaModal from './components/modals/PanoramaModal'; -import NegativeConversionModal from './components/modals/NegativeConversionModal'; -import DenoiseModal from './components/modals/DenoiseModal'; -import CollageModal from './components/modals/CollageModal'; -import CopyPasteSettingsModal from './components/modals/CopyPasteSettingsModal'; -import CullingModal from './components/modals/CullingModal'; -import { useHistoryState } from './hooks/useHistoryState'; -import Resizer from './components/ui/Resizer'; -import { - Adjustments, - AiPatch, - Color, - COLOR_LABELS, - Coord, - COPYABLE_ADJUSTMENT_KEYS, - INITIAL_ADJUSTMENTS, - MaskContainer, - normalizeLoadedAdjustments, - PasteMode, - CopyPasteSettings, -} from './utils/adjustments'; -import { generatePaletteFromImage } from './utils/palette'; -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 { - AppSettings, - BrushSettings, - FilterCriteria, - Invokes, - ImageFile, - Option, - OPTION_SEPARATOR, - LibraryViewMode, - Panel, - Progress, - RawStatus, - SelectedImage, - SortCriteria, - SortDirection, - SupportedTypes, - Theme, - TransformState, - UiVisibility, - WaveformData, - Orientation, - ThumbnailSize, - ThumbnailAspectRatio, - CullingSuggestions, -} from './components/ui/AppProperties'; -import { ChannelConfig } from './components/adjustments/Curves'; -import HdrModal from './components/modals/HdrModal'; - -const CLERK_PUBLISHABLE_KEY = 'pk_test_YnJpZWYtc2Vhc25haWwtMTIuY2xlcmsuYWNjb3VudHMuZGV2JA'; // local dev key - -interface CollapsibleSectionsState { - basic: boolean; - color: boolean; - curves: boolean; - details: boolean; - effects: boolean; -} - -interface ConfirmModalState { - confirmText?: string; - confirmVariant?: string; - isOpen: boolean; - message?: string; - onConfirm?(): void; - title?: string; -} - -interface Metadata { - adjustments: Adjustments; - rating: number; - tags: Array | null; - version: number; -} - -interface MultiSelectOptions { - onSimpleClick(p: any): void; - updateLibraryActivePath: boolean; - shiftAnchor: string | null; -} - -interface CollageModalState { - isOpen: boolean; - sourceImages: ImageFile[]; -} - -interface PanoramaModalState { - error: string | null; - finalImageBase64: string | null; - isOpen: boolean; - progressMessage: string | null; - stitchingSourcePaths: Array; -} - -interface HdrModalState { - error: string | null; - finalImageBase64: string | null; - isOpen: boolean; - progressMessage: string | null; - stitchingSourcePaths: Array; -} - -interface DenoiseModalState { - isOpen: boolean; - isProcessing: boolean; - previewBase64: string | null; - originalBase64?: string | null; - error: string | null; - targetPath: string | null; - progressMessage: string | null; -} - -interface NegativeConversionModalState { - isOpen: boolean; - targetPath: string | null; -} - -interface CullingModalState { - isOpen: boolean; - suggestions: CullingSuggestions | null; - progress: { current: number; total: number; stage: string } | null; - error: string | null; - pathsToCull: Array; -} - -interface LutData { - size: number; -} - -interface SearchCriteria { - tags: string[]; - text: string; - mode: 'AND' | 'OR'; -} - -const RIGHT_PANEL_ORDER = [ - Panel.Metadata, - Panel.Adjustments, - Panel.Crop, - Panel.Masks, - Panel.Ai, - Panel.Presets, - Panel.Export, -]; - -const DEBUG = false; - -const getParentDir = (filePath: string): string => { - const separator = filePath.includes('/') ? '/' : '\\'; - const lastSeparatorIndex = filePath.lastIndexOf(separator); - if (lastSeparatorIndex === -1) { - return ''; - } - return filePath.substring(0, lastSeparatorIndex); -}; - -function App() { - const [rootPath, setRootPath] = useState(null); - const [appSettings, setAppSettings] = useState(null); - const [activeView, setActiveView] = useState('library'); - const [isWindowFullScreen, setIsWindowFullScreen] = useState(false); - const [isLayoutReady, setIsLayoutReady] = useState(false); - const [currentFolderPath, setCurrentFolderPath] = useState(null); - const [expandedFolders, setExpandedFolders] = useState(new Set()); - const [folderTree, setFolderTree] = useState(null); - const [pinnedFolderTrees, setPinnedFolderTrees] = useState([]); - const [imageList, setImageList] = useState>([]); - const [imageRatings, setImageRatings] = useState>({}); - const [sortCriteria, setSortCriteria] = useState({ key: 'name', order: SortDirection.Ascending }); - const [filterCriteria, setFilterCriteria] = useState({ - colors: [], - rating: 0, - rawStatus: RawStatus.All, - }); - const [supportedTypes, setSupportedTypes] = useState(null); - const [selectedImage, setSelectedImage] = useState(null); - const selectedImagePathRef = useRef(null); - useEffect(() => { - selectedImagePathRef.current = selectedImage?.path ?? null; - }, [selectedImage?.path]); - const [multiSelectedPaths, setMultiSelectedPaths] = useState>([]); - const [libraryActivePath, setLibraryActivePath] = useState(null); - const [libraryActiveAdjustments, setLibraryActiveAdjustments] = useState(INITIAL_ADJUSTMENTS); - const [finalPreviewUrl, setFinalPreviewUrl] = useState(null); - const [uncroppedAdjustedPreviewUrl, setUncroppedAdjustedPreviewUrl] = useState(null); - const { - state: historyAdjustments, - setState: setHistoryAdjustments, - undo: undoAdjustments, - redo: redoAdjustments, - canUndo, - canRedo, - resetHistory: resetAdjustmentsHistory, - history: adjustmentsHistory, - historyIndex: adjustmentsHistoryIndex, - goToIndex: goToAdjustmentsHistoryIndex, - } = useHistoryState(INITIAL_ADJUSTMENTS); - const [adjustments, setLiveAdjustments] = useState(INITIAL_ADJUSTMENTS); - const [showOriginal, setShowOriginal] = useState(false); - const [isTreeLoading, setIsTreeLoading] = useState(false); - const [isViewLoading, setIsViewLoading] = useState(false); - const [initialFileToOpen, setInitialFileToOpen] = useState(null); - const [error, setError] = useState(null); - const [histogram, setHistogram] = useState(null); - const [waveform, setWaveform] = useState(null); - const [isWaveformVisible, setIsWaveformVisible] = useState(false); - const [uiVisibility, setUiVisibility] = useState({ - folderTree: true, - filmstrip: true, - }); - const [isSliderDragging, setIsSliderDragging] = useState(false); - const dragIdleTimer = useRef | null>(null); - const [isFullScreen, setIsFullScreen] = useState(false); - const [isHighResNeeded, setIsHighResNeeded] = useState(false); - const [isAnimatingTheme, setIsAnimatingTheme] = useState(false); - const isInitialThemeMount = useRef(true); - const [theme, setTheme] = useState(DEFAULT_THEME_ID); - const [adaptivePalette, setAdaptivePalette] = useState(null); - const [activeRightPanel, setActiveRightPanel] = useState(Panel.Adjustments); - const [slideDirection, setSlideDirection] = useState(1); - const [activeMaskContainerId, setActiveMaskContainerId] = useState(null); - const [activeMaskId, setActiveMaskId] = useState(null); - const [activeAiPatchContainerId, setActiveAiPatchContainerId] = useState(null); - const [activeAiSubMaskId, setActiveAiSubMaskId] = useState(null); - const [zoom, setZoom] = useState(1); - const [displaySize, setDisplaySize] = useState({ width: 0, height: 0 }); - const [previewSize, setPreviewSize] = useState({ width: 0, height: 0 }); - const [baseRenderSize, setBaseRenderSize] = useState({ width: 0, height: 0 }); - const [originalSize, setOriginalSize] = useState({ width: 0, height: 0 }); - const [isLoadingFullRes, setIsLoadingFullRes] = useState(false); - const [isRotationActive, setIsRotationActive] = useState(false); - const [overlayMode, setOverlayMode] = useState('thirds'); - const [overlayRotation, setOverlayRotation] = useState(0); - const [transformedOriginalUrl, setTransformedOriginalUrl] = useState(null); - const fullResCacheKeyRef = useRef(null); - const patchesSentToBackend = useRef>(new Set()); - - const handleDisplaySizeChange = useCallback((size: ImageDimensions & { scale?: number }) => { - setDisplaySize({ width: size.width, height: size.height }); - - if (size.scale) { - const baseWidth = size.width / size.scale; - const baseHeight = size.height / size.scale; - setBaseRenderSize({ width: baseWidth, height: baseHeight }); - } - }, []); - - const [initialFitScale, setInitialFitScale] = useState(null); - const [renderedRightPanel, setRenderedRightPanel] = useState(activeRightPanel); - const [collapsibleSectionsState, setCollapsibleSectionsState] = useState({ - basic: true, - color: false, - curves: true, - details: false, - effects: false, - }); - const [isLibraryExportPanelVisible, setIsLibraryExportPanelVisible] = useState(false); - const [libraryViewMode, setLibraryViewMode] = useState(LibraryViewMode.Flat); - const [leftPanelWidth, setLeftPanelWidth] = useState(256); - const [rightPanelWidth, setRightPanelWidth] = useState(320); - const [bottomPanelHeight, setBottomPanelHeight] = useState(144); - const [activeTreeSection, setActiveTreeSection] = useState('current'); - const [isResizing, setIsResizing] = useState(false); - const [thumbnailSize, setThumbnailSize] = useState(ThumbnailSize.Medium); - const [thumbnailAspectRatio, setThumbnailAspectRatio] = useState(ThumbnailAspectRatio.Cover); - const [copiedAdjustments, setCopiedAdjustments] = useState(null); - const [isStraightenActive, setIsStraightenActive] = useState(false); - const [isWbPickerActive, setIsWbPickerActive] = useState(false); - const [copiedFilePaths, setCopiedFilePaths] = useState>([]); - const [aiModelDownloadStatus, setAiModelDownloadStatus] = useState(null); - const [copiedSectionAdjustments, setCopiedSectionAdjustments] = useState(null); - const [copiedMask, setCopiedMask] = useState(null); - const [isCopied, setIsCopied] = useState(false); - const [isPasted, setIsPasted] = useState(false); - const [isIndexing, setIsIndexing] = useState(false); - const [indexingProgress, setIndexingProgress] = useState({ current: 0, total: 0 }); - const [searchCriteria, setSearchCriteria] = useState({ - tags: [], - text: '', - mode: 'OR', - }); - const [brushSettings, setBrushSettings] = useState({ - size: 50, - feather: 50, - tool: ToolType.Brush, - }); - const [isCreateFolderModalOpen, setIsCreateFolderModalOpen] = useState(false); - const [isRenameFolderModalOpen, setIsRenameFolderModalOpen] = useState(false); - const [isRenameFileModalOpen, setIsRenameFileModalOpen] = useState(false); - const [renameTargetPaths, setRenameTargetPaths] = useState>([]); - const [isImportModalOpen, setIsImportModalOpen] = useState(false); - const [isCopyPasteSettingsModalOpen, setIsCopyPasteSettingsModalOpen] = useState(false); - const [importTargetFolder, setImportTargetFolder] = useState(null); - const [importSourcePaths, setImportSourcePaths] = useState>([]); - const [folderActionTarget, setFolderActionTarget] = useState(null); - const [confirmModalState, setConfirmModalState] = useState({ isOpen: false }); - const [panoramaModalState, setPanoramaModalState] = useState({ - error: null, - finalImageBase64: null, - isOpen: false, - progressMessage: '', - stitchingSourcePaths: [], - }); - const [hdrModalState, setHdrModalState] = useState({ - error: null, - finalImageBase64: null, - isOpen: false, - progressMessage: '', - stitchingSourcePaths: [], - }); - const [negativeModalState, setNegativeModalState] = useState({ - isOpen: false, - targetPath: null, - }); - const [denoiseModalState, setDenoiseModalState] = useState({ - isOpen: false, - isProcessing: false, - previewBase64: null, - error: null, - targetPath: null, - progressMessage: null, - }); - const [cullingModalState, setCullingModalState] = useState({ - isOpen: false, - suggestions: null, - progress: null, - error: null, - pathsToCull: [], - }); - const [collageModalState, setCollageModalState] = useState({ - isOpen: false, - sourceImages: [], - }); - const [customEscapeHandler, setCustomEscapeHandler] = useState(null); - const [isGeneratingAiMask, setIsGeneratingAiMask] = useState(false); - const [isAIConnectorConnected, setisAIConnectorConnected] = useState(false); - const [isGeneratingAi, setIsGeneratingAi] = useState(false); - const [isMaskControlHovered, setIsMaskControlHovered] = useState(false); - const [libraryScrollTop, setLibraryScrollTop] = useState(0); - const { showContextMenu } = useContextMenu(); - const [thumbnails, setThumbnails] = useState>({}); - const { loading: isThumbnailsLoading } = useThumbnails(imageList, setThumbnails); - const transformWrapperRef = useRef(null); - const isProgrammaticZoom = useRef(false); - const isInitialMount = useRef(true); - const currentFolderPathRef = useRef(currentFolderPath); - const preloadedDataRef = useRef<{ - tree?: Promise; - images?: Promise; - rootPath?: string; - currentPath?: string; - }>({}); - const previewJobIdRef = useRef(0); - const latestRenderedJobIdRef = useRef(0); - - useEffect(() => { - if (currentFolderPath) { - preloadedDataRef.current = { - ...preloadedDataRef.current, - currentPath: currentFolderPath, - images: Promise.resolve(imageList), - }; - } - }, [currentFolderPath, imageList]); - - useEffect(() => { - if (rootPath && folderTree) { - preloadedDataRef.current = { - ...preloadedDataRef.current, - rootPath: rootPath, - tree: Promise.resolve(folderTree), - }; - } - }, [rootPath, folderTree]); - - const [exportState, setExportState] = useState({ - errorMessage: '', - progress: { current: 0, total: 0 }, - status: Status.Idle, - }); - - const [importState, setImportState] = useState({ - errorMessage: '', - path: '', - progress: { current: 0, total: 0 }, - status: Status.Idle, - }); - - useEffect(() => { - currentFolderPathRef.current = currentFolderPath; - }, [currentFolderPath]); - - useEffect(() => { - if (!isCopied) { - return; - } - const timer = setTimeout(() => setIsCopied(false), 1000); - return () => clearTimeout(timer); - }, [isCopied]); - - useEffect(() => { - if (!isPasted) { - return; - } - const timer = setTimeout(() => setIsPasted(false), 1000); - return () => clearTimeout(timer); - }, [isPasted]); - - const isLightTheme = useMemo(() => [Theme.Light, Theme.Snow, Theme.Arctic].includes(theme as Theme), [theme]); - - useEffect(() => { - if (error) { - toast.error(error); - setError(null); - } - }, [error]); - - const debouncedSetHistory = useMemo( - () => debounce((newAdjustments) => setHistoryAdjustments(newAdjustments), 300), - [setHistoryAdjustments], - ); - - const setAdjustments = useCallback( - (value: any) => { - setLiveAdjustments((prevAdjustments: Adjustments) => { - const newAdjustments = typeof value === 'function' ? value(prevAdjustments) : value; - debouncedSetHistory(newAdjustments); - return newAdjustments; - }); - }, - [debouncedSetHistory], - ); - - const handleStraighten = useCallback( - (angleCorrection: number) => { - setAdjustments((prev: Partial) => { - const newRotation = (prev.rotation || 0) + angleCorrection; - return { ...prev, rotation: newRotation, crop: null }; - }); - - setIsStraightenActive(false); - }, - [setAdjustments], - ); - - const toggleWbPicker = useCallback(() => { - setIsWbPickerActive((prev) => !prev); - }, []); - - const handleWbPicked = useCallback(() => { - //setIsWbPickerActive(false); // lets keep it active - }, []); - - useEffect(() => { - setLiveAdjustments(historyAdjustments); - }, [historyAdjustments]); - - useEffect(() => { - if ( - (activeRightPanel !== Panel.Masks || !activeMaskContainerId) && - (activeRightPanel !== Panel.Ai || !activeAiPatchContainerId) - ) { - setIsMaskControlHovered(false); - } - }, [activeRightPanel, activeMaskContainerId, activeAiPatchContainerId]); - - const geometricAdjustmentsKey = useMemo(() => { - if (!adjustments) return ''; - const { crop, rotation, flipHorizontal, flipVertical, orientationSteps } = adjustments; - return JSON.stringify({ crop, rotation, flipHorizontal, flipVertical, orientationSteps }); - }, [ - adjustments?.crop, - adjustments?.rotation, - adjustments?.flipHorizontal, - adjustments?.flipVertical, - adjustments?.orientationSteps, - ]); - - const visualAdjustmentsKey = useMemo(() => { - if (!adjustments) return ''; - const { rating: _rating, sectionVisibility: _sectionVisibility, ...visualAdjustments } = adjustments; - return JSON.stringify(visualAdjustments); - }, [adjustments]); - - const undo = useCallback(() => { - if (canUndo) { - undoAdjustments(); - debouncedSetHistory.cancel(); - } - }, [canUndo, undoAdjustments, debouncedSetHistory]); - const redo = useCallback(() => { - if (canRedo) { - redoAdjustments(); - debouncedSetHistory.cancel(); - } - }, [canRedo, redoAdjustments, debouncedSetHistory]); - - useEffect(() => { - setTransformedOriginalUrl(null); - }, [geometricAdjustmentsKey, selectedImage?.path]); - - useEffect(() => { - let isEffectActive = true; - - const generate = async () => { - if (showOriginal && selectedImage?.path && !transformedOriginalUrl) { - try { - const base64Data: string = await invoke('generate_original_transformed_preview', { - jsAdjustments: adjustments, - }); - if (isEffectActive) { - setTransformedOriginalUrl(base64Data); - } - } catch (e) { - if (isEffectActive) { - console.error('Failed to generate original preview:', e); - setError('Failed to show original image.'); - setShowOriginal(false); - } - } - } - }; - - generate(); - - return () => { - isEffectActive = false; - }; - }, [showOriginal, selectedImage?.path, adjustments, transformedOriginalUrl]); - - useEffect(() => { - if (currentFolderPath) { - refreshImageList(); - } - }, [libraryViewMode]); - - useEffect(() => { - const unlisten = listen('ai-connector-status-update', (event: any) => { - setisAIConnectorConnected(event.payload.connected); - }); - invoke(Invokes.CheckAIConnectorStatus); - const interval = setInterval(() => invoke(Invokes.CheckAIConnectorStatus), 10000); - return () => { - clearInterval(interval); - unlisten.then((f) => f()); - }; - }, []); - - const updateSubMask = (subMaskId: string, updatedData: any) => { - setAdjustments((prev: Adjustments) => ({ - ...prev, - masks: prev.masks.map((c: MaskContainer) => ({ - ...c, - subMasks: c.subMasks.map((sm: SubMask) => (sm.id === subMaskId ? { ...sm, ...updatedData } : sm)), - })), - aiPatches: (prev.aiPatches || []).map((p: AiPatch) => ({ - ...p, - subMasks: p.subMasks.map((sm: SubMask) => (sm.id === subMaskId ? { ...sm, ...updatedData } : sm)), - })), - })); - }; - - const handleGenerativeReplace = useCallback( - async (patchId: string, prompt: string, useFastInpaint: boolean) => { - if (!selectedImage?.path || isGeneratingAi) { - return; - } - - const patch: AiPatch | undefined = adjustments.aiPatches.find((p: AiPatch) => p.id === patchId); - if (!patch) { - console.error('Could not find AI patch to generate for:', patchId); - return; - } - - const patchDefinition = { ...patch, prompt }; - - setAdjustments((prev: Adjustments) => ({ - ...prev, - aiPatches: prev.aiPatches.map((p: AiPatch) => (p.id === patchId ? { ...p, isLoading: true, prompt } : p)), - })); - - setIsGeneratingAi(true); - - try { - const newPatchDataJson: any = await invoke(Invokes.InvokeGenerativeReplaseWithMaskDef, { - currentAdjustments: adjustments, - patchDefinition: patchDefinition, - path: selectedImage.path, - useFastInpaint: useFastInpaint, - }); - - const newPatchData = JSON.parse(newPatchDataJson); - patchesSentToBackend.current.delete(patchId); - setAdjustments((prev: Adjustments) => ({ - ...prev, - aiPatches: prev.aiPatches.map((p: AiPatch) => - p.id === patchId - ? { - ...p, - patchData: newPatchData, - isLoading: false, - name: useFastInpaint ? 'Inpaint' : prompt && prompt.trim() ? prompt.trim() : p.name, - } - : p, - ), - })); - setActiveAiPatchContainerId(null); - setActiveAiSubMaskId(null); - } catch (err) { - console.error('Generative replace failed:', err); - setError(`AI Replace Failed: ${err}`); - setAdjustments((prev: Adjustments) => ({ - ...prev, - aiPatches: prev.aiPatches.map((p: AiPatch) => (p.id === patchId ? { ...p, isLoading: false } : p)), - })); - } finally { - setIsGeneratingAi(false); - } - }, - [ - selectedImage?.path, - isGeneratingAi, - adjustments, - setAdjustments, - setActiveAiPatchContainerId, - setActiveAiSubMaskId, - ], - ); - - const handleQuickErase = useCallback( - async (subMaskId: string | null, startPoint: Coord, endPoint: Coord) => { - if (!selectedImage?.path || isGeneratingAi) { - return; - } - - const patchId = adjustments.aiPatches.find((p: AiPatch) => - p.subMasks.some((sm: SubMask) => sm.id === subMaskId), - )?.id; - if (!patchId) { - console.error('Could not find AI patch container for Quick Erase.'); - return; - } - - setIsGeneratingAi(true); - setAdjustments((prev: Partial) => ({ - ...prev, - aiPatches: prev.aiPatches?.map((p: AiPatch) => (p.id === patchId ? { ...p, isLoading: true } : p)), - })); - - try { - const transformAdjustments = { - transformDistortion: adjustments.transformDistortion, - transformVertical: adjustments.transformVertical, - transformHorizontal: adjustments.transformHorizontal, - transformRotate: adjustments.transformRotate, - transformAspect: adjustments.transformAspect, - transformScale: adjustments.transformScale, - transformXOffset: adjustments.transformXOffset, - transformYOffset: adjustments.transformYOffset, - lensDistortionAmount: adjustments.lensDistortionAmount, - lensVignetteAmount: adjustments.lensVignetteAmount, - lensTcaAmount: adjustments.lensTcaAmount, - lensDistortionParams: adjustments.lensDistortionParams, - lensMaker: adjustments.lensMaker, - lensModel: adjustments.lensModel, - lensDistortionEnabled: adjustments.lensDistortionEnabled, - lensTcaEnabled: adjustments.lensTcaEnabled, - lensVignetteEnabled: adjustments.lensVignetteEnabled, - }; - - const newMaskParams: any = await invoke(Invokes.GenerateAiSubjectMask, { - jsAdjustments: transformAdjustments, - endPoint: [endPoint.x, endPoint.y], - flipHorizontal: adjustments.flipHorizontal, - flipVertical: adjustments.flipVertical, - orientationSteps: adjustments.orientationSteps, - path: selectedImage.path, - rotation: adjustments.rotation, - startPoint: [startPoint.x, startPoint.y], - }); - - const subMaskToUpdate = adjustments.aiPatches - ?.find((p: AiPatch) => p.id === patchId) - ?.subMasks.find((sm: SubMask) => sm.id === subMaskId); - const finalSubMaskParams: any = { ...subMaskToUpdate?.parameters, ...newMaskParams }; - const updatedAdjustmentsForBackend = { - ...adjustments, - aiPatches: adjustments.aiPatches.map((p: AiPatch) => - p.id === patchId - ? { - ...p, - subMasks: p.subMasks.map((sm: SubMask) => - sm.id === subMaskId ? { ...sm, parameters: finalSubMaskParams } : sm, - ), - } - : p, - ), - }; - - const patchDefinitionForBackend = updatedAdjustmentsForBackend.aiPatches.find((p: AiPatch) => p.id === patchId); - const newPatchDataJson: any = await invoke(Invokes.InvokeGenerativeReplaseWithMaskDef, { - currentAdjustments: updatedAdjustmentsForBackend, - patchDefinition: { ...patchDefinitionForBackend, prompt: '' }, - path: selectedImage.path, - useFastInpaint: true, - }); - - const newPatchData = JSON.parse(newPatchDataJson); - if (!newPatchData?.color || !newPatchData?.mask) { - throw new Error('Inpainting failed to return a valid result.'); - } - patchesSentToBackend.current.delete(patchId); - - setAdjustments((prev: Partial) => ({ - ...prev, - aiPatches: prev.aiPatches?.map((p: AiPatch) => - p.id === patchId - ? { - ...p, - patchData: newPatchData, - isLoading: false, - subMasks: p.subMasks.map((sm: SubMask) => - sm.id === subMaskId ? { ...sm, parameters: finalSubMaskParams } : sm, - ), - } - : p, - ), - })); - setActiveAiPatchContainerId(null); - setActiveAiSubMaskId(null); - } catch (err: any) { - console.error('Quick Erase failed:', err); - setError(`Quick Erase Failed: ${err.message || String(err)}`); - setAdjustments((prev: Partial) => ({ - ...prev, - aiPatches: prev.aiPatches?.map((p: AiPatch) => (p.id === patchId ? { ...p, isLoading: false } : p)), - })); - } finally { - setIsGeneratingAi(false); - } - }, - [ - selectedImage?.path, - isGeneratingAi, - adjustments, - setAdjustments, - setActiveAiPatchContainerId, - setActiveAiSubMaskId, - ], - ); - - const handleDeleteMaskContainer = useCallback( - (containerId: string) => { - setAdjustments((prev: Adjustments) => ({ - ...prev, - masks: (prev.masks || []).filter((c) => c.id !== containerId), - })); - if (activeMaskContainerId === containerId) { - setActiveMaskContainerId(null); - setActiveMaskId(null); - } - }, - [setAdjustments, activeMaskContainerId], - ); - - const handleDeleteAiPatch = useCallback( - (patchId: string) => { - setAdjustments((prev: Adjustments) => ({ - ...prev, - aiPatches: (prev.aiPatches || []).filter((p) => p.id !== patchId), - })); - if (activeAiPatchContainerId === patchId) { - setActiveAiPatchContainerId(null); - setActiveAiSubMaskId(null); - } - }, - [setAdjustments, activeAiPatchContainerId], - ); - - const handleToggleAiPatchVisibility = useCallback( - (patchId: string) => { - setAdjustments((prev: Adjustments) => ({ - ...prev, - aiPatches: (prev.aiPatches || []).map((p: AiPatch) => (p.id === patchId ? { ...p, visible: !p.visible } : p)), - })); - }, - [setAdjustments], - ); - - const handleGenerateAiMask = async (subMaskId: string, startPoint: Coord, endPoint: Coord) => { - if (!selectedImage?.path) { - console.error('Cannot generate AI mask: No image selected.'); - return; - } - setIsGeneratingAiMask(true); - try { - const transformAdjustments = { - transformDistortion: adjustments.transformDistortion, - transformVertical: adjustments.transformVertical, - transformHorizontal: adjustments.transformHorizontal, - transformRotate: adjustments.transformRotate, - transformAspect: adjustments.transformAspect, - transformScale: adjustments.transformScale, - transformXOffset: adjustments.transformXOffset, - transformYOffset: adjustments.transformYOffset, - lensDistortionAmount: adjustments.lensDistortionAmount, - lensVignetteAmount: adjustments.lensVignetteAmount, - lensTcaAmount: adjustments.lensTcaAmount, - lensDistortionParams: adjustments.lensDistortionParams, - lensMaker: adjustments.lensMaker, - lensModel: adjustments.lensModel, - lensDistortionEnabled: adjustments.lensDistortionEnabled, - lensTcaEnabled: adjustments.lensTcaEnabled, - lensVignetteEnabled: adjustments.lensVignetteEnabled, - }; - const newParameters = await invoke(Invokes.GenerateAiSubjectMask, { - jsAdjustments: transformAdjustments, - endPoint: [endPoint.x, endPoint.y], - flipHorizontal: adjustments.flipHorizontal, - flipVertical: adjustments.flipVertical, - orientationSteps: adjustments.orientationSteps, - path: selectedImage.path, - rotation: adjustments.rotation, - startPoint: [startPoint.x, startPoint.y], - }); - - const subMask = adjustments.aiPatches - ?.flatMap((p: AiPatch) => p.subMasks) - .find((sm: SubMask) => sm.id === subMaskId); - - const mergedParameters = { ...(subMask?.parameters || {}), ...newParameters }; - patchesSentToBackend.current.delete(subMaskId); - updateSubMask(subMaskId, { parameters: mergedParameters }); - } catch (error) { - console.error('Failed to generate AI subject mask:', error); - setError(`AI Mask Failed: ${error}`); - } finally { - setIsGeneratingAiMask(false); - } - }; - - const handleGenerateAiForegroundMask = async (subMaskId: string) => { - if (!selectedImage?.path) { - console.error('Cannot generate AI mask: No image selected.'); - return; - } - setIsGeneratingAiMask(true); - try { - const transformAdjustments = { - transformDistortion: adjustments.transformDistortion, - transformVertical: adjustments.transformVertical, - transformHorizontal: adjustments.transformHorizontal, - transformRotate: adjustments.transformRotate, - transformAspect: adjustments.transformAspect, - transformScale: adjustments.transformScale, - transformXOffset: adjustments.transformXOffset, - transformYOffset: adjustments.transformYOffset, - lensDistortionAmount: adjustments.lensDistortionAmount, - lensVignetteAmount: adjustments.lensVignetteAmount, - lensTcaAmount: adjustments.lensTcaAmount, - lensDistortionParams: adjustments.lensDistortionParams, - lensMaker: adjustments.lensMaker, - lensModel: adjustments.lensModel, - lensDistortionEnabled: adjustments.lensDistortionEnabled, - lensTcaEnabled: adjustments.lensTcaEnabled, - lensVignetteEnabled: adjustments.lensVignetteEnabled, - }; - const newParameters = await invoke(Invokes.GenerateAiForegroundMask, { - jsAdjustments: transformAdjustments, - flipHorizontal: adjustments.flipHorizontal, - flipVertical: adjustments.flipVertical, - orientationSteps: adjustments.orientationSteps, - rotation: adjustments.rotation, - }); - - const subMask = adjustments.aiPatches - ?.flatMap((p: AiPatch) => p.subMasks) - .find((sm: SubMask) => sm.id === subMaskId); - - const mergedParameters = { ...(subMask?.parameters || {}), ...newParameters }; - patchesSentToBackend.current.delete(subMaskId); - updateSubMask(subMaskId, { parameters: mergedParameters }); - } catch (error) { - console.error('Failed to generate AI foreground mask:', error); - setError(`AI Mask Failed: ${error}`); - } finally { - setIsGeneratingAiMask(false); - } - }; - - const handleGenerateAiSkyMask = async (subMaskId: string) => { - if (!selectedImage?.path) { - console.error('Cannot generate AI mask: No image selected.'); - return; - } - setIsGeneratingAiMask(true); - try { - const transformAdjustments = { - transformDistortion: adjustments.transformDistortion, - transformVertical: adjustments.transformVertical, - transformHorizontal: adjustments.transformHorizontal, - transformRotate: adjustments.transformRotate, - transformAspect: adjustments.transformAspect, - transformScale: adjustments.transformScale, - transformXOffset: adjustments.transformXOffset, - transformYOffset: adjustments.transformYOffset, - lensDistortionAmount: adjustments.lensDistortionAmount, - lensVignetteAmount: adjustments.lensVignetteAmount, - lensTcaAmount: adjustments.lensTcaAmount, - lensDistortionParams: adjustments.lensDistortionParams, - lensMaker: adjustments.lensMaker, - lensModel: adjustments.lensModel, - lensDistortionEnabled: adjustments.lensDistortionEnabled, - lensTcaEnabled: adjustments.lensTcaEnabled, - lensVignetteEnabled: adjustments.lensVignetteEnabled, - }; - const newParameters = await invoke(Invokes.GenerateAiSkyMask, { - jsAdjustments: transformAdjustments, - flipHorizontal: adjustments.flipHorizontal, - flipVertical: adjustments.flipVertical, - orientationSteps: adjustments.orientationSteps, - rotation: adjustments.rotation, - }); - - const subMask = adjustments.aiPatches - ?.flatMap((p: AiPatch) => p.subMasks) - .find((sm: SubMask) => sm.id === subMaskId); - - const mergedParameters = { ...(subMask?.parameters || {}), ...newParameters }; - patchesSentToBackend.current.delete(subMaskId); - updateSubMask(subMaskId, { parameters: mergedParameters }); - } catch (error) { - console.error('Failed to generate AI sky mask:', error); - setError(`AI Mask Failed: ${error}`); - } finally { - setIsGeneratingAiMask(false); - } - }; - - const sortedImageList = useMemo(() => { - let processedList = imageList; - - if (filterCriteria.rawStatus === RawStatus.RawOverNonRaw && supportedTypes) { - const rawBaseNames = new Set(); - - for (const image of imageList) { - const pathWithoutVC = image.path.split('?vc=')[0]; - const filename = pathWithoutVC.split(/[\\/]/).pop() || ''; - const lastDotIndex = filename.lastIndexOf('.'); - const extension = lastDotIndex !== -1 ? filename.substring(lastDotIndex + 1).toLowerCase() : ''; - - if (extension && supportedTypes.raw.includes(extension)) { - const baseName = lastDotIndex !== -1 ? filename.substring(0, lastDotIndex) : filename; - const parentDir = getParentDir(pathWithoutVC); - const uniqueKey = `${parentDir}/${baseName}`; - rawBaseNames.add(uniqueKey); - } - } - - if (rawBaseNames.size > 0) { - processedList = imageList.filter((image) => { - const pathWithoutVC = image.path.split('?vc=')[0]; - const filename = pathWithoutVC.split(/[\\/]/).pop() || ''; - const lastDotIndex = filename.lastIndexOf('.'); - const extension = lastDotIndex !== -1 ? filename.substring(lastDotIndex + 1).toLowerCase() : ''; - - const isNonRaw = extension && supportedTypes.nonRaw.includes(extension); - - if (isNonRaw) { - const baseName = lastDotIndex !== -1 ? filename.substring(0, lastDotIndex) : filename; - const parentDir = getParentDir(pathWithoutVC); - const uniqueKey = `${parentDir}/${baseName}`; - - if (rawBaseNames.has(uniqueKey)) { - return false; - } - } - - return true; - }); - } - } - - const filteredList = processedList.filter((image) => { - if (filterCriteria.rating > 0) { - const rating = imageRatings[image.path] || 0; - if (filterCriteria.rating === 5) { - if (rating !== 5) return false; - } else { - if (rating < filterCriteria.rating) return false; - } - } - - if ( - filterCriteria.rawStatus && - filterCriteria.rawStatus !== RawStatus.All && - filterCriteria.rawStatus !== RawStatus.RawOverNonRaw && - supportedTypes - ) { - const extension = image.path.split('.').pop()?.toLowerCase() || ''; - const isRaw = supportedTypes.raw?.includes(extension); - - if (filterCriteria.rawStatus === RawStatus.RawOnly && !isRaw) { - return false; - } - if (filterCriteria.rawStatus === RawStatus.NonRawOnly && isRaw) { - return false; - } - } - - if (filterCriteria.colors && filterCriteria.colors.length > 0) { - const imageColor = (image.tags || []).find((tag: string) => tag.startsWith('color:'))?.substring(6); - - const hasMatchingColor = imageColor && filterCriteria.colors.includes(imageColor); - const matchesNone = !imageColor && filterCriteria.colors.includes('none'); - - if (!hasMatchingColor && !matchesNone) { - return false; - } - } - - return true; - }); - - const { tags: searchTags, text: searchText, mode: searchMode } = searchCriteria; - const lowerCaseSearchText = searchText.trim().toLowerCase(); - - const filteredBySearch = - searchTags.length === 0 && lowerCaseSearchText === '' - ? filteredList - : filteredList.filter((image: ImageFile) => { - const lowerCaseImageTags = (image.tags || []).map((t) => t.toLowerCase().replace('user:', '')); - const filename = image?.path?.split(/[\\/]/)?.pop()?.toLowerCase() || ''; - - let tagsMatch = true; - if (searchTags.length > 0) { - const lowerCaseSearchTags = searchTags.map((t) => t.toLowerCase()); - if (searchMode === 'OR') { - tagsMatch = lowerCaseSearchTags.some((searchTag) => - lowerCaseImageTags.some((imgTag) => imgTag.includes(searchTag)), - ); - } else { - tagsMatch = lowerCaseSearchTags.every((searchTag) => - lowerCaseImageTags.some((imgTag) => imgTag.includes(searchTag)), - ); - } - } - - let textMatch = true; - if (lowerCaseSearchText !== '') { - textMatch = - filename.includes(lowerCaseSearchText) || - lowerCaseImageTags.some((t) => t.includes(lowerCaseSearchText)); - } - - return tagsMatch && textMatch; - }); - - const list = [...filteredBySearch]; - - const parseShutter = (val: string | undefined): number | null => { - if (!val) return null; - const cleanVal = val.replace(/s/i, '').trim(); - const parts = cleanVal.split('/'); - if (parts.length === 2) { - const num = parseFloat(parts[0]); - const den = parseFloat(parts[1]); - return den !== 0 ? num / den : null; - } - const numVal = parseFloat(cleanVal); - return isNaN(numVal) ? null : numVal; - }; - - const parseAperture = (val: string | undefined): number | null => { - if (!val) return null; - const match = val.match(/(\d+(\.\d+)?)/); - const numVal = match ? parseFloat(match[0]) : null; - return numVal === null || isNaN(numVal) ? null : numVal; - }; - - const parseFocalLength = (val: string | undefined): number | null => { - if (!val) return null; - const match = val.match(/(\d+(\.\d+)?)/); - if (!match) return null; - const numVal = parseFloat(match[0]); - return isNaN(numVal) ? null : numVal; - }; - - list.sort((a, b) => { - const { key, order } = sortCriteria; - let comparison = 0; - - const compareNullable = (valA: any, valB: any) => { - if (valA !== null && valB !== null) { - if (valA < valB) return -1; - if (valA > valB) return 1; - return 0; - } - if (valA !== null) return -1; - if (valB !== null) return 1; - return 0; - }; - - switch (key) { - case 'date_taken': { - const dateA = a.exif?.DateTimeOriginal; - const dateB = b.exif?.DateTimeOriginal; - comparison = compareNullable(dateA, dateB); - if (comparison === 0) comparison = a.modified - b.modified; - break; - } - case 'iso': { - const getIso = (exif: { [key: string]: string } | null): number | null => { - if (!exif) return null; - const isoStr = exif.PhotographicSensitivity || exif.ISOSpeedRatings; - if (!isoStr) return null; - const isoNum = parseInt(isoStr, 10); - return isNaN(isoNum) ? null : isoNum; - }; - const isoA = getIso(a.exif); - const isoB = getIso(b.exif); - comparison = compareNullable(isoA, isoB); - break; - } - case 'shutter_speed': { - const shutterA = parseShutter(a.exif?.ExposureTime); - const shutterB = parseShutter(b.exif?.ExposureTime); - comparison = compareNullable(shutterA, shutterB); - break; - } - case 'aperture': { - const apertureA = parseAperture(a.exif?.FNumber); - const apertureB = parseAperture(b.exif?.FNumber); - comparison = compareNullable(apertureA, apertureB); - break; - } - case 'focal_length': { - const focalA = parseFocalLength(a.exif?.FocalLength); - const focalB = parseFocalLength(b.exif?.FocalLength); - comparison = compareNullable(focalA, focalB); - break; - } - case 'date': - comparison = a.modified - b.modified; - break; - case 'rating': - comparison = (imageRatings[a.path] || 0) - (imageRatings[b.path] || 0); - break; - default: - comparison = a.path.localeCompare(b.path); - break; - } - - if (comparison === 0 && key !== 'name') { - return a.path.localeCompare(b.path); - } - - return order === SortDirection.Ascending ? comparison : -comparison; - }); - return list; - }, [imageList, sortCriteria, imageRatings, filterCriteria, supportedTypes, searchCriteria, appSettings]); - - const applyAdjustments = useCallback( - async (currentAdjustments: Adjustments, dragging: boolean = false) => { - if (!selectedImage?.isReady) return; - const currentPath = selectedImage.path; - - const payload = JSON.parse(JSON.stringify(currentAdjustments)); - - if (payload.aiPatches && Array.isArray(payload.aiPatches)) { - payload.aiPatches.forEach((p: any) => { - if (p.id && p.patchData && !p.isLoading) { - if (patchesSentToBackend.current.has(p.id)) { - p.patchData = null; - } else { - patchesSentToBackend.current.add(p.id); - } - } - }); - } - - if (payload.masks && Array.isArray(payload.masks)) { - payload.masks.forEach((container: any) => { - if (container.subMasks && Array.isArray(container.subMasks)) { - container.subMasks.forEach((sm: any) => { - if (sm.id && sm.parameters && sm.parameters.mask_data_base64) { - if (patchesSentToBackend.current.has(sm.id)) { - sm.parameters.mask_data_base64 = null; - } else { - patchesSentToBackend.current.add(sm.id); - } - } - }); - } - }); - } - - const jobId = ++previewJobIdRef.current; - - try { - const buffer: ArrayBuffer = await invoke(Invokes.ApplyAdjustments, { - jsAdjustments: payload, - isInteractive: dragging, - }); - - if (currentPath !== selectedImagePathRef.current) return; - - if (buffer && buffer.byteLength > 0 && jobId >= latestRenderedJobIdRef.current) { - latestRenderedJobIdRef.current = jobId; - const blob = new Blob([buffer], { type: 'image/jpeg' }); - const url = URL.createObjectURL(blob); - - setFinalPreviewUrl((prevUrl) => { - if (prevUrl && prevUrl.startsWith('blob:')) URL.revokeObjectURL(prevUrl); - return url; - }); - } - } catch (err) { - if (err !== 'Superseded or worker failed') { - console.error('Failed to apply adjustments:', err); - } - } - }, - [selectedImage?.isReady, selectedImage?.path], - ); - - const generateUncroppedPreview = useCallback( - (currentAdjustments: Adjustments) => { - if (!selectedImage?.isReady) { - return; - } - invoke(Invokes.GenerateUncroppedPreview, { jsAdjustments: currentAdjustments }).catch((err) => - console.error('Failed to generate uncropped preview:', err), - ); - }, - [selectedImage?.isReady], - ); - - useEffect(() => { - if (activeRightPanel === Panel.Crop && selectedImage?.isReady) { - generateUncroppedPreview(adjustments); - } - }, [adjustments, activeRightPanel, selectedImage?.isReady, generateUncroppedPreview]); - - const debouncedSave = useCallback( - debounce((path, adjustmentsToSave) => { - invoke(Invokes.SaveMetadataAndUpdateThumbnail, { path, adjustments: adjustmentsToSave }).catch((err) => { - console.error('Auto-save failed:', err); - setError(`Failed to save changes: ${err}`); - }); - }, 300), - [], - ); - - const createResizeHandler = (setter: any, startSize: number) => (e: any) => { - e.preventDefault(); - setIsResizing(true); - const startX = e.clientX; - const startY = e.clientY; - const doDrag = (moveEvent: any) => { - if (setter === setLeftPanelWidth) { - setter(Math.max(200, Math.min(startSize + (moveEvent.clientX - startX), 500))); - } else if (setter === setRightPanelWidth) { - setter(Math.max(280, Math.min(startSize - (moveEvent.clientX - startX), 600))); - } else if (setter === setBottomPanelHeight) { - setter(Math.max(100, Math.min(startSize - (moveEvent.clientY - startY), 400))); - } - }; - const stopDrag = () => { - document.documentElement.style.cursor = ''; - window.removeEventListener('mousemove', doDrag); - window.removeEventListener('mouseup', stopDrag); - setIsResizing(false); - }; - document.documentElement.style.cursor = setter === setBottomPanelHeight ? 'row-resize' : 'col-resize'; - window.addEventListener('mousemove', doDrag); - window.addEventListener('mouseup', stopDrag); - }; - - useEffect(() => { - const appWindow = getCurrentWindow(); - const checkFullscreen = async () => { - setIsWindowFullScreen(await appWindow.isFullscreen()); - }; - checkFullscreen(); - - const unlistenPromise = appWindow.onResized(checkFullscreen); - - return () => { - unlistenPromise.then((unlisten: any) => unlisten()); - }; - }, []); - - const handleLutSelect = useCallback( - async (path: string) => { - try { - const result: LutData = await invoke('load_and_parse_lut', { path }); - const name = path.split(/[\\/]/).pop() || 'LUT'; - setAdjustments((prev: Partial) => ({ - ...prev, - lutPath: path, - lutName: name, - lutSize: result.size, - lutIntensity: 100, - sectionVisibility: { - ...(prev.sectionVisibility || INITIAL_ADJUSTMENTS.sectionVisibility), - effects: true, - }, - })); - } catch (err) { - console.error('Failed to load or parse LUT:', err); - setError(`Failed to load LUT: ${err}`); - } - }, - [setAdjustments], - ); - - const handleRightPanelSelect = useCallback( - (panelId: Panel) => { - if (panelId === activeRightPanel) { - setActiveRightPanel(null); - } else { - const currentIndex = activeRightPanel ? RIGHT_PANEL_ORDER.indexOf(activeRightPanel) : -1; - const newIndex = RIGHT_PANEL_ORDER.indexOf(panelId); - setSlideDirection(newIndex > currentIndex ? 1 : -1); - setActiveRightPanel(panelId); - setRenderedRightPanel(panelId); - } - setActiveMaskId(null); - setActiveAiSubMaskId(null); - }, - [activeRightPanel], - ); - - const handleSettingsChange = useCallback( - (newSettings: AppSettings) => { - if (!newSettings) { - console.error('handleSettingsChange was called with null settings. Aborting save operation.'); - return; - } - if (newSettings.theme && newSettings.theme !== theme) { - setTheme(newSettings.theme); - } - - const { searchCriteria: _searchCriteria, ...settingsToSave } = newSettings as any; - setAppSettings(newSettings); - invoke(Invokes.SaveSettings, { settings: settingsToSave }).catch((err) => { - console.error('Failed to save settings:', err); - }); - }, - [theme], - ); - - useEffect(() => { - invoke(Invokes.LoadSettings) - .then(async (settings: any) => { - if ( - !settings.copyPasteSettings || - !settings.copyPasteSettings.includedAdjustments || - settings.copyPasteSettings.includedAdjustments.length === 0 - ) { - settings.copyPasteSettings = { - mode: 'merge', - includedAdjustments: COPYABLE_ADJUSTMENT_KEYS, - }; - } - setAppSettings(settings); - if (settings?.sortCriteria) setSortCriteria(settings.sortCriteria); - if (settings?.filterCriteria) { - setFilterCriteria((prev: FilterCriteria) => ({ - ...prev, - ...settings.filterCriteria, - rawStatus: settings.filterCriteria.rawStatus || RawStatus.All, - colors: settings.filterCriteria.colors || [], - })); - } - if (settings?.theme) { - setTheme(settings.theme); - } - if (settings?.uiVisibility) { - setUiVisibility((prev) => ({ ...prev, ...settings.uiVisibility })); - } - if (settings?.libraryViewMode) { - setLibraryViewMode(settings.libraryViewMode); - } - if (settings?.thumbnailSize) { - setThumbnailSize(settings.thumbnailSize); - } - if (settings?.thumbnailAspectRatio) { - setThumbnailAspectRatio(settings.thumbnailAspectRatio); - } - if (settings?.activeTreeSection) { - setActiveTreeSection(settings.activeTreeSection); - } - if (settings?.pinnedFolders && settings.pinnedFolders.length > 0) { - try { - const trees = await invoke(Invokes.GetPinnedFolderTrees, { paths: settings.pinnedFolders }); - setPinnedFolderTrees(trees); - } catch (err) { - console.error('Failed to load pinned folder trees:', err); - } - } - - if (settings.lastRootPath) { - const root = settings.lastRootPath; - const currentPath = settings.lastFolderState?.currentFolderPath || root; - - const command = - settings.libraryViewMode === LibraryViewMode.Recursive - ? Invokes.ListImagesRecursive - : Invokes.ListImagesInDir; - - preloadedDataRef.current = { - rootPath: root, - currentPath: currentPath, - tree: invoke(Invokes.GetFolderTree, { path: root }), - images: invoke(command, { path: currentPath }), - }; - } - - invoke('frontend_ready').catch((e) => console.error('Failed to notify backend of readiness:', e)); - }) - .catch((err) => { - console.error('Failed to load settings:', err); - setAppSettings({ lastRootPath: null, theme: DEFAULT_THEME_ID }); - }) - .finally(() => { - isInitialMount.current = false; - }); - }, []); - - useEffect(() => { - if (isInitialMount.current || !appSettings) { - return; - } - if (JSON.stringify(appSettings.uiVisibility) !== JSON.stringify(uiVisibility)) { - handleSettingsChange({ ...appSettings, uiVisibility }); - } - }, [uiVisibility, appSettings, handleSettingsChange]); - - const handleToggleWaveform = useCallback(() => { - setIsWaveformVisible((prev: boolean) => !prev); - }, []); - - useEffect(() => { - if (isInitialMount.current || !appSettings) { - return; - } - if (appSettings.thumbnailSize !== thumbnailSize) { - handleSettingsChange({ ...appSettings, thumbnailSize }); - } - }, [thumbnailSize, appSettings, handleSettingsChange]); - - useEffect(() => { - if (isInitialMount.current || !appSettings) { - return; - } - if (appSettings.thumbnailAspectRatio !== thumbnailAspectRatio) { - handleSettingsChange({ ...appSettings, thumbnailAspectRatio }); - } - }, [thumbnailAspectRatio, appSettings, handleSettingsChange]); - - useEffect(() => { - if (isInitialMount.current || !appSettings) { - return; - } - if (appSettings.libraryViewMode !== libraryViewMode) { - handleSettingsChange({ ...appSettings, libraryViewMode }); - } - }, [libraryViewMode, appSettings, handleSettingsChange]); - - useEffect(() => { - invoke(Invokes.GetSupportedFileTypes) - .then((types: any) => setSupportedTypes(types)) - .catch((err) => console.error('Failed to load supported file types:', err)); - }, []); - - useEffect(() => { - if (isInitialMount.current || !appSettings) { - return; - } - if (JSON.stringify(appSettings.sortCriteria) !== JSON.stringify(sortCriteria)) { - handleSettingsChange({ ...appSettings, sortCriteria }); - } - }, [sortCriteria, appSettings, handleSettingsChange]); - - useEffect(() => { - if (isInitialMount.current || !appSettings) { - return; - } - if (JSON.stringify(appSettings.filterCriteria) !== JSON.stringify(filterCriteria)) { - handleSettingsChange({ ...appSettings, filterCriteria }); - } - }, [filterCriteria, appSettings, handleSettingsChange]); - - useEffect(() => { - if (appSettings?.adaptiveEditorTheme && selectedImage && finalPreviewUrl) { - generatePaletteFromImage(finalPreviewUrl) - .then(setAdaptivePalette) - .catch((_err) => { - const darkTheme = THEMES.find((t) => t.id === Theme.Dark); - setAdaptivePalette(darkTheme ? darkTheme.cssVariables : null); - }); - } else if (!appSettings?.adaptiveEditorTheme || !selectedImage) { - setAdaptivePalette(null); - } - }, [appSettings?.adaptiveEditorTheme, selectedImage, finalPreviewUrl]); - - useEffect(() => { - const root = document.documentElement; - const currentThemeId = theme || DEFAULT_THEME_ID; - - const baseTheme = - THEMES.find((t: ThemeProps) => t.id === currentThemeId) || - THEMES.find((t: ThemeProps) => t.id === DEFAULT_THEME_ID); - if (!baseTheme) { - return; - } - - let finalCssVariables: any = { ...baseTheme.cssVariables }; - const effectThemeForWindow = baseTheme.id; - - if (adaptivePalette) { - finalCssVariables = { ...finalCssVariables, ...adaptivePalette }; - } - - Object.entries(finalCssVariables).forEach(([key, value]) => { - root.style.setProperty(key, value as string); - }); - - const isLight = [Theme.Light, Theme.Snow, Theme.Arctic].includes(effectThemeForWindow); - invoke(Invokes.UpdateWindowEffect, { theme: isLight ? Theme.Light : Theme.Dark }); - }, [theme, adaptivePalette]); - - useEffect(() => { - if (isInitialThemeMount.current) { - isInitialThemeMount.current = false; - return; - } - - setIsAnimatingTheme(true); - const timer = setTimeout(() => setIsAnimatingTheme(false), 500); - - return () => clearTimeout(timer); - }, [theme]); - - const refreshAllFolderTrees = useCallback(async () => { - if (rootPath) { - try { - const treeData = await invoke(Invokes.GetFolderTree, { path: rootPath }); - setFolderTree(treeData); - } catch (err) { - console.error('Failed to refresh main folder tree:', err); - setError(`Failed to refresh folder tree: ${err}.`); - } - } - - const currentPins = appSettings?.pinnedFolders || []; - if (currentPins.length > 0) { - try { - const trees = await invoke(Invokes.GetPinnedFolderTrees, { paths: currentPins }); - setPinnedFolderTrees(trees); - } catch (err) { - console.error('Failed to refresh pinned folder trees:', err); - } - } - }, [rootPath, appSettings?.pinnedFolders]); - - const pinnedFolders = useMemo(() => appSettings?.pinnedFolders || [], [appSettings]); - - const handleTogglePinFolder = useCallback( - async (path: string) => { - if (!appSettings) return; - const currentPins = appSettings.pinnedFolders || []; - const isPinned = currentPins.includes(path); - const newPins = isPinned - ? currentPins.filter((p) => p !== path) - : [...currentPins, path].sort((a, b) => a.localeCompare(b)); - - if (!isPinned && path === currentFolderPath) { - handleActiveTreeSectionChange('pinned'); - } - - handleSettingsChange({ ...appSettings, pinnedFolders: newPins }); - - try { - const trees = await invoke(Invokes.GetPinnedFolderTrees, { paths: newPins }); - setPinnedFolderTrees(trees); - } catch (err) { - console.error('Failed to refresh pinned folders:', err); - } - }, - [appSettings, handleSettingsChange], - ); - - const handleActiveTreeSectionChange = (section: string | null) => { - setActiveTreeSection(section); - if (appSettings) { - handleSettingsChange({ ...appSettings, activeTreeSection: section }); - } - }; - - const handleSelectSubfolder = useCallback( - async (path: string | null, isNewRoot = false, preloadedImages?: ImageFile[]) => { - await invoke('cancel_thumbnail_generation'); - setIsViewLoading(true); - setSearchCriteria({ tags: [], text: '', mode: 'OR' }); - setLibraryScrollTop(0); - setThumbnails({}); - try { - setCurrentFolderPath(path); - setActiveView('library'); - - if (isNewRoot) { - setExpandedFolders(new Set([path])); - } else if (path) { - setExpandedFolders((prev) => { - const newSet = new Set(prev); - const allRoots = [rootPath, ...pinnedFolders].filter(Boolean) as string[]; - const relevantRoot = allRoots.find((r) => path.startsWith(r)); - - if (relevantRoot) { - const separator = path.includes('/') ? '/' : '\\'; - const parentSeparatorIndex = path.lastIndexOf(separator); - - if (parentSeparatorIndex > -1 && path.length > relevantRoot.length) { - let current = path.substring(0, parentSeparatorIndex); - while (current && current.length >= relevantRoot.length) { - newSet.add(current); - const nextParentIndex = current.lastIndexOf(separator); - if (nextParentIndex === -1 || current === relevantRoot) { - break; - } - current = current.substring(0, nextParentIndex); - } - } - newSet.add(relevantRoot); - } - return newSet; - }); - } - - if (isNewRoot) { - if (path && !pinnedFolders.includes(path)) { - handleActiveTreeSectionChange('current'); - } - setIsTreeLoading(true); - handleSettingsChange({ ...appSettings, lastRootPath: path } as AppSettings); - try { - const treeData = await invoke(Invokes.GetFolderTree, { path }); - setFolderTree(treeData); - } catch (err) { - console.error('Failed to load folder tree:', err); - setError(`Failed to load folder tree: ${err}. Some sub-folders might be inaccessible.`); - } finally { - setIsTreeLoading(false); - } - } - - setImageList([]); - setImageRatings({}); - setMultiSelectedPaths([]); - setLibraryActivePath(null); - if (selectedImage) { - setSelectedImage(null); - setFinalPreviewUrl(null); - setUncroppedAdjustedPreviewUrl(null); - setHistogram(null); - } - - const command = - libraryViewMode === LibraryViewMode.Recursive ? Invokes.ListImagesRecursive : Invokes.ListImagesInDir; - - let files: ImageFile[]; - if (preloadedImages) { - files = preloadedImages; - } else { - files = await invoke(command, { path }); - } - - const exifSortKeys = ['date_taken', 'iso', 'shutter_speed', 'aperture', 'focal_length']; - const isExifSortActive = exifSortKeys.includes(sortCriteria.key); - const shouldReadExif = appSettings?.enableExifReading ?? false; - - if (shouldReadExif && files.length > 0) { - const paths = files.map((f: ImageFile) => f.path); - - if (isExifSortActive) { - const exifDataMap: Record = await invoke(Invokes.ReadExifForPaths, { paths }); - const finalImageList = files.map((image) => ({ - ...image, - exif: exifDataMap[image.path] || image.exif || null, - })); - setImageList(finalImageList); - } else { - setImageList(files); - invoke(Invokes.ReadExifForPaths, { paths }) - .then((exifDataMap: any) => { - setImageList((currentImageList) => - currentImageList.map((image) => ({ - ...image, - exif: exifDataMap[image.path] || image.exif || null, - })), - ); - }) - .catch((err) => { - console.error('Failed to read EXIF data in background:', err); - }); - } - } else { - setImageList(files); - } - - invoke(Invokes.StartBackgroundIndexing, { folderPath: path }).catch((err) => { - console.error('Failed to start background indexing:', err); - }); - } catch (err) { - console.error('Failed to load folder contents:', err); - setError('Failed to load images from the selected folder.'); - setIsTreeLoading(false); - } finally { - setIsViewLoading(false); - } - }, - [appSettings, handleSettingsChange, selectedImage, rootPath, sortCriteria.key, pinnedFolders, libraryViewMode], - ); - - const handleLibraryRefresh = useCallback(() => { - if (currentFolderPath) handleSelectSubfolder(currentFolderPath, false); - }, [currentFolderPath, handleSelectSubfolder]); - - const refreshImageList = useCallback(async () => { - if (!currentFolderPath) return; - try { - const command = - libraryViewMode === LibraryViewMode.Recursive ? Invokes.ListImagesRecursive : Invokes.ListImagesInDir; - - const files: ImageFile[] = await invoke(command, { path: currentFolderPath }); - const exifSortKeys = ['date_taken', 'iso', 'shutter_speed', 'aperture', 'focal_length']; - const isExifSortActive = exifSortKeys.includes(sortCriteria.key); - const shouldReadExif = appSettings?.enableExifReading ?? false; - - let freshExifData: Record | null = null; - - if (shouldReadExif && files.length > 0 && isExifSortActive) { - const paths = files.map((f: ImageFile) => f.path); - freshExifData = await invoke(Invokes.ReadExifForPaths, { paths }); - } - - setImageList((prevList) => { - const prevMap = new Map(prevList.map((img) => [img.path, img])); - - return files.map((newFile) => { - if (freshExifData && freshExifData[newFile.path]) { - newFile.exif = freshExifData[newFile.path]; - return newFile; - } - const existing = prevMap.get(newFile.path); - if (existing && existing.modified === newFile.modified) { - return existing; - } - - return newFile; - }); - }); - - if (shouldReadExif && files.length > 0 && !isExifSortActive) { - const paths = files.map((f: ImageFile) => f.path); - invoke(Invokes.ReadExifForPaths, { paths }) - .then((exifDataMap: any) => { - setImageList((currentImageList) => - currentImageList.map((image) => { - if (exifDataMap[image.path] && !image.exif) { - return { ...image, exif: exifDataMap[image.path] }; - } - return image; - }), - ); - }) - .catch((err) => { - console.error('Failed to read EXIF data in background:', err); - }); - } - } catch (err) { - console.error('Failed to refresh image list:', err); - setError('Failed to refresh image list.'); - } - }, [currentFolderPath, sortCriteria.key, appSettings?.enableExifReading, libraryViewMode]); - - const handleToggleFolder = useCallback((path: string) => { - setExpandedFolders((prev) => { - const newSet = new Set(prev); - if (newSet.has(path)) { - newSet.delete(path); - } else { - newSet.add(path); - } - return newSet; - }); - }, []); - - useEffect(() => { - if (isInitialMount.current || !appSettings || !rootPath) { - return; - } - - const newFolderState = { - currentFolderPath, - expandedFolders: Array.from(expandedFolders), - }; - - if (JSON.stringify(appSettings.lastFolderState) === JSON.stringify(newFolderState)) { - return; - } - - handleSettingsChange({ ...appSettings, lastFolderState: newFolderState }); - }, [currentFolderPath, expandedFolders, rootPath, appSettings, handleSettingsChange]); - - useEffect(() => { - const handleGlobalContextMenu = (event: any) => { - if (!DEBUG) event.preventDefault(); - }; - window.addEventListener('contextmenu', handleGlobalContextMenu); - return () => window.removeEventListener('contextmenu', handleGlobalContextMenu); - }, []); - - const handleBackToLibrary = useCallback(() => { - const lastActivePath = selectedImage?.path ?? null; - setSelectedImage(null); - setFinalPreviewUrl(null); - setUncroppedAdjustedPreviewUrl(null); - setHistogram(null); - setWaveform(null); - setIsWaveformVisible(false); - setActiveMaskId(null); - setActiveMaskContainerId(null); - setActiveAiPatchContainerId(null); - setIsWbPickerActive(false); - setActiveAiSubMaskId(null); - setLibraryActivePath(lastActivePath); - setSlideDirection(1); - setLiveAdjustments(INITIAL_ADJUSTMENTS); - resetAdjustmentsHistory(INITIAL_ADJUSTMENTS); - }, [selectedImage?.path]); - - const handleImageSelect = useCallback( - (path: string) => { - if (selectedImage?.path === path) { - return; - } - debouncedSave.cancel(); - patchesSentToBackend.current.clear(); - - const knownRating = imageRatings[path] ?? 0; - const placeholderAdjustments = { - ...INITIAL_ADJUSTMENTS, - rating: knownRating, - }; - - setLiveAdjustments(placeholderAdjustments); - resetAdjustmentsHistory(placeholderAdjustments); - - setSelectedImage({ - exif: null, - height: 0, - isRaw: false, - isReady: false, - metadata: null, - originalUrl: null, - path, - thumbnailUrl: thumbnails[path], - width: 0, - }); - setOriginalSize({ width: 0, height: 0 }); - setPreviewSize({ width: 0, height: 0 }); - setMultiSelectedPaths([path]); - setLibraryActivePath(null); - setIsViewLoading(true); - setError(null); - setHistogram(null); - setFinalPreviewUrl(null); - setUncroppedAdjustedPreviewUrl(null); - setTransformedOriginalUrl(null); - setShowOriginal(false); - setActiveMaskId(null); - setActiveMaskContainerId(null); - setActiveAiPatchContainerId(null); - setActiveAiSubMaskId(null); - setIsWbPickerActive(false); - setIsHighResNeeded(false); - - if (transformWrapperRef.current) { - transformWrapperRef.current.resetTransform(0); - } - - setZoom(1); - setIsLibraryExportPanelVisible(false); - - fullResCacheKeyRef.current = null; - setFinalPreviewUrl((prev) => { - if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev); - return null; - }); - }, - [selectedImage?.path, applyAdjustments, debouncedSave, thumbnails, imageRatings, resetAdjustmentsHistory], - ); - - const executeDelete = useCallback( - async (pathsToDelete: Array, options = { includeAssociated: false }) => { - if (!pathsToDelete || pathsToDelete.length === 0) { - return; - } - - const activePath = selectedImage ? selectedImage.path : libraryActivePath; - let nextImagePath: string | null = null; - - if (activePath) { - const physicalPath = activePath.split('?vc=')[0]; - const isActiveImageDeleted = pathsToDelete.some((p) => p === activePath || p === physicalPath); - - if (isActiveImageDeleted) { - const currentIndex = sortedImageList.findIndex((img) => img.path === activePath); - if (currentIndex !== -1) { - const nextCandidate = sortedImageList - .slice(currentIndex + 1) - .find((img) => !pathsToDelete.includes(img.path)); - - if (nextCandidate) { - nextImagePath = nextCandidate.path; - } else { - const prevCandidate = sortedImageList - .slice(0, currentIndex) - .reverse() - .find((img) => !pathsToDelete.includes(img.path)); - - if (prevCandidate) { - nextImagePath = prevCandidate.path; - } - } - } - } else { - nextImagePath = activePath; - } - } - - try { - const command = options.includeAssociated ? 'delete_files_with_associated' : 'delete_files_from_disk'; - await invoke(command, { paths: pathsToDelete }); - - await refreshImageList(); - - if (selectedImage) { - const physicalPath = selectedImage.path.split('?vc=')[0]; - const isFileBeingEditedDeleted = pathsToDelete.some((p) => p === selectedImage.path || p === physicalPath); - - if (isFileBeingEditedDeleted) { - if (nextImagePath) { - handleImageSelect(nextImagePath); - } else { - handleBackToLibrary(); - } - } - } else { - if (nextImagePath) { - setMultiSelectedPaths([nextImagePath]); - setLibraryActivePath(nextImagePath); - } else { - setMultiSelectedPaths([]); - setLibraryActivePath(null); - } - } - } catch (err) { - console.error('Failed to delete files:', err); - setError(`Failed to delete files: ${err}`); - } - }, - [refreshImageList, selectedImage, handleBackToLibrary, libraryActivePath, sortedImageList, handleImageSelect], - ); - - const handleDeleteSelected = useCallback(() => { - const pathsToDelete = multiSelectedPaths; - if (pathsToDelete.length === 0) { - return; - } - - const isSingle = pathsToDelete.length === 1; - - const selectionHasVirtualCopies = - isSingle && - !pathsToDelete[0].includes('?vc=') && - imageList.some((image) => image.path.startsWith(`${pathsToDelete[0]}?vc=`)); - - let modalTitle = 'Confirm Delete'; - let modalMessage = ''; - let confirmText = 'Delete'; - - if (selectionHasVirtualCopies) { - modalTitle = 'Delete Image and All Virtual Copies?'; - modalMessage = `Are you sure you want to permanently delete this image and all of its virtual copies? This action cannot be undone.`; - confirmText = 'Delete All'; - } else if (isSingle) { - modalMessage = `Are you sure you want to permanently delete this image? This action cannot be undone. Right-click for more options (e.g., deleting associated files).`; - confirmText = 'Delete Selected Only'; - } else { - modalMessage = `Are you sure you want to permanently delete these ${pathsToDelete.length} images? This action cannot be undone. Right-click for more options (e.g., deleting associated files).`; - confirmText = 'Delete Selected Only'; - } - - setConfirmModalState({ - confirmText, - confirmVariant: 'destructive', - isOpen: true, - message: modalMessage, - onConfirm: () => executeDelete(pathsToDelete, { includeAssociated: false }), - title: modalTitle, - }); - }, [multiSelectedPaths, executeDelete, imageList]); - - const handleToggleFullScreen = useCallback(() => { - if (isFullScreen) { - setIsFullScreen(false); - } else { - if (!selectedImage) { - return; - } - setIsFullScreen(true); - } - }, [isFullScreen, selectedImage]); - - const handleCopyAdjustments = useCallback(() => { - const sourceAdjustments = selectedImage ? adjustments : libraryActiveAdjustments; - const adjustmentsToCopy: any = {}; - for (const key of COPYABLE_ADJUSTMENT_KEYS) { - if (Object.prototype.hasOwnProperty.call(sourceAdjustments, key)) { - adjustmentsToCopy[key] = sourceAdjustments[key]; - } - } - setCopiedAdjustments(adjustmentsToCopy); - setIsCopied(true); - }, [selectedImage, adjustments, libraryActiveAdjustments]); - - const handlePasteAdjustments = useCallback( - (paths?: Array) => { - if (!copiedAdjustments || !appSettings) { - return; - } - - const { mode, includedAdjustments } = appSettings.copyPasteSettings; - - const adjustmentsToApply: Partial = {}; - - for (const key of includedAdjustments) { - if (Object.prototype.hasOwnProperty.call(copiedAdjustments, key)) { - const value = copiedAdjustments[key as keyof Adjustments]; - - if (mode === PasteMode.Merge) { - const defaultValue = INITIAL_ADJUSTMENTS[key as keyof Adjustments]; - if (JSON.stringify(value) !== JSON.stringify(defaultValue)) { - adjustmentsToApply[key as keyof Adjustments] = value; - } - } else { - adjustmentsToApply[key as keyof Adjustments] = value; - } - } - } - - if (Object.keys(adjustmentsToApply).length === 0) { - setIsPasted(true); - return; - } - - const pathsToUpdate = - paths || (multiSelectedPaths.length > 0 ? multiSelectedPaths : selectedImage ? [selectedImage.path] : []); - if (pathsToUpdate.length === 0) { - return; - } - - if (selectedImage && pathsToUpdate.includes(selectedImage.path)) { - const newAdjustments = { ...adjustments, ...adjustmentsToApply }; - setAdjustments(newAdjustments); - } - - invoke(Invokes.ApplyAdjustmentsToPaths, { paths: pathsToUpdate, adjustments: adjustmentsToApply }).catch( - (err) => { - console.error('Failed to paste adjustments to multiple images:', err); - setError(`Failed to paste adjustments: ${err}`); - }, - ); - setIsPasted(true); - }, - [copiedAdjustments, appSettings, multiSelectedPaths, selectedImage, adjustments, setAdjustments], - ); - - const handleAutoAdjustments = async () => { - if (!selectedImage) { - return; - } - try { - const autoAdjustments: Adjustments = await invoke(Invokes.CalculateAutoAdjustments); - setAdjustments((prev: Adjustments) => { - const newAdjustments = { ...prev, ...autoAdjustments }; - newAdjustments.sectionVisibility = { - ...prev.sectionVisibility, - ...autoAdjustments.sectionVisibility, - }; - - return newAdjustments; - }); - } catch (err) { - console.error('Failed to calculate auto adjustments:', err); - setError(`Failed to apply auto adjustments: ${err}`); - } - }; - - const handleRate = useCallback( - (newRating: number, paths?: Array) => { - const pathsToRate = - paths || (multiSelectedPaths.length > 0 ? multiSelectedPaths : selectedImage ? [selectedImage.path] : []); - if (pathsToRate.length === 0) { - return; - } - - let currentRating = 0; - if (selectedImage && pathsToRate.includes(selectedImage.path)) { - currentRating = adjustments.rating; - } else if (libraryActivePath && pathsToRate.includes(libraryActivePath)) { - currentRating = libraryActiveAdjustments.rating; - } - - const finalRating = newRating === currentRating ? 0 : newRating; - - setImageRatings((prev: Record) => { - const newRatings = { ...prev }; - pathsToRate.forEach((path: string) => { - newRatings[path] = finalRating; - }); - return newRatings; - }); - - if (selectedImage && pathsToRate.includes(selectedImage.path)) { - setAdjustments((prev: Adjustments) => ({ ...prev, rating: finalRating })); - } - - if (libraryActivePath && pathsToRate.includes(libraryActivePath)) { - setLibraryActiveAdjustments((prev) => ({ ...prev, rating: finalRating })); - } - - invoke(Invokes.ApplyAdjustmentsToPaths, { paths: pathsToRate, adjustments: { rating: finalRating } }).catch( - (err) => { - console.error('Failed to apply rating to paths:', err); - setError(`Failed to apply rating: ${err}`); - }, - ); - }, - [ - multiSelectedPaths, - selectedImage, - libraryActivePath, - adjustments.rating, - libraryActiveAdjustments.rating, - setAdjustments, - ], - ); - - const handleSetColorLabel = useCallback( - async (color: string | null, paths?: Array) => { - const pathsToUpdate = - paths || (multiSelectedPaths.length > 0 ? multiSelectedPaths : selectedImage ? [selectedImage.path] : []); - if (pathsToUpdate.length === 0) { - return; - } - const primaryPath = selectedImage?.path || libraryActivePath; - const primaryImage = imageList.find((img: ImageFile) => img.path === primaryPath); - let currentColor = null; - if (primaryImage && primaryImage.tags) { - const colorTag = primaryImage.tags.find((tag: string) => tag.startsWith('color:')); - if (colorTag) { - currentColor = colorTag.substring(6); - } - } - const finalColor = color !== null && color === currentColor ? null : color; - try { - await invoke(Invokes.SetColorLabelForPaths, { paths: pathsToUpdate, color: finalColor }); - - setImageList((prevList: Array) => - prevList.map((image: ImageFile) => { - if (pathsToUpdate.includes(image.path)) { - const otherTags = (image.tags || []).filter((tag: string) => !tag.startsWith('color:')); - const newTags = finalColor ? [...otherTags, `color:${finalColor}`] : otherTags; - return { ...image, tags: newTags }; - } - return image; - }), - ); - } catch (err) { - console.error('Failed to set color label:', err); - setError(`Failed to set color label: ${err}`); - } - }, - [multiSelectedPaths, selectedImage, libraryActivePath, imageList], - ); - - const getCommonTags = useCallback( - (paths: string[]): { tag: string; isUser: boolean }[] => { - if (paths.length === 0) return []; - const imageFiles = imageList.filter((img) => paths.includes(img.path)); - if (imageFiles.length === 0) return []; - - const allTagsSets = imageFiles.map((img) => { - const tagsWithPrefix = (img.tags || []).filter((t) => !t.startsWith('color:')); - return new Set(tagsWithPrefix); - }); - - if (allTagsSets.length === 0) return []; - - const commonTagsWithPrefix = allTagsSets.reduce((intersection, currentSet) => { - return new Set([...intersection].filter((tag) => currentSet.has(tag))); - }); - - return Array.from(commonTagsWithPrefix) - .map((tag) => ({ - tag: tag.startsWith('user:') ? tag.substring(5) : tag, - isUser: tag.startsWith('user:'), - })) - .sort((a, b) => a.tag.localeCompare(b.tag)); - }, - [imageList], - ); - - const handleTagsChanged = useCallback( - (changedPaths: string[], newTags: { tag: string; isUser: boolean }[]) => { - setImageList((prevList) => - prevList.map((image) => { - if (changedPaths.includes(image.path)) { - const colorTags = (image.tags || []).filter((t) => t.startsWith('color:')); - const prefixedNewTags = newTags.map((t) => (t.isUser ? `user:${t.tag}` : t.tag)); - const finalTags = [...colorTags, ...prefixedNewTags].sort(); - return { ...image, tags: finalTags.length > 0 ? finalTags : null }; - } - return image; - }), - ); - }, - [setImageList], - ); - - const closeConfirmModal = () => setConfirmModalState({ ...confirmModalState, isOpen: false }); - - const handlePasteFiles = useCallback( - async (mode = 'copy') => { - if (copiedFilePaths.length === 0 || !currentFolderPath) { - return; - } - try { - if (mode === 'copy') - await invoke(Invokes.CopyFiles, { sourcePaths: copiedFilePaths, destinationFolder: currentFolderPath }); - else { - await invoke(Invokes.MoveFiles, { sourcePaths: copiedFilePaths, destinationFolder: currentFolderPath }); - setCopiedFilePaths([]); - } - await refreshImageList(); - } catch (err) { - setError(`Failed to ${mode} files: ${err}`); - } - }, - [copiedFilePaths, currentFolderPath, refreshImageList], - ); - - const requestFullResolution = useCallback( - debounce((currentAdjustments: any, key: string) => { - if (!selectedImage?.path) return; - - const jobId = ++previewJobIdRef.current; - - invoke(Invokes.GenerateFullscreenPreview, { - jsAdjustments: currentAdjustments, - }) - .then((buffer) => { - if (jobId >= latestRenderedJobIdRef.current) { - latestRenderedJobIdRef.current = jobId; - const blob = new Blob([buffer], { type: 'image/jpeg' }); - const url = URL.createObjectURL(blob); - - setFinalPreviewUrl((prevUrl) => { - if (prevUrl && prevUrl.startsWith('blob:')) URL.revokeObjectURL(prevUrl); - return url; - }); - - fullResCacheKeyRef.current = key; - setIsLoadingFullRes(false); - } - }) - .catch((error: any) => { - if (jobId >= latestRenderedJobIdRef.current) { - console.error('Failed to generate high resolution preview:', error); - fullResCacheKeyRef.current = null; - setIsLoadingFullRes(false); - } - }); - }, 100), - [selectedImage?.path], - ); - - useEffect(() => { - if ((isFullScreen || isHighResNeeded) && selectedImage?.isReady) { - if (fullResCacheKeyRef.current !== visualAdjustmentsKey) { - setIsLoadingFullRes(true); - requestFullResolution(adjustments, visualAdjustmentsKey); - } - } else if (!isFullScreen && !isHighResNeeded) { - if (requestFullResolution.cancel) { - requestFullResolution.cancel(); - } - setIsLoadingFullRes(false); - } - }, [adjustments, isFullScreen, isHighResNeeded, selectedImage?.isReady, requestFullResolution, visualAdjustmentsKey]); - - const handleFullResolutionLogic = useCallback( - (targetZoomPercent: number) => { - if (appSettings?.enableZoomHifi === false) { - return; - } - - if (!initialFitScale) { - return; - } - const highResThreshold = Math.max(initialFitScale * 2, 0.5); - const needsFullRes = targetZoomPercent > highResThreshold; - const previewIsAlreadyFullRes = previewSize.width >= originalSize.width; - - if (needsFullRes && !previewIsAlreadyFullRes) { - setIsHighResNeeded(true); - } else { - setIsHighResNeeded(false); - } - }, - [initialFitScale, previewSize.width, originalSize.width, appSettings], - ); - - const handleZoomChange = useCallback( - (zoomValue: number, fitToWindow: boolean = false) => { - let targetZoomPercent: number; - const orientationSteps = adjustments.orientationSteps || 0; - const isSwapped = orientationSteps === 1 || orientationSteps === 3; - const effectiveOriginalWidth = isSwapped ? originalSize.height : originalSize.width; - const effectiveOriginalHeight = isSwapped ? originalSize.width : originalSize.height; - if (fitToWindow) { - if ( - effectiveOriginalWidth > 0 && - effectiveOriginalHeight > 0 && - baseRenderSize.width > 0 && - baseRenderSize.height > 0 - ) { - const originalAspect = effectiveOriginalWidth / effectiveOriginalHeight; - const baseAspect = baseRenderSize.width / baseRenderSize.height; - if (originalAspect > baseAspect) { - targetZoomPercent = baseRenderSize.width / effectiveOriginalWidth; - } else { - targetZoomPercent = baseRenderSize.height / effectiveOriginalHeight; - } - } else { - targetZoomPercent = 1.0; - } - } else { - targetZoomPercent = zoomValue; - } - targetZoomPercent = Math.max(0.1, Math.min(2.0, targetZoomPercent)); - let transformZoom = 1.0; - if ( - effectiveOriginalWidth > 0 && - effectiveOriginalHeight > 0 && - baseRenderSize.width > 0 && - baseRenderSize.height > 0 - ) { - const originalAspect = effectiveOriginalWidth / effectiveOriginalHeight; - const baseAspect = baseRenderSize.width / baseRenderSize.height; - if (originalAspect > baseAspect) { - transformZoom = (targetZoomPercent * effectiveOriginalWidth) / baseRenderSize.width; - } else { - transformZoom = (targetZoomPercent * effectiveOriginalHeight) / baseRenderSize.height; - } - } - isProgrammaticZoom.current = true; - setZoom(transformZoom); - handleFullResolutionLogic(targetZoomPercent); - }, - [originalSize, baseRenderSize, handleFullResolutionLogic, adjustments.orientationSteps], - ); - - const handleUserTransform = useCallback( - (transformState: TransformState) => { - if (isProgrammaticZoom.current) { - isProgrammaticZoom.current = false; - return; - } - - setZoom(transformState.scale); - - if (originalSize.width > 0 && baseRenderSize.width > 0) { - const orientationSteps = adjustments.orientationSteps || 0; - const isSwapped = orientationSteps === 1 || orientationSteps === 3; - const effectiveOriginalWidth = isSwapped ? originalSize.height : originalSize.width; - - const targetZoomPercent = (baseRenderSize.width * transformState.scale) / effectiveOriginalWidth; - handleFullResolutionLogic(targetZoomPercent); - } - }, - [originalSize, baseRenderSize, handleFullResolutionLogic, adjustments.orientationSteps], - ); - - const isAnyModalOpen = - isCreateFolderModalOpen || - isRenameFolderModalOpen || - isRenameFileModalOpen || - isImportModalOpen || - isCopyPasteSettingsModalOpen || - confirmModalState.isOpen || - panoramaModalState.isOpen || - cullingModalState.isOpen || - collageModalState.isOpen || - denoiseModalState.isOpen || - negativeModalState.isOpen; - - useKeyboardShortcuts({ - isModalOpen: isAnyModalOpen, - activeAiPatchContainerId, - activeAiSubMaskId, - activeMaskContainerId, - activeMaskId, - activeRightPanel, - canRedo, - canUndo, - copiedFilePaths, - customEscapeHandler, - handleBackToLibrary, - handleCopyAdjustments, - handleDeleteAiPatch, - handleDeleteMaskContainer, - handleDeleteSelected, - handleImageSelect, - handlePasteAdjustments, - handlePasteFiles, - handleRate, - handleRightPanelSelect, - handleSetColorLabel, - handleToggleFullScreen, - handleZoomChange, - isFullScreen, - isStraightenActive, - isViewLoading, - libraryActivePath, - multiSelectedPaths, - redo, - selectedImage, - setActiveAiSubMaskId, - setActiveMaskContainerId, - setActiveMaskId, - setCopiedFilePaths, - setIsStraightenActive, - setIsWaveformVisible, - setLibraryActivePath, - setMultiSelectedPaths, - setShowOriginal, - sortedImageList, - undo, - zoom, - displaySize, - baseRenderSize, - originalSize, - }); - - useEffect(() => { - let isEffectActive = true; - const listeners = [ - listen('preview-update-uncropped', (event: any) => { - if (isEffectActive) { - setUncroppedAdjustedPreviewUrl(event.payload); - } - }), - listen('histogram-update', (event: any) => { - if (isEffectActive) { - setHistogram(event.payload); - } - }), - listen('open-with-file', (event: any) => { - if (isEffectActive) { - setInitialFileToOpen(event.payload as string); - } - }), - listen('waveform-update', (event: any) => { - if (isEffectActive) { - setWaveform(event.payload); - } - }), - listen('thumbnail-generated', (event: any) => { - if (isEffectActive) { - const { path, data, rating } = event.payload; - if (data) { - setThumbnails((prev) => ({ ...prev, [path]: data })); - } - if (rating !== undefined) { - setImageRatings((prev) => ({ ...prev, [path]: rating })); - } - } - }), - listen('ai-model-download-start', (event: any) => { - if (isEffectActive) { - setAiModelDownloadStatus(event.payload); - } - }), - listen('ai-model-download-finish', () => { - if (isEffectActive) { - setAiModelDownloadStatus(null); - } - }), - listen('indexing-started', () => { - if (isEffectActive) { - setIsIndexing(true); - setIndexingProgress({ current: 0, total: 0 }); - } - }), - listen('indexing-progress', (event: any) => { - if (isEffectActive) { - setIndexingProgress(event.payload); - } - }), - listen('indexing-finished', () => { - if (isEffectActive) { - setIsIndexing(false); - setIndexingProgress({ current: 0, total: 0 }); - if (currentFolderPathRef.current) { - const refreshImageList = async () => { - try { - const list: ImageFile[] = await invoke(Invokes.ListImagesInDir, { path: currentFolderPathRef.current }); - if (Array.isArray(list)) { - setImageList(list); - } - } catch (err) { - console.error('Failed to refresh after indexing:', err); - } - }; - refreshImageList(); - } - } - }), - listen('batch-export-progress', (event: any) => { - if (isEffectActive) { - setExportState((prev: ExportState) => ({ ...prev, progress: event.payload })); - } - }), - listen('export-complete', () => { - if (isEffectActive) { - setExportState((prev: ExportState) => ({ ...prev, status: Status.Success })); - } - }), - listen('export-error', (event) => { - if (isEffectActive) { - setExportState((prev: ExportState) => ({ - ...prev, - status: Status.Error, - errorMessage: typeof event.payload === 'string' ? event.payload : 'An unknown export error occurred.', - })); - } - }), - listen('export-cancelled', () => { - if (isEffectActive) { - setExportState((prev: ExportState) => ({ ...prev, status: Status.Cancelled })); - } - }), - listen('import-start', (event: any) => { - if (isEffectActive) { - setImportState({ - errorMessage: '', - path: '', - progress: { current: 0, total: event.payload.total }, - status: Status.Importing, - }); - } - }), - listen('import-progress', (event: any) => { - if (isEffectActive) { - setImportState((prev: ImportState) => ({ - ...prev, - path: event.payload.path, - progress: { current: event.payload.current, total: event.payload.total }, - })); - } - }), - listen('import-complete', () => { - if (isEffectActive) { - setImportState((prev: ImportState) => ({ ...prev, status: Status.Success })); - refreshAllFolderTrees(); - if (currentFolderPathRef.current) { - handleSelectSubfolder(currentFolderPathRef.current, false); - } - } - }), - listen('import-error', (event) => { - if (isEffectActive) { - setImportState((prev: ImportState) => ({ - ...prev, - errorMessage: typeof event.payload === 'string' ? event.payload : 'An unknown import error occurred.', - status: Status.Error, - })); - } - }), - listen('denoise-progress', (event: any) => { - if (isEffectActive) { - setDenoiseModalState((prev) => ({ ...prev, progressMessage: event.payload as string })); - } - }), - listen('denoise-complete', (event: any) => { - if (isEffectActive) { - const payload = event.payload; - const isObject = typeof payload === 'object' && payload !== null; - - setDenoiseModalState((prev) => ({ - ...prev, - isProcessing: false, - previewBase64: isObject ? payload.denoised : payload, - originalBase64: isObject ? payload.original : null, - progressMessage: null, - })); - } - }), - listen('denoise-error', (event: any) => { - if (isEffectActive) { - setDenoiseModalState((prev) => ({ - ...prev, - isProcessing: false, - error: String(event.payload), - progressMessage: null, - })); - } - }), - ]; - return () => { - isEffectActive = false; - listeners.forEach((p) => p.then((unlisten) => unlisten())); - }; - }, [refreshAllFolderTrees, handleSelectSubfolder]); - - useEffect(() => { - if ([Status.Success, Status.Error, Status.Cancelled].includes(exportState.status)) { - const timeoutDuration = exportState.status === Status.Success ? 5000 : 3000; - - const timer = setTimeout(() => { - setExportState({ status: Status.Idle, progress: { current: 0, total: 0 }, errorMessage: '' }); - }, timeoutDuration); - return () => clearTimeout(timer); - } - }, [exportState.status]); - - useEffect(() => { - if ([Status.Success, Status.Error].includes(importState.status)) { - const timer = setTimeout(() => { - setImportState({ status: Status.Idle, progress: { current: 0, total: 0 }, path: '', errorMessage: '' }); - }, IMPORT_TIMEOUT); - - return () => clearTimeout(timer); - } - }, [importState.status]); - - useEffect(() => { - if (libraryActivePath) { - invoke(Invokes.LoadMetadata, { path: libraryActivePath }) - .then((metadata: any) => { - if (metadata.adjustments && !metadata.adjustments.is_null) { - const normalized: Adjustments = normalizeLoadedAdjustments(metadata.adjustments); - setLibraryActiveAdjustments(normalized); - } else { - setLibraryActiveAdjustments(INITIAL_ADJUSTMENTS); - } - }) - .catch((err) => { - console.error('Failed to load metadata for library active image', err); - setLibraryActiveAdjustments(INITIAL_ADJUSTMENTS); - }); - } else { - setLibraryActiveAdjustments(INITIAL_ADJUSTMENTS); - } - }, [libraryActivePath]); - - useEffect(() => { - let isEffectActive = true; - - const unlistenProgress = listen('panorama-progress', (event: any) => { - if (isEffectActive) { - setPanoramaModalState((prev: PanoramaModalState) => ({ - ...prev, - error: null, - finalImageBase64: null, - isOpen: true, - progressMessage: event.payload, - })); - } - }); - - const unlistenComplete = listen('panorama-complete', (event: any) => { - if (isEffectActive) { - const { base64 } = event.payload; - setPanoramaModalState((prev: PanoramaModalState) => ({ - ...prev, - error: null, - finalImageBase64: base64, - progressMessage: 'Panorama Ready', - })); - } - }); - - const unlistenError = listen('panorama-error', (event: any) => { - if (isEffectActive) { - setPanoramaModalState((prev: PanoramaModalState) => ({ - ...prev, - error: String(event.payload), - finalImageBase64: null, - progressMessage: 'An error occurred.', - })); - } - }); - - return () => { - isEffectActive = false; - unlistenProgress.then((f: any) => f()); - unlistenComplete.then((f: any) => f()); - unlistenError.then((f: any) => f()); - }; - }, []); - - useEffect(() => { - let isEffectActive = true; - - const unlistenProgress = listen('hdr-progress', (event: any) => { - if (isEffectActive) { - setHdrModalState((prev: HdrModalState) => ({ - ...prev, - error: null, - finalImageBase64: null, - isOpen: true, - progressMessage: event.payload, - })); - } - }); - - const unlistenComplete = listen('hdr-complete', (event: any) => { - if (isEffectActive) { - const { base64 } = event.payload; - setHdrModalState((prev: HdrModalState) => ({ - ...prev, - error: null, - finalImageBase64: base64, - progressMessage: 'Hdr Ready', - })); - } - }); - - const unlistenError = listen('hdr-error', (event: any) => { - if (isEffectActive) { - setHdrModalState((prev: HdrModalState) => ({ - ...prev, - error: String(event.payload), - finalImageBase64: null, - progressMessage: 'An error occurred.', - })); - } - }); - - return () => { - isEffectActive = false; - unlistenProgress.then((f: any) => f()); - unlistenComplete.then((f: any) => f()); - unlistenError.then((f: any) => f()); - }; - }, []); - - useEffect(() => { - let isEffectActive = true; - - const unlistenStart = listen('culling-start', (event: any) => { - if (isEffectActive) { - setCullingModalState({ - isOpen: true, - progress: { current: 0, total: event.payload, stage: 'Initializing...' }, - suggestions: null, - error: null, - }); - } - }); - - const unlistenProgress = listen('culling-progress', (event: any) => { - if (isEffectActive) { - setCullingModalState((prev) => ({ ...prev, progress: event.payload })); - } - }); - - const unlistenComplete = listen('culling-complete', (event: any) => { - if (isEffectActive) { - setCullingModalState((prev) => ({ ...prev, progress: null, suggestions: event.payload })); - } - }); - - const unlistenError = listen('culling-error', (event: any) => { - if (isEffectActive) { - setCullingModalState((prev) => ({ ...prev, progress: null, error: String(event.payload) })); - } - }); - - return () => { - isEffectActive = false; - unlistenStart.then((f) => f()); - unlistenProgress.then((f) => f()); - unlistenComplete.then((f) => f()); - unlistenError.then((f) => f()); - }; - }, []); - - const handleSavePanorama = async (): Promise => { - if (panoramaModalState.stitchingSourcePaths.length === 0) { - const err = 'Source paths for panorama not found.'; - setPanoramaModalState((prev: PanoramaModalState) => ({ ...prev, error: err })); - throw new Error(err); - } - - try { - const savedPath: string = await invoke(Invokes.SavePanorama, { - firstPathStr: panoramaModalState.stitchingSourcePaths[0], - }); - await refreshImageList(); - return savedPath; - } catch (err) { - console.error('Failed to save panorama:', err); - setPanoramaModalState((prev: PanoramaModalState) => ({ ...prev, error: String(err) })); - throw err; - } - }; - - const handleSaveHdr = async (): Promise => { - if (hdrModalState.stitchingSourcePaths.length === 0) { - const err = 'Source paths for HDR not found.'; - setHdrModalState((prev: HdrModalState) => ({ ...prev, error: err })); - throw new Error(err); - } - - try { - const savedPath: string = await invoke(Invokes.SaveHdr, { - firstPathStr: hdrModalState.stitchingSourcePaths[0], - }); - await refreshImageList(); - return savedPath; - } catch (err) { - console.error('Failed to save HDR image:', err); - setHdrModalState((prev: HdrModalState) => ({ ...prev, error: String(err) })); - throw err; - } - }; - - const handleApplyDenoise = useCallback( - async (intensity: number) => { - if (!denoiseModalState.targetPath) return; - - setDenoiseModalState((prev) => ({ - ...prev, - isProcessing: true, - error: null, - progressMessage: 'Starting engine...', - })); - - try { - await invoke(Invokes.ApplyDenoising, { - path: denoiseModalState.targetPath, - intensity: intensity, - }); - } catch (err) { - setDenoiseModalState((prev) => ({ - ...prev, - isProcessing: false, - error: String(err), - })); - } - }, - [denoiseModalState.targetPath], - ); - - const handleSaveDenoisedImage = async (): Promise => { - if (!denoiseModalState.targetPath) throw new Error('No target path'); - const savedPath = await invoke(Invokes.SaveDenoisedImage, { - originalPathStr: denoiseModalState.targetPath, - }); - await refreshImageList(); - return savedPath; - }; - - const handleSaveCollage = async (base64Data: string, firstPath: string): Promise => { - try { - const savedPath: string = await invoke(Invokes.SaveCollage, { - base64Data, - firstPathStr: firstPath, - }); - await refreshImageList(); - return savedPath; - } catch (err) { - console.error('Failed to save collage:', err); - setError(`Failed to save collage: ${err}`); - throw err; - } - }; - - useEffect(() => { - if (!selectedImage?.isReady) return; - - if (dragIdleTimer.current) { - clearTimeout(dragIdleTimer.current); - } - - if (isSliderDragging) { - if (appSettings?.enableLivePreviews !== false) { - applyAdjustments(adjustments, true); - } - - dragIdleTimer.current = setTimeout(() => { - applyAdjustments(adjustments, false); - }, 150); - } else { - applyAdjustments(adjustments, false); - debouncedSave(selectedImage.path, adjustments); - } - - return () => { - if (dragIdleTimer.current) clearTimeout(dragIdleTimer.current); - }; - }, [ - adjustments, - selectedImage?.path, - selectedImage?.isReady, - isSliderDragging, - applyAdjustments, - debouncedSave, - appSettings?.enableLivePreviews, - ]); - - const handleOpenFolder = async () => { - try { - const selected = await open({ directory: true, multiple: false, defaultPath: await homeDir() }); - if (typeof selected === 'string') { - setRootPath(selected); - await handleSelectSubfolder(selected, true); - } - } catch (err) { - console.error('Failed to open directory dialog:', err); - setError('Failed to open folder selection dialog.'); - } - }; - - useEffect(() => { - if (!rootPath) { - setIsLayoutReady(false); - return; - } - - const timer = setTimeout(() => { - setIsLayoutReady(true); - }, 100); - - return () => clearTimeout(timer); - }, [rootPath]); - - const handleContinueSession = () => { - const restore = async () => { - if (!appSettings?.lastRootPath) { - return; - } - - const root = appSettings.lastRootPath; - const folderState = appSettings.lastFolderState; - const pathToSelect = folderState?.currentFolderPath || root; - - setRootPath(root); - - if (folderState?.expandedFolders) { - const newExpandedFolders = new Set(folderState.expandedFolders); - newExpandedFolders.add(root); - setExpandedFolders(newExpandedFolders); - } else { - setExpandedFolders(new Set([root])); - } - - setIsTreeLoading(true); - try { - let treeData; - if (preloadedDataRef.current.rootPath === root && preloadedDataRef.current.tree) { - treeData = await preloadedDataRef.current.tree; - console.log('Preload cache hit for folder tree.'); - } else { - treeData = await invoke(Invokes.GetFolderTree, { path: root }); - } - setFolderTree(treeData); - } catch (err) { - console.error('Failed to restore folder tree:', err); - } finally { - setIsTreeLoading(false); - } - - let preloadedImages: ImageFile[] | undefined = undefined; - if (preloadedDataRef.current.currentPath === pathToSelect && preloadedDataRef.current.images) { - try { - preloadedImages = await preloadedDataRef.current.images; - console.log('Preload cache hit for image list.'); - } catch (e) { - console.error('Failed to retrieve preloaded images', e); - } - } - - await handleSelectSubfolder(pathToSelect, false, preloadedImages); - }; - restore().catch((err) => { - console.error('Failed to restore session, folder might be missing:', err); - setError('Failed to restore session. The last used folder may have been moved or deleted.'); - if (appSettings) { - handleSettingsChange({ ...appSettings, lastRootPath: null, lastFolderState: null }); - } - handleGoHome(); - setIsTreeLoading(false); - }); - }; - - useEffect(() => { - if (!initialFileToOpen || !appSettings) { - return; - } - const parentDir = getParentDir(initialFileToOpen); - if (currentFolderPath !== parentDir) { - setRootPath(parentDir); - handleSelectSubfolder(parentDir, true); - return; - } - const isImageInList = imageList.some((image) => image.path === initialFileToOpen); - if (isImageInList) { - handleImageSelect(initialFileToOpen); - setInitialFileToOpen(null); - } else if (!isViewLoading) { - console.warn(`'open-with-file' target ${initialFileToOpen} not found in its directory after loading. Aborting.`); - setInitialFileToOpen(null); - } - }, [ - initialFileToOpen, - appSettings, - currentFolderPath, - imageList, - isViewLoading, - handleSelectSubfolder, - handleImageSelect, - ]); - - const handleGoHome = () => { - setRootPath(null); - setCurrentFolderPath(null); - setImageList([]); - setImageRatings({}); - setFolderTree(null); - setMultiSelectedPaths([]); - setLibraryActivePath(null); - setIsLibraryExportPanelVisible(false); - setExpandedFolders(new Set()); - }; - - const handleMultiSelectClick = (path: string, event: any, options: MultiSelectOptions) => { - const { ctrlKey, metaKey, shiftKey } = event; - const isCtrlPressed = ctrlKey || metaKey; - const { shiftAnchor, onSimpleClick, updateLibraryActivePath } = options; - - if (shiftKey && shiftAnchor) { - const lastIndex = sortedImageList.findIndex((f) => f.path === shiftAnchor); - const currentIndex = sortedImageList.findIndex((f) => f.path === path); - - if (lastIndex !== -1 && currentIndex !== -1) { - const start = Math.min(lastIndex, currentIndex); - const end = Math.max(lastIndex, currentIndex); - const range = sortedImageList.slice(start, end + 1).map((f: ImageFile) => f.path); - const baseSelection = isCtrlPressed ? multiSelectedPaths : [shiftAnchor]; - const newSelection = Array.from(new Set([...baseSelection, ...range])); - - setMultiSelectedPaths(newSelection); - if (updateLibraryActivePath) { - setLibraryActivePath(path); - } - } - } else if (isCtrlPressed) { - const newSelection = new Set(multiSelectedPaths); - if (newSelection.has(path)) { - newSelection.delete(path); - } else { - newSelection.add(path); - } - - const newSelectionArray = Array.from(newSelection); - setMultiSelectedPaths(newSelectionArray); - - if (updateLibraryActivePath) { - if (newSelectionArray.includes(path)) { - setLibraryActivePath(path); - } else if (newSelectionArray.length > 0) { - setLibraryActivePath(newSelectionArray[newSelectionArray.length - 1]); - } else { - setLibraryActivePath(null); - } - } - } else { - onSimpleClick(path); - } - }; - - const handleLibraryImageSingleClick = (path: string, event: any) => { - handleMultiSelectClick(path, event, { - shiftAnchor: libraryActivePath, - updateLibraryActivePath: true, - onSimpleClick: (p: any) => { - setMultiSelectedPaths([p]); - setLibraryActivePath(p); - }, - }); - }; - - const handleImageClick = (path: string, event: any) => { - const inEditor = !!selectedImage; - handleMultiSelectClick(path, event, { - shiftAnchor: inEditor ? selectedImage.path : libraryActivePath, - updateLibraryActivePath: !inEditor, - onSimpleClick: handleImageSelect, - }); - }; - - useEffect(() => { - const invokeWaveForm = async () => { - const waveForm: any = await invoke(Invokes.GenerateWaveform).catch((err) => - console.error('Failed to generate waveform:', err), - ); - if (waveForm) { - setWaveform(waveForm); - } - }; - - if (isWaveformVisible && selectedImage?.isReady && !waveform) { - invokeWaveForm(); - } - }, [isWaveformVisible, selectedImage?.isReady, waveform]); - - useEffect(() => { - if (selectedImage && !selectedImage.isReady && selectedImage.path) { - let isEffectActive = true; - - const loadMetadataEarly = async () => { - try { - const metadata: any = await invoke(Invokes.LoadMetadata, { path: selectedImage.path }); - if (!isEffectActive) return; - - let initialAdjusts; - if (metadata.adjustments && !metadata.adjustments.is_null) { - initialAdjusts = normalizeLoadedAdjustments(metadata.adjustments); - } else { - initialAdjusts = { ...INITIAL_ADJUSTMENTS }; - } - - setLiveAdjustments(initialAdjusts); - resetAdjustmentsHistory(initialAdjusts); - } catch (err) { - console.error('Failed to load metadata early:', err); - } - }; - - loadMetadataEarly(); - - const loadFullImageData = async () => { - try { - const loadImageResult: any = await invoke(Invokes.LoadImage, { path: selectedImage.path }); - if (!isEffectActive) { - return; - } - - const { width, height } = loadImageResult; - setOriginalSize({ width, height }); - - if (appSettings?.editorPreviewResolution) { - const maxSize = appSettings.editorPreviewResolution; - const aspectRatio = width / height; - - if (width > height) { - const pWidth = Math.min(width, maxSize); - const pHeight = Math.round(pWidth / aspectRatio); - setPreviewSize({ width: pWidth, height: pHeight }); - } else { - const pHeight = Math.min(height, maxSize); - const pWidth = Math.round(pHeight * aspectRatio); - setPreviewSize({ width: pWidth, height: pHeight }); - } - } else { - setPreviewSize({ width: 0, height: 0 }); - } - - fullResCacheKeyRef.current = null; - - setSelectedImage((currentSelected: SelectedImage | null) => { - if (currentSelected && currentSelected.path === selectedImage.path) { - return { - ...currentSelected, - exif: loadImageResult.exif, - height: loadImageResult.height, - isRaw: loadImageResult.is_raw, - isReady: true, - metadata: loadImageResult.metadata, - originalUrl: null, - width: loadImageResult.width, - }; - } - return currentSelected; - }); - - setLiveAdjustments((prev: Adjustments) => { - if (!prev.aspectRatio && !prev.crop) { - return { ...prev, aspectRatio: loadImageResult.width / loadImageResult.height }; - } - return prev; - }); - } catch (err) { - if (isEffectActive) { - console.error('Failed to load image:', err); - setError(`Failed to load image: ${err}`); - setSelectedImage(null); - } - } finally { - if (isEffectActive) { - setIsViewLoading(false); - } - } - }; - loadFullImageData(); - return () => { - isEffectActive = false; - }; - } - }, [selectedImage?.path, selectedImage?.isReady, resetAdjustmentsHistory, appSettings?.editorPreviewResolution]); - - const handleClearSelection = () => { - if (selectedImage) { - setMultiSelectedPaths([selectedImage.path]); - } else { - setMultiSelectedPaths([]); - setLibraryActivePath(null); - } - }; - - const handleRenameFiles = useCallback(async (paths: Array) => { - if (paths && paths.length > 0) { - setRenameTargetPaths(paths); - setIsRenameFileModalOpen(true); - } - }, []); - - const handleSaveRename = useCallback( - async (nameTemplate: string) => { - if (renameTargetPaths.length > 0 && nameTemplate) { - try { - const newPaths: Array = await invoke(Invokes.RenameFiles, { - nameTemplate, - paths: renameTargetPaths, - }); - - await refreshImageList(); - - if (selectedImage && renameTargetPaths.includes(selectedImage.path)) { - const oldPathIndex = renameTargetPaths.indexOf(selectedImage.path); - - if (newPaths[oldPathIndex]) { - handleImageSelect(newPaths[oldPathIndex]); - } else { - handleBackToLibrary(); - } - } - - if (libraryActivePath && renameTargetPaths.includes(libraryActivePath)) { - const oldPathIndex = renameTargetPaths.indexOf(libraryActivePath); - - if (newPaths[oldPathIndex]) { - setLibraryActivePath(newPaths[oldPathIndex]); - } else { - setLibraryActivePath(null); - } - } - - setMultiSelectedPaths(newPaths); - } catch (err) { - setError(`Failed to rename files: ${err}`); - } - } - - setRenameTargetPaths([]); - }, - [renameTargetPaths, refreshImageList, selectedImage, libraryActivePath, handleImageSelect, handleBackToLibrary], - ); - - const handleStartImport = async (settings: AppSettings) => { - if (importSourcePaths.length > 0 && importTargetFolder) { - invoke(Invokes.ImportFiles, { - destinationFolder: importTargetFolder, - settings: settings, - sourcePaths: importSourcePaths, - }).catch((err) => { - console.error('Failed to start import:', err); - setImportState({ status: Status.Error, errorMessage: `Failed to start import: ${err}` }); - }); - } - }; - - const handleResetAdjustments = useCallback( - (paths?: Array) => { - const pathsToReset = paths || multiSelectedPaths; - if (pathsToReset.length === 0) { - return; - } - - debouncedSetHistory.cancel(); - - invoke(Invokes.ResetAdjustmentsForPaths, { paths: pathsToReset }) - .then(() => { - if (libraryActivePath && pathsToReset.includes(libraryActivePath)) { - setLibraryActiveAdjustments((prev: Adjustments) => ({ ...INITIAL_ADJUSTMENTS, rating: prev.rating })); - } - if (selectedImage && pathsToReset.includes(selectedImage.path)) { - const currentRating = adjustments.rating; - - const originalAspectRatio = - selectedImage.width && selectedImage.height ? selectedImage.width / selectedImage.height : null; - - resetAdjustmentsHistory({ - ...INITIAL_ADJUSTMENTS, - aspectRatio: originalAspectRatio, - rating: currentRating, - aiPatches: [], - }); - } - }) - .catch((err) => { - console.error('Failed to reset adjustments:', err); - setError(`Failed to reset adjustments: ${err}`); - }); - }, - [ - multiSelectedPaths, - libraryActivePath, - selectedImage, - adjustments.rating, - resetAdjustmentsHistory, - debouncedSetHistory, - ], - ); - - const handleImportClick = useCallback( - async (targetPath: string) => { - try { - const nonRaw = supportedTypes?.nonRaw || []; - const raw = supportedTypes?.raw || []; - - const expandExtensions = (exts: string[]) => { - return Array.from(new Set(exts.flatMap((ext) => [ext.toLowerCase(), ext.toUpperCase()]))); - }; - - const processedNonRaw = expandExtensions(nonRaw); - const processedRaw = expandExtensions(raw); - const allImageExtensions = [...processedNonRaw, ...processedRaw]; - - const selected = await open({ - filters: [ - { - name: 'All Supported Images', - extensions: allImageExtensions, - }, - { - name: 'RAW Images', - extensions: processedRaw, - }, - { - name: 'Standard Images (JPEG, PNG, etc.)', - extensions: processedNonRaw, - }, - { - name: 'All Files', - extensions: ['*'], - }, - ], - multiple: true, - title: 'Select files to import', - }); - - if (Array.isArray(selected) && selected.length > 0) { - setImportSourcePaths(selected); - setImportTargetFolder(targetPath); - setIsImportModalOpen(true); - } - } catch (err) { - console.error('Failed to open file dialog for import:', err); - } - }, - [supportedTypes], - ); - - const handleEditorContextMenu = (event: any) => { - event.preventDefault(); - event.stopPropagation(); - if (!selectedImage) return; - - const handleCreateVirtualCopy = async (sourcePath: string) => { - try { - await invoke(Invokes.CreateVirtualCopy, { sourceVirtualPath: sourcePath }); - await refreshImageList(); - } catch (err) { - console.error('Failed to create virtual copy:', err); - setError(`Failed to create virtual copy: ${err}`); - } - }; - - const commonTags = getCommonTags([selectedImage.path]); - - const options: Array