diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 21c86cd4..b3151823 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -260,6 +260,10 @@ pub struct AppSettings { pub processing_backend: Option, #[serde(default)] pub linux_gpu_optimization: Option, + #[serde(default)] + pub group_associated_files: Option, + #[serde(default)] + pub preferred_associated_type: Option, } fn default_adjustment_visibility() -> HashMap { @@ -309,6 +313,8 @@ impl Default for AppSettings { linux_gpu_optimization: Some(true), #[cfg(not(target_os = "linux"))] linux_gpu_optimization: Some(false), + group_associated_files: Some(true), + preferred_associated_type: Some("jpeg".to_string()), } } } diff --git a/src/App.tsx b/src/App.tsx index 9ea772b1..2f316724 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -89,6 +89,8 @@ import { } from './components/panel/right/ExportImportProperties'; import { AppSettings, + AssociationInfo, + AssociatedPrimaryPreference, BrushSettings, FilterCriteria, Invokes, @@ -115,6 +117,103 @@ import { ChannelConfig } from './components/adjustments/Curves'; const CLERK_PUBLISHABLE_KEY = 'pk_test_YnJpZWYtc2Vhc25haWwtMTIuY2xlcmsuYWNjb3VudHMuZGV2JA'; // local dev key +const JPEG_EXTENSIONS = new Set(['jpg', 'jpeg']); + +type LibraryImageFile = ImageFile & { associatedPaths?: Array }; + +const getFileExtension = (path: string) => { + const normalized = path.toLowerCase(); + const lastDot = normalized.lastIndexOf('.'); + if (lastDot === -1) { + return ''; + } + return normalized.substring(lastDot + 1); +}; + +const getStemKey = (path: string) => { + const normalized = path.replace(/\\/g, '/'); + const lastDot = normalized.lastIndexOf('.'); + if (lastDot === -1) { + return normalized; + } + return normalized.substring(0, lastDot); +}; + +const buildAssociations = ( + images: Array, + supportedTypes: SupportedTypes | null, + preferredType: AssociatedPrimaryPreference, +) => { + const associationMap: Record = {}; + const groupedList: Array = []; + const groups = new Map< + string, + { variants: Array; jpeg?: ImageFile; raw?: ImageFile } + >(); + const rawExtensions = new Set((supportedTypes?.raw || []).map((ext) => ext.toLowerCase())); + + images.forEach((image) => { + const stem = getStemKey(image.path); + let bucket = groups.get(stem); + if (!bucket) { + bucket = { variants: [] }; + groups.set(stem, bucket); + } + bucket.variants.push(image); + const ext = getFileExtension(image.path); + if (JPEG_EXTENSIONS.has(ext)) { + bucket.jpeg = image; + } else if (rawExtensions.has(ext) && !bucket.raw) { + bucket.raw = image; + } + }); + + groups.forEach((bucket) => { + if (bucket.variants.length === 0) { + return; + } + const pickPrimary = () => { + const jpegCandidate = bucket.jpeg; + const rawCandidate = bucket.raw; + const fallback = bucket.variants[0]; + switch (preferredType) { + case 'raw': + return rawCandidate || jpegCandidate || fallback; + case 'jpeg': + return jpegCandidate || rawCandidate || fallback; + case 'auto': + default: + return jpegCandidate || rawCandidate || fallback; + } + }; + const primary = pickPrimary(); + const variantPaths = bucket.variants.map((variant) => variant.path); + const info: AssociationInfo = { + primaryPath: primary.path, + variantPaths, + jpegPath: bucket.jpeg?.path, + rawPath: bucket.raw?.path, + }; + bucket.variants.forEach((variant) => { + associationMap[variant.path] = info; + }); + groupedList.push({ ...primary, associatedPaths: variantPaths }); + }); + + return { associationMap, groupedList }; +}; + +const getVariantLabel = (path: string, supportedTypes: SupportedTypes | null) => { + const ext = getFileExtension(path); + if (JPEG_EXTENSIONS.has(ext)) { + return 'JPEG'; + } + if (supportedTypes?.raw?.some((rawExt) => rawExt.toLowerCase() === ext)) { + return 'RAW'; + } + return ext ? ext.toUpperCase() : 'FILE'; +}; + interface CollapsibleSectionsState { basic: boolean; color: boolean; @@ -372,11 +471,25 @@ function App() { progress: { current: 0, total: 0 }, status: Status.Idle, }); + const [groupAssociatedFiles, setGroupAssociatedFiles] = useState(true); + const [preferredAssociatedType, setPreferredAssociatedType] = useState('jpeg'); useEffect(() => { currentFolderPathRef.current = currentFolderPath; }, [currentFolderPath]); + const associationData = useMemo( + () => buildAssociations(imageList, supportedTypes, preferredAssociatedType), + [imageList, supportedTypes, preferredAssociatedType], + ); + const associationsByPath = associationData.associationMap; + const groupedImageList = associationData.groupedList; + const isGroupingEnabled = groupAssociatedFiles && filterCriteria.rawStatus === RawStatus.All; + const visibleImageList = useMemo( + () => (isGroupingEnabled ? groupedImageList : imageList), + [isGroupingEnabled, groupedImageList, imageList], + ); + useEffect(() => { if (!isCopied) { return; @@ -818,7 +931,7 @@ function App() { }; const sortedImageList = useMemo(() => { - const filteredList = imageList.filter((image) => { + const filteredList = visibleImageList.filter((image) => { if (filterCriteria.rating > 0) { const rating = imageRatings[image.path] || 0; if (filterCriteria.rating === 5) { @@ -972,7 +1085,25 @@ function App() { return order === SortDirection.Ascending ? comparison : -comparison; }); return list; - }, [imageList, sortCriteria, imageRatings, filterCriteria, supportedTypes, searchQuery, appSettings]); + }, [visibleImageList, sortCriteria, imageRatings, filterCriteria, supportedTypes, searchQuery, appSettings]); + + const variantOptions = useMemo(() => { + if (!selectedImage) { + return []; + } + const association = associationsByPath[selectedImage.path]; + if (!association || association.variantPaths.length < 2) { + return []; + } + return association.variantPaths.map((path) => ({ + path, + label: getVariantLabel(path, supportedTypes), + })); + }, [selectedImage?.path, associationsByPath, supportedTypes]); + + const filmstripActivePath = selectedImage + ? associationsByPath[selectedImage.path]?.primaryPath || selectedImage.path + : null; const applyAdjustments = useCallback( debounce((currentAdjustments) => { @@ -1140,6 +1271,16 @@ function App() { if (settings?.thumbnailAspectRatio) { setThumbnailAspectRatio(settings.thumbnailAspectRatio); } + if (settings?.groupAssociatedFiles !== undefined) { + setGroupAssociatedFiles(settings.groupAssociatedFiles); + } else { + setGroupAssociatedFiles(true); + } + if (settings?.preferredAssociatedType) { + setPreferredAssociatedType(settings.preferredAssociatedType); + } else { + setPreferredAssociatedType('jpeg'); + } if (settings?.activeTreeSection) { setActiveTreeSection(settings.activeTreeSection); } @@ -1174,6 +1315,14 @@ function App() { setIsWaveformVisible((prev: boolean) => !prev); }, []); + const handleGroupAssociationsChange = useCallback((value: boolean) => { + setGroupAssociatedFiles(value); + }, []); + + const handlePreferredAssociatedTypeChange = useCallback((value: AssociatedPrimaryPreference) => { + setPreferredAssociatedType(value); + }, []); + useEffect(() => { if (isInitialMount.current || !appSettings) { return; @@ -1216,6 +1365,24 @@ function App() { } }, [filterCriteria, appSettings, handleSettingsChange]); + useEffect(() => { + if (isInitialMount.current || !appSettings) { + return; + } + if (appSettings.groupAssociatedFiles !== groupAssociatedFiles) { + handleSettingsChange({ ...appSettings, groupAssociatedFiles }); + } + }, [groupAssociatedFiles, appSettings, handleSettingsChange]); + + useEffect(() => { + if (isInitialMount.current || !appSettings) { + return; + } + if (appSettings.preferredAssociatedType !== preferredAssociatedType) { + handleSettingsChange({ ...appSettings, preferredAssociatedType }); + } + }, [preferredAssociatedType, appSettings, handleSettingsChange]); + useEffect(() => { if (appSettings?.adaptiveEditorTheme && selectedImage && finalPreviewUrl) { generatePaletteFromImage(finalPreviewUrl) @@ -1467,6 +1634,10 @@ function App() { const handleBackToLibrary = useCallback(() => { const lastActivePath = selectedImage?.path ?? null; + const primaryPath = + lastActivePath && associationsByPath[lastActivePath] + ? associationsByPath[lastActivePath].primaryPath + : lastActivePath; setSelectedImage(null); setFinalPreviewUrl(null); setUncroppedAdjustedPreviewUrl(null); @@ -1477,8 +1648,9 @@ function App() { setActiveMaskContainerId(null); setActiveAiPatchContainerId(null); setActiveAiSubMaskId(null); - setLibraryActivePath(lastActivePath); - }, [selectedImage?.path]); + setLibraryActivePath(primaryPath); + setMultiSelectedPaths(primaryPath ? [primaryPath] : []); + }, [associationsByPath, selectedImage?.path]); const executeDelete = useCallback( async (pathsToDelete: Array, options = { includeAssociated: false }) => { @@ -1970,6 +2142,16 @@ function App() { [selectedImage?.path, applyAdjustments, debouncedSave, thumbnails, resetAdjustmentsHistory], ); + const handleVariantSelect = useCallback( + (path: string) => { + if (selectedImage?.path === path) { + return; + } + handleImageSelect(path); + }, + [handleImageSelect, selectedImage?.path], + ); + useKeyboardShortcuts({ activeAiPatchContainerId, activeAiSubMaskId, @@ -2000,6 +2182,7 @@ function App() { multiSelectedPaths, redo, selectedImage, + selectedDisplayPath: filmstripActivePath, setActiveAiSubMaskId, setActiveMaskContainerId, setActiveMaskId, @@ -2807,12 +2990,8 @@ function App() { const stitchLabel = `Stitch Panorama`; const hasAssociatedFiles = finalSelection.some((selectedPath) => { - const lastDotIndex = selectedPath.lastIndexOf('.'); - if (lastDotIndex === -1) return false; - const basePath = selectedPath.substring(0, lastDotIndex); - return imageList.some( - (image) => image.path.startsWith(basePath + '.') && image.path !== selectedPath, - ); + const association = associationsByPath[selectedPath]; + return association && association.variantPaths.length > 1; }); const deleteOption = { @@ -3301,12 +3480,15 @@ function App() { isFullResolution={isFullResolution} fullResolutionUrl={fullResolutionUrl} isLoadingFullRes={isLoadingFullRes} + variantOptions={variantOptions} + onVariantSelect={handleVariantSelect} /> setActiveView('community')} + groupAssociatedFiles={groupAssociatedFiles} + preferredAssociatedType={preferredAssociatedType} + onGroupAssociationsChange={handleGroupAssociationsChange} + onPreferredAssociationTypeChange={handlePreferredAssociatedTypeChange} /> )} {rootPath && ( @@ -3714,4 +3900,4 @@ const AppWrapper = () => ( ); -export default AppWrapper; \ No newline at end of file +export default AppWrapper; diff --git a/src/components/panel/BottomBar.tsx b/src/components/panel/BottomBar.tsx index 217568e6..9fc8ecd0 100644 --- a/src/components/panel/BottomBar.tsx +++ b/src/components/panel/BottomBar.tsx @@ -5,6 +5,7 @@ import Filmstrip from './Filmstrip'; import { GLOBAL_KEYS, ImageFile, SelectedImage, ThumbnailAspectRatio } from '../ui/AppProperties'; interface BottomBarProps { + activeDisplayPath?: string | null; filmstripHeight?: number; imageList?: Array; imageRatings?: Record | null; @@ -79,6 +80,7 @@ const StarRating = ({ rating, onRate, disabled }: StarRatingProps) => { }; export default function BottomBar({ + activeDisplayPath, filmstripHeight, imageList = [], imageRatings, @@ -220,7 +222,7 @@ export default function BottomBar({ onClearSelection={onClearSelection} onContextMenu={onContextMenu} onImageSelect={onImageSelect} - selectedImage={selectedImage} + activePath={activeDisplayPath || selectedImage?.path || null} thumbnails={thumbnails} thumbnailAspectRatio={thumbnailAspectRatio} /> @@ -356,4 +358,4 @@ export default function BottomBar({ ); -} \ No newline at end of file +} diff --git a/src/components/panel/Editor.tsx b/src/components/panel/Editor.tsx index 927123fa..34c96121 100644 --- a/src/components/panel/Editor.tsx +++ b/src/components/panel/Editor.tsx @@ -65,6 +65,8 @@ interface EditorProps { isFullResolution?: boolean; fullResolutionUrl?: string | null; isLoadingFullRes?: boolean; + variantOptions?: Array<{ label: string; path: string }>; + onVariantSelect?(path: string): void; } export default function Editor({ @@ -116,6 +118,8 @@ export default function Editor({ isFullResolution, fullResolutionUrl, isLoadingFullRes, + variantOptions, + onVariantSelect, }: EditorProps) { const [crop, setCrop] = useState(null); const prevCropParams = useRef(null); @@ -510,6 +514,8 @@ export default function Editor({ selectedImage={selectedImage} showOriginal={showOriginal} isLoadingFullRes={isLoadingFullRes} + variantOptions={variantOptions} + onVariantSelect={onVariantSelect} />
); -} \ No newline at end of file +} diff --git a/src/components/panel/Filmstrip.tsx b/src/components/panel/Filmstrip.tsx index 4016ba39..912ab5c3 100644 --- a/src/components/panel/Filmstrip.tsx +++ b/src/components/panel/Filmstrip.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { Image as ImageIcon, Star } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import clsx from 'clsx'; -import { ImageFile, SelectedImage, ThumbnailAspectRatio } from '../ui/AppProperties'; +import { ImageFile, ThumbnailAspectRatio } from '../ui/AppProperties'; import { Color, COLOR_LABELS } from '../../utils/adjustments'; interface ImageLayer { @@ -204,9 +204,9 @@ interface FilmStripProps { onClearSelection?(): void; onContextMenu?(event: any, path: string): void; onImageSelect?(path: string, event: any): void; - selectedImage?: SelectedImage; thumbnails: Record | undefined; thumbnailAspectRatio: ThumbnailAspectRatio; + activePath?: string | null; } export default function Filmstrip({ @@ -217,9 +217,9 @@ export default function Filmstrip({ onClearSelection, onContextMenu, onImageSelect, - selectedImage, thumbnails, thumbnailAspectRatio, + activePath, }: FilmStripProps) { const filmstripRef = useRef(null); @@ -247,11 +247,11 @@ export default function Filmstrip({ }, []); useEffect(() => { - if (selectedImage && filmstripRef.current) { - const selectedIndex = imageList.findIndex((img: ImageFile) => img.path === selectedImage.path); + if (activePath && filmstripRef.current) { + const selectedIndex = imageList.findIndex((img: ImageFile) => img.path === activePath); if (selectedIndex !== -1) { - const activeElement = filmstripRef.current.querySelector(`[data-path="${CSS.escape(selectedImage.path)}"]`); + const activeElement = filmstripRef.current.querySelector(`[data-path="${CSS.escape(activePath)}"]`); if (activeElement) { setTimeout(() => { @@ -264,7 +264,7 @@ export default function Filmstrip({ } } } - }, [selectedImage, imageList]); + }, [activePath, imageList]); return (
@@ -275,7 +275,7 @@ export default function Filmstrip({ key={imageFile.path} imageFile={imageFile} imageRatings={imageRatings} - isActive={selectedImage?.path === imageFile.path} + isActive={activePath === imageFile.path} isSelected={multiSelectedPaths.includes(imageFile.path)} onContextMenu={onContextMenu} onImageSelect={onImageSelect} @@ -287,4 +287,4 @@ export default function Filmstrip({
); -} \ No newline at end of file +} diff --git a/src/components/panel/MainLibrary.tsx b/src/components/panel/MainLibrary.tsx index 927f73e1..8948c5e4 100644 --- a/src/components/panel/MainLibrary.tsx +++ b/src/components/panel/MainLibrary.tsx @@ -26,6 +26,7 @@ import SettingsPanel from './SettingsPanel'; import { ThemeProps, THEMES, DEFAULT_THEME_ID } from '../../utils/themes'; import { AppSettings, + AssociatedPrimaryPreference, FilterCriteria, ImageFile, Invokes, @@ -105,6 +106,10 @@ interface MainLibraryProps { thumbnails: Record; thumbnailSize: ThumbnailSize; onNavigateToCommunity(): void; + groupAssociatedFiles: boolean; + preferredAssociatedType: AssociatedPrimaryPreference; + onGroupAssociationsChange(value: boolean): void; + onPreferredAssociationTypeChange(value: AssociatedPrimaryPreference): void; } interface SearchInputProps { @@ -161,6 +166,14 @@ interface ThumbnailAspectRatioProps { selectedAspectRatio: ThumbnailAspectRatio; } +interface AssociationOptionsProps { + groupAssociatedFiles: boolean; + onGroupAssociationsChange(value: boolean): void; + preferredAssociatedType: AssociatedPrimaryPreference; + onPreferredAssociationTypeChange(value: AssociatedPrimaryPreference): void; + groupingDisabled: boolean; +} + interface ViewOptionsProps { filterCriteria: FilterCriteria; onSelectSize(size: ThumbnailSize): any; @@ -171,6 +184,10 @@ interface ViewOptionsProps { sortOptions: Array & { label?: string; disabled?: boolean }>; thumbnailSize: ThumbnailSize; thumbnailAspectRatio: ThumbnailAspectRatio; + groupAssociatedFiles: boolean; + onGroupAssociationsChange(value: boolean): void; + preferredAssociatedType: AssociatedPrimaryPreference; + onPreferredAssociationTypeChange(value: AssociatedPrimaryPreference): void; } const ratingFilterOptions: Array = [ @@ -450,6 +467,63 @@ function ThumbnailAspectRatioOptions({ selectedAspectRatio, onSelectAspectRatio ); } +function AssociationOptions({ + groupAssociatedFiles, + onGroupAssociationsChange, + preferredAssociatedType, + onPreferredAssociationTypeChange, + groupingDisabled, +}: AssociationOptionsProps) { + const preferenceOptions: Array<{ id: AssociatedPrimaryPreference; label: string }> = [ + { id: 'jpeg', label: 'Prefer JPEG' }, + { id: 'raw', label: 'Prefer RAW' }, + ]; + + return ( +
+
+ Associated Files + {groupingDisabled && ( + Only available for All Types + )} +
+ +
+
Preferred Variant
+
+ {preferenceOptions.map((option) => { + const isActive = preferredAssociatedType === option.id; + return ( + + ); + })} +
+
+
+ ); +} + function FilterOptions({ filterCriteria, setFilterCriteria }: FilterOptionProps) { const handleRatingFilterChange = (rating: number | undefined) => { setFilterCriteria((prev: Partial) => ({ ...prev, rating })); @@ -577,11 +651,17 @@ function ViewOptionsDropdown({ sortOptions, thumbnailSize, thumbnailAspectRatio, + groupAssociatedFiles, + onGroupAssociationsChange, + preferredAssociatedType, + onPreferredAssociationTypeChange, }: ViewOptionsProps) { const isFilterActive = filterCriteria.rating > 0 || (filterCriteria.rawStatus && filterCriteria.rawStatus !== RawStatus.All) || - (filterCriteria.colors && filterCriteria.colors.length > 0); + (filterCriteria.colors && filterCriteria.colors.length > 0) || + !groupAssociatedFiles; + const groupingDisabled = filterCriteria.rawStatus !== RawStatus.All; return (
+
@@ -861,6 +948,10 @@ export default function MainLibrary({ thumbnails, thumbnailSize, onNavigateToCommunity, + groupAssociatedFiles, + preferredAssociatedType, + onGroupAssociationsChange, + onPreferredAssociationTypeChange, }: MainLibraryProps) { const [showSettings, setShowSettings] = useState(false); const [appVersion, setAppVersion] = useState(''); @@ -1205,6 +1296,10 @@ export default function MainLibrary({ sortOptions={sortOptions} thumbnailSize={thumbnailSize} thumbnailAspectRatio={thumbnailAspectRatio} + groupAssociatedFiles={groupAssociatedFiles} + onGroupAssociationsChange={onGroupAssociationsChange} + preferredAssociatedType={preferredAssociatedType} + onPreferredAssociationTypeChange={onPreferredAssociationTypeChange} /> + {variantOptions && variantOptions.length > 1 && ( +
+ {variantOptions.map((variant) => { + const isActive = variant.path === selectedImage.path; + return ( + + ); + })} +
+ )}