diff --git a/src-tauri/src/exif_processing.rs b/src-tauri/src/exif_processing.rs index 33d0c4fed..aeec3eca8 100644 --- a/src-tauri/src/exif_processing.rs +++ b/src-tauri/src/exif_processing.rs @@ -560,6 +560,50 @@ pub fn extract_metadata(file_bytes: &[u8]) -> Option> { Some(map) } +/// Like `get_creation_date_from_path` but returns `None` when no real +/// EXIF/RAW metadata timestamp is found. Does NOT fall back to filesystem +/// dates or `Utc::now()`. Used for EXIF-based grouping verification. +pub fn try_get_exif_creation_date(path: &std::path::Path) -> Option> { + if let Some(map) = read_rrexif_sidecar(path) + && let Some(dt_str) = map.get("DateTimeOriginal").or(map.get("CreateDate")) + && let Some(dt) = parse_creation_datetime(dt_str) + { + return Some(DateTime::from_naive_utc_and_offset(dt, Utc)); + } + + if let Ok(file) = std::fs::File::open(path) { + let mut bufreader = BufReader::new(&file); + let exifreader = exif::Reader::new(); + + if let Ok(exif_obj) = exifreader.read_from_container(&mut bufreader) { + for tag in [exif::Tag::DateTimeOriginal, exif::Tag::DateTime] { + if let Some(field) = exif_obj.get_field(tag, exif::In::PRIMARY) + && let Some(dt) = parse_creation_field(field) + { + return Some(dt); + } + } + } + } + + if is_raw_file(path.to_string_lossy().as_ref()) { + let loader = rawler::RawLoader::new(); + if let Ok(raw_source) = rawler::rawsource::RawSource::new(path) + && let Ok(decoder) = loader.get_decoder(&raw_source) + && let Ok(metadata) = decoder.raw_metadata(&raw_source, &Default::default()) + { + if let Some(dt) = parse_raw_creation_date(metadata.exif.date_time_original.as_deref()) { + return Some(dt); + } + if let Some(dt) = parse_raw_creation_date(metadata.exif.create_date.as_deref()) { + return Some(dt); + } + } + } + + None +} + pub fn get_creation_date_from_path(path: &std::path::Path) -> DateTime { if let Some(map) = read_rrexif_sidecar(path) && let Some(dt_str) = map.get("DateTimeOriginal").or(map.get("CreateDate")) diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index bee7b4058..f739f28c3 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -456,6 +456,12 @@ pub struct AppSettings { pub active_waveform_channel: Option, #[serde(default)] pub use_wgpu_renderer: Option, + #[serde(default)] + pub group_preferred_type: Option, + #[serde(default)] + pub group_edited_files: Option, + #[serde(default)] + pub require_matching_exif: Option, } fn default_adjustment_visibility() -> HashMap { @@ -525,6 +531,9 @@ impl Default for AppSettings { use_wgpu_renderer: Some(false), #[cfg(not(any(target_os = "linux", target_os = "android")))] use_wgpu_renderer: Some(true), + group_preferred_type: Some("raw".to_string()), + group_edited_files: Some(true), + require_matching_exif: Some(false), } } } @@ -538,6 +547,76 @@ pub struct ImageFile { tags: Option>, exif: Option>, is_virtual_copy: bool, + is_raw: bool, + group_id: Option, +} + +/// Grouping key from a source image path: the full path with the +/// extension stripped. Files sharing this key are variants of the +/// same shot. Case-sensitive. +fn make_group_key(source_path: &Path) -> String { + source_path.with_extension("").to_string_lossy().into_owned() +} + +/// Tag files that share a stem with `group_id`. Virtual copies are +/// excluded from counting (one file + its virtual copy don't form a +/// group) but still get assigned the group_id of their source. +/// When `group_edited_files` is false, files that have been edited in +/// RapidRAW (have non-rating adjustments in their sidecar) are also +/// excluded from grouping. +/// When `require_matching_exif` is true, files sharing a stem are only +/// grouped if their EXIF creation timestamps match exactly. +fn assign_group_ids( + files: &mut Vec, + group_edited_files: bool, + require_matching_exif: bool, +) { + let mut stem_sources: HashMap> = HashMap::new(); + + for file in files.iter() { + if file.is_virtual_copy { + continue; + } + if !group_edited_files && file.is_edited { + continue; + } + let (source_path, _) = parse_virtual_path(&file.path); + let key = make_group_key(&source_path); + stem_sources.entry(key).or_default().insert(source_path); + } + + // When EXIF verification is enabled, remove stems where files have + // different creation timestamps. + // Files without EXIF metadata are excluded from grouping entirely. + if require_matching_exif { + stem_sources.retain(|_key, paths| { + if paths.len() < 2 { + return true; // nothing to verify + } + let timestamps: Vec>> = paths + .iter() + .map(|p| exif_processing::try_get_exif_creation_date(p)) + .collect(); + // All files must have EXIF data to be grouped + if !timestamps.iter().all(|t| t.is_some()) { + return false; + } + // All timestamps must match exactly + let first = timestamps[0].unwrap(); + timestamps.iter().all(|t| t.unwrap() == first) + }); + } + + for file in files.iter_mut() { + if !group_edited_files && file.is_edited { + continue; + } + let (source_path, _) = parse_virtual_path(&file.path); + let key = make_group_key(&source_path); + if stem_sources.get(&key).map_or(false, |s| s.len() >= 2) { + file.group_id = Some(key); + } + } } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -912,7 +991,7 @@ pub fn list_images_in_dir(path: String, app_handle: AppHandle) -> Result = tasks + let mut result_list: Vec = tasks .into_par_iter() .flat_map(|(path_str, file_name, path_buf, sidecars)| { let modified = fs::metadata(&path_buf) @@ -967,6 +1046,8 @@ pub fn list_images_in_dir(path: String, app_handle: AppHandle) -> Result Result = tasks + let mut result_list: Vec = tasks .into_par_iter() .flat_map(|(path_str, file_name, path_buf, sidecars)| { let modified = fs::metadata(&path_buf) @@ -1093,6 +1177,8 @@ pub fn list_images_recursive( tags, exif: None, is_virtual_copy, + is_raw: is_raw_file(&path_str), + group_id: None, rating, }); } @@ -1101,6 +1187,9 @@ pub fn list_images_recursive( }) .collect(); + let group_edited = settings.group_edited_files.unwrap_or(true); + let require_exif = settings.require_matching_exif.unwrap_or(false); + assign_group_ids(&mut result_list, group_edited, require_exif); Ok(result_list) } @@ -3312,9 +3401,7 @@ pub fn delete_files_with_associated(paths: Vec) -> Result<(), String> { for path_str in &paths { let (source_path, _) = parse_virtual_path(path_str); - if let Some(file_name) = source_path.file_name().and_then(|s| s.to_str()) - && let Some(stem) = file_name.split('.').next() - { + if let Some(stem) = source_path.file_stem().and_then(|s| s.to_str()) { stems_to_delete.insert(stem.to_string()); } if let Some(parent) = source_path.parent() { @@ -3339,13 +3426,39 @@ pub fn delete_files_with_associated(paths: Vec) -> Result<(), String> { let entry_filename = entry.file_name(); let entry_filename_str = entry_filename.to_string_lossy(); - if let Some(base_stem) = entry_filename_str.split('.').next() - && stems_to_delete.contains(base_stem) - && (is_supported_image_file(entry_filename_str.as_ref()) - || entry_filename_str.ends_with(".rrdata") - || entry_filename_str.ends_with(".rrexif")) + if entry_filename_str.ends_with(".rrdata") { + // Sidecars: {filename}.rrdata or {filename}.{vc_id}.rrdata + // VC ids are first 6 chars of a UUID v4, see create_virtual_copy() + let without_rrdata = entry_filename_str.trim_end_matches(".rrdata"); + let image_filename = if let Some(dot_pos) = without_rrdata.rfind('.') { + let suffix = &without_rrdata[dot_pos + 1..]; + if suffix.len() == 6 && suffix.chars().all(|c| c.is_ascii_hexdigit()) { + &without_rrdata[..dot_pos] + } else { + without_rrdata + } + } else { + without_rrdata + }; + let sidecar_stem = Path::new(image_filename) + .file_stem() + .and_then(|s| s.to_str()); + if let Some(stem) = sidecar_stem { + if stems_to_delete.contains(stem) { + files_to_trash.insert(entry_path); + } + } + } else if is_supported_image_file(entry_filename_str.as_ref()) + || entry_filename_str.ends_with(".rrexif") { - files_to_trash.insert(entry_path); + let entry_stem = Path::new(entry_filename_str.as_ref()) + .file_stem() + .and_then(|s| s.to_str()); + if let Some(stem) = entry_stem { + if stems_to_delete.contains(stem) { + files_to_trash.insert(entry_path); + } + } } } } diff --git a/src/App.tsx b/src/App.tsx index 4329e2cc6..ce3ba29b5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -117,7 +117,10 @@ import { ThumbnailSize, ThumbnailAspectRatio, CullingSuggestions, + GroupId, + GroupPreference, } from './components/ui/AppProperties'; +import { buildImageGroups, getVariantLabel, GroupingResult } from './utils/imageGrouping'; import { ChannelConfig } from './components/adjustments/Curves'; import HdrModal from './components/modals/HdrModal'; @@ -291,6 +294,9 @@ function App() { rawStatus: RawStatus.All, }); const [supportedTypes, setSupportedTypes] = useState(null); + const [groupPreferredType, setGroupPreferredType] = useState('raw'); + const [groupEditedFiles, setGroupEditedFiles] = useState(true); + const [requireMatchingExif, setRequireMatchingExif] = useState(false); const [selectedImage, setSelectedImage] = useState(null); const selectedImagePathRef = useRef(null); useEffect(() => { @@ -1146,49 +1152,56 @@ function App() { } }; - 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 isGroupingActive = filterCriteria.rawStatus === RawStatus.GroupVariants || + filterCriteria.rawStatus === RawStatus.RawOverNonRaw; // migration: treat old setting as grouping + + const groupingResult: GroupingResult | null = useMemo(() => { + if (!isGroupingActive) return null; + return buildImageGroups(imageList, groupPreferredType, groupEditedFiles); + }, [imageList, isGroupingActive, groupPreferredType, groupEditedFiles]); + + // Per-group badge data for thumbnails (count + extension label like "RAF+JPG") + const groupBadgeInfo: Record = useMemo(() => { + if (!groupingResult) return {}; + const info: Record = {}; + for (const [groupId, group] of groupingResult.groups) { + const extensions = [...new Set(group.variants.map((v) => getVariantLabel(v.path)))]; + info[groupId] = { + count: group.variants.length, + label: extensions.join('+'), + }; } + return info; + }, [groupingResult]); + + // Variant pills for the editor toolbar. Suppressed for VCs since the + // pills point to originals and would silently leave the VC context. + const variantOptions = useMemo(() => { + if (!selectedImage || !groupingResult) return []; + const imageFile = imageList.find((img) => img.path === selectedImage.path); + if (!imageFile?.group_id || imageFile.is_virtual_copy) return []; + const group = groupingResult.groups.get(imageFile.group_id); + if (!group || group.variants.length < 2) return []; + return group.variants.map((v) => ({ + path: v.path, + label: getVariantLabel(v.path), + })); + }, [selectedImage?.path, groupingResult, imageList]); + + // Highlight the group primary in the filmstrip even when viewing a non-primary variant + const filmstripActivePath = useMemo(() => { + if (!selectedImage) return null; + if (!groupingResult) return selectedImage.path; + const imageFile = imageList.find((img) => img.path === selectedImage.path); + if (!imageFile?.group_id || imageFile.is_virtual_copy) return selectedImage.path; + const group = groupingResult.groups.get(imageFile.group_id); + return group?.primary.path ?? selectedImage.path; + }, [selectedImage?.path, groupingResult, imageList]); + + const sortedImageList = useMemo(() => { + const processedList = isGroupingActive && groupingResult + ? groupingResult.displayList + : imageList; const filteredList = processedList.filter((image) => { if (filterCriteria.rating > 0) { @@ -1204,15 +1217,12 @@ function App() { filterCriteria.rawStatus && filterCriteria.rawStatus !== RawStatus.All && filterCriteria.rawStatus !== RawStatus.RawOverNonRaw && - supportedTypes + filterCriteria.rawStatus !== RawStatus.GroupVariants ) { - const extension = image.path.split('.').pop()?.toLowerCase() || ''; - const isRaw = supportedTypes.raw?.includes(extension); - - if (filterCriteria.rawStatus === RawStatus.RawOnly && !isRaw) { + if (filterCriteria.rawStatus === RawStatus.RawOnly && !image.is_raw) { return false; } - if (filterCriteria.rawStatus === RawStatus.NonRawOnly && isRaw) { + if (filterCriteria.rawStatus === RawStatus.NonRawOnly && image.is_raw) { return false; } } @@ -1367,7 +1377,7 @@ function App() { return order === SortDirection.Ascending ? comparison : -comparison; }); return list; - }, [imageList, sortCriteria, imageRatings, filterCriteria, supportedTypes, searchCriteria, appSettings]); + }, [imageList, sortCriteria, imageRatings, filterCriteria, supportedTypes, searchCriteria, appSettings, isGroupingActive, groupingResult]); useEffect(() => { if (selectedImage?.path && selectedImage.isReady && finalPreviewUrl) { @@ -1851,6 +1861,15 @@ function App() { if (settings?.waveformHeight !== undefined) { setWaveformHeight(settings.waveformHeight); } + if (settings?.groupPreferredType) { + setGroupPreferredType(settings.groupPreferredType); + } + if (settings?.groupEditedFiles !== undefined) { + setGroupEditedFiles(settings.groupEditedFiles); + } + if (settings?.requireMatchingExif !== undefined) { + setRequireMatchingExif(settings.requireMatchingExif); + } if (settings?.pinnedFolders && settings.pinnedFolders.length > 0) { try { const trees = await invoke(Invokes.GetPinnedFolderTrees, { @@ -1978,6 +1997,42 @@ function App() { } }, [isWaveformVisible, activeWaveformChannel, waveformHeight, appSettings, handleSettingsChange]); + useEffect(() => { + if (isInitialMount.current || !appSettings) { + return; + } + if (appSettings.groupPreferredType !== groupPreferredType) { + handleSettingsChange({ + ...appSettings, + groupPreferredType, + }); + } + }, [groupPreferredType, appSettings, handleSettingsChange]); + + useEffect(() => { + if (isInitialMount.current || !appSettings) { + return; + } + if (appSettings.groupEditedFiles !== groupEditedFiles) { + handleSettingsChange({ + ...appSettings, + groupEditedFiles, + }); + } + }, [groupEditedFiles, appSettings, handleSettingsChange]); + + useEffect(() => { + if (isInitialMount.current || !appSettings) { + return; + } + if (appSettings.requireMatchingExif !== requireMatchingExif) { + handleSettingsChange({ + ...appSettings, + requireMatchingExif, + }); + } + }, [requireMatchingExif, appSettings, handleSettingsChange]); + useEffect(() => { const root = document.documentElement; const currentThemeId = theme || DEFAULT_THEME_ID; @@ -2526,6 +2581,14 @@ function App() { [selectedImage?.path, debouncedSave, debouncedSetHistory, thumbnails, resetAdjustmentsHistory], ); + const handleVariantSelect = useCallback( + (path: string) => { + if (selectedImage?.path === path) return; + handleImageSelect(path); + }, + [handleImageSelect, selectedImage?.path], + ); + const executeDelete = useCallback( async (pathsToDelete: Array, options = { includeAssociated: false }) => { if (!pathsToDelete || pathsToDelete.length === 0) { @@ -4422,10 +4485,9 @@ function App() { imageList.some((image) => image.path.startsWith(`${finalSelection[0]}?vc=`)); 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 image = imageList.find((img) => img.path === selectedPath); + // Rust sets group_id when >= 2 files share a stem, independent of the UI toggle + return image?.group_id != null; }); let deleteSubmenu; @@ -5102,6 +5164,13 @@ function App() { onNavigateToCommunity={() => setActiveView('community')} listColumnWidths={listColumnWidths} setListColumnWidths={setListColumnWidths} + groupPreferredType={groupPreferredType} + groupBadgeInfo={groupBadgeInfo} + groupEditedFiles={groupEditedFiles} + requireMatchingExif={requireMatchingExif} + onGroupPreferredTypeChange={setGroupPreferredType} + onGroupEditedFilesChange={setGroupEditedFiles} + onRequireMatchingExifChange={setRequireMatchingExif} /> )} {rootPath && ( @@ -5205,6 +5274,8 @@ function App() { liveRotation={liveRotation} isInstantTransition={isInstantTransition} hasRenderedFirstFrame={hasRenderedFirstFrame} + variantOptions={variantOptions} + onVariantSelect={handleVariantSelect} />
; imageRatings?: Record | null; @@ -83,6 +84,7 @@ const StarRating = ({ rating, onRate, disabled }: StarRatingProps) => { }; export default function BottomBar({ + activeDisplayPath, filmstripHeight, imageList = [], imageRatings, @@ -248,6 +250,7 @@ export default function BottomBar({ onContextMenu={onContextMenu} onImageSelect={onImageSelect} onRequestThumbnails={onRequestThumbnails} + activeDisplayPath={activeDisplayPath} selectedImage={selectedImage} thumbnails={thumbnails} thumbnailAspectRatio={thumbnailAspectRatio} diff --git a/src/components/panel/Editor.tsx b/src/components/panel/Editor.tsx index 050098cca..d490438e9 100644 --- a/src/components/panel/Editor.tsx +++ b/src/components/panel/Editor.tsx @@ -86,6 +86,8 @@ interface EditorProps { liveRotation?: number | null; isInstantTransition: boolean; hasRenderedFirstFrame: boolean; + variantOptions?: Array<{ label: string; path: string }>; + onVariantSelect?(path: string): void; } export default function Editor({ @@ -140,6 +142,8 @@ export default function Editor({ liveRotation, isInstantTransition, hasRenderedFirstFrame, + variantOptions, + onVariantSelect, }: EditorProps) { const [crop, setCrop] = useState(null); const prevCropParams = useRef(null); @@ -1467,6 +1471,8 @@ export default function Editor({ adjustmentsHistory={adjustmentsHistory} adjustmentsHistoryIndex={adjustmentsHistoryIndex} goToAdjustmentsHistoryIndex={goToAdjustmentsHistoryIndex} + variantOptions={variantOptions} + onVariantSelect={onVariantSelect} />
diff --git a/src/components/panel/Filmstrip.tsx b/src/components/panel/Filmstrip.tsx index 20d3b32ce..a0dcc1897 100644 --- a/src/components/panel/Filmstrip.tsx +++ b/src/components/panel/Filmstrip.tsx @@ -559,6 +559,7 @@ const FilmstripList = ({ }; interface FilmStripProps { + activeDisplayPath?: string | null; imageList: Array; imageRatings: any; isLoading: boolean; @@ -573,6 +574,7 @@ interface FilmStripProps { } export default function Filmstrip({ + activeDisplayPath, imageList, imageRatings, isLoading: _isLoading, @@ -619,7 +621,7 @@ export default function Filmstrip({ data={{ imageList, imageRatings, - selectedPath: selectedImage?.path, + selectedPath: activeDisplayPath ?? selectedImage?.path, multiSelectedPaths, thumbnails, thumbnailAspectRatio, diff --git a/src/components/panel/MainLibrary.tsx b/src/components/panel/MainLibrary.tsx index fae200e7c..b0e4828cc 100644 --- a/src/components/panel/MainLibrary.tsx +++ b/src/components/panel/MainLibrary.tsx @@ -11,6 +11,7 @@ import { FolderInput, Home, Image as ImageIcon, + Layers, Loader2, FolderOpen, RefreshCw, @@ -29,6 +30,8 @@ import { ThemeProps, THEMES, DEFAULT_THEME_ID } from '../../utils/themes'; import { AppSettings, FilterCriteria, + GroupId, + GroupPreference, ImageFile, Invokes, LibraryViewMode, @@ -62,6 +65,12 @@ interface DropdownMenuProps { interface FilterOptionProps { filterCriteria: FilterCriteria; + groupPreferredType?: GroupPreference; + groupEditedFiles?: boolean; + requireMatchingExif?: boolean; + onGroupPreferredTypeChange?(type: GroupPreference): void; + onGroupEditedFilesChange?(value: boolean): void; + onRequireMatchingExifChange?(value: boolean): void; setFilterCriteria(criteria: any): void; } @@ -123,6 +132,13 @@ interface MainLibraryProps { onNavigateToCommunity(): void; listColumnWidths: ColumnWidths; setListColumnWidths: React.Dispatch>; + groupPreferredType: GroupPreference; + groupBadgeInfo?: Record; + groupEditedFiles?: boolean; + requireMatchingExif?: boolean; + onGroupPreferredTypeChange(type: GroupPreference): void; + onGroupEditedFilesChange?(value: boolean): void; + onRequireMatchingExifChange?(value: boolean): void; } interface SearchInputProps { @@ -146,6 +162,7 @@ interface ImageLayer { interface ThumbnailProps { data: string | undefined; + groupBadge?: { count: number; label: string }; isActive: boolean; isSelected: boolean; onContextMenu(e: any): void; @@ -186,7 +203,13 @@ interface ThumbnailAspectRatioProps { interface ViewOptionsProps { filterCriteria: FilterCriteria; + groupPreferredType: GroupPreference; + groupEditedFiles?: boolean; + requireMatchingExif?: boolean; libraryViewMode: LibraryViewMode; + onGroupPreferredTypeChange(type: GroupPreference): void; + onGroupEditedFilesChange?(value: boolean): void; + onRequireMatchingExifChange?(value: boolean): void; onSelectSize(size: ThumbnailSize): any; onSelectAspectRatio(aspectRatio: ThumbnailAspectRatio): any; setFilterCriteria(criteria: Partial): void; @@ -211,7 +234,7 @@ const rawStatusOptions: Array = [ { key: RawStatus.All, label: 'All Types' }, { key: RawStatus.RawOnly, label: 'RAW Only' }, { key: RawStatus.NonRawOnly, label: 'Non-RAW Only' }, - { key: RawStatus.RawOverNonRaw, label: 'Prefer RAW' }, + { key: RawStatus.GroupVariants, label: 'Group RAW + JPEG' }, ]; const thumbnailSizeOptions: Array = [ @@ -758,7 +781,7 @@ function ThumbnailAspectRatioOptions({ selectedAspectRatio, onSelectAspectRatio ); } -function FilterOptions({ filterCriteria, setFilterCriteria }: FilterOptionProps) { +function FilterOptions({ filterCriteria, setFilterCriteria, groupPreferredType, groupEditedFiles, requireMatchingExif, onGroupPreferredTypeChange, onGroupEditedFilesChange, onRequireMatchingExifChange }: FilterOptionProps) { const handleRatingFilterChange = (rating: number | undefined) => { setFilterCriteria((prev: Partial) => ({ ...prev, rating })); }; @@ -827,6 +850,60 @@ function FilterOptions({ filterCriteria, setFilterCriteria }: FilterOptionProps) ); })} + {(filterCriteria.rawStatus === RawStatus.GroupVariants || + filterCriteria.rawStatus === RawStatus.RawOverNonRaw) && + onGroupPreferredTypeChange && ( +
+ + Prefer + +
+ {(['raw', 'jpeg'] as const).map((type) => ( + + ))} +
+
+ )} + {(filterCriteria.rawStatus === RawStatus.GroupVariants || + filterCriteria.rawStatus === RawStatus.RawOverNonRaw) && + onGroupEditedFilesChange && ( + + )} + {(filterCriteria.rawStatus === RawStatus.GroupVariants || + filterCriteria.rawStatus === RawStatus.RawOverNonRaw) && + onRequireMatchingExifChange && ( + + )}
@@ -933,7 +1010,13 @@ function ViewModeOptions({ mode, setMode }: { mode: LibraryViewMode; setMode: (m function ViewOptionsDropdown({ filterCriteria, + groupPreferredType, + groupEditedFiles, + requireMatchingExif, libraryViewMode, + onGroupPreferredTypeChange, + onGroupEditedFilesChange, + onRequireMatchingExifChange, onSelectSize, onSelectAspectRatio, setFilterCriteria, @@ -974,7 +1057,16 @@ function ViewOptionsDropdown({
- +
@@ -1189,6 +1281,7 @@ function ListItem({ function Thumbnail({ data, + groupBadge, isActive, isSelected, onContextMenu, @@ -1356,6 +1449,14 @@ function Thumbnail({ {baseName} + {groupBadge && groupBadge.count >= 2 && ( +
+ +
+ )} {isVirtualCopy && ( onContextMenu(e, imageFile.path)} @@ -1541,6 +1644,13 @@ export default function MainLibrary({ onNavigateToCommunity, listColumnWidths, setListColumnWidths, + groupPreferredType, + groupBadgeInfo, + groupEditedFiles, + requireMatchingExif, + onGroupPreferredTypeChange, + onGroupEditedFilesChange, + onRequireMatchingExifChange, }: MainLibraryProps) { const [showSettings, setShowSettings] = useState(false); const [appVersion, setAppVersion] = useState(''); @@ -2114,7 +2224,13 @@ export default function MainLibrary({ /> { const isAnyLoading = isLoading; const [isLoaderVisible, setIsLoaderVisible] = useState(false); @@ -620,6 +629,30 @@ const EditorToolbar = memo( > {showOriginal ? : } + {variantOptions && variantOptions.length > 1 && ( +
+ {variantOptions.map((variant) => { + const isActive = variant.path === selectedImage.path; + return ( + + ); + })} +
+ )}