From 703565205f301d8ed6d19aa9ec73cab10509eb15 Mon Sep 17 00:00:00 2001 From: savukadoo Date: Sat, 14 Mar 2026 21:58:51 +1000 Subject: [PATCH 1/2] Added a edit icon to top right of edited image --- src/components/panel/Filmstrip.tsx | 12 +++++++++++- src/components/panel/MainLibrary.tsx | 13 +++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/components/panel/Filmstrip.tsx b/src/components/panel/Filmstrip.tsx index cc764bf2d..a36a7930f 100644 --- a/src/components/panel/Filmstrip.tsx +++ b/src/components/panel/Filmstrip.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback, useMemo, memo, forwardRef } from 'react'; -import { Image as ImageIcon, Star } from 'lucide-react'; +import { Image as ImageIcon, Star, Pencil } from 'lucide-react'; import { motion } from 'framer-motion'; import clsx from 'clsx'; import { Grid, useGridCallbackRef } from 'react-window'; @@ -67,6 +67,7 @@ const FilmstripThumbnail = memo( const isInitialLoad = useRef(true); const { path, tags } = imageFile; + const isEdited = imageFile.is_edited; const rating = imageRatings?.[path] || 0; const colorTag = tags?.find((t: string) => t.startsWith('color:'))?.substring(6); const colorLabel = COLOR_LABELS.find((c: Color) => c.name === colorTag); @@ -210,6 +211,15 @@ const FilmstripThumbnail = memo( )} + {isEdited && ( +
+ +
+ )} + {(colorLabel || rating > 0) && (
{colorLabel && ( diff --git a/src/components/panel/MainLibrary.tsx b/src/components/panel/MainLibrary.tsx index a6bbaef3e..739f37eb8 100644 --- a/src/components/panel/MainLibrary.tsx +++ b/src/components/panel/MainLibrary.tsx @@ -18,6 +18,7 @@ import { Search, Users, X, + Pencil, } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { List, useListCallbackRef } from 'react-window'; @@ -143,6 +144,7 @@ interface ThumbnailProps { rating: number; tags: Array; aspectRatio: ThumbnailAspectRatio; + isEdited: boolean; } interface ThumbnailSizeOption { @@ -893,6 +895,7 @@ function Thumbnail({ rating, tags, aspectRatio: thumbnailAspectRatio, + isEdited, }: ThumbnailProps) { const [showPlaceholder, setShowPlaceholder] = useState(false); const [layers, setLayers] = useState([]); @@ -1024,6 +1027,15 @@ function Thumbnail({ )} + {isEdited && ( +
+ +
+ )} + {(colorLabel || rating > 0) && (
{colorLabel && ( @@ -1158,6 +1170,7 @@ const Row = ({ rating={imageRatings?.[imageFile.path] || 0} tags={imageFile.tags} aspectRatio={thumbnailAspectRatio} + isEdited={imageFile.is_edited} />
))} From 705f74241db5730e05ee65920351f6adb8bcd7f8 Mon Sep 17 00:00:00 2001 From: savukadoo Date: Sat, 21 Mar 2026 18:37:50 +1000 Subject: [PATCH 2/2] Adjusted logic of what an edited image vs a non-edit image state is --- src-tauri/src/file_management.rs | 242 +++++++++++++++++++++++---- src-tauri/src/image_processing.rs | 16 +- src-tauri/src/main.rs | 3 + src/App.tsx | 112 ++++++++++--- src/components/panel/Editor.tsx | 17 +- src/components/panel/Filmstrip.tsx | 8 +- src/components/panel/MainLibrary.tsx | 6 +- src/components/ui/AppProperties.tsx | 1 + src/utils/adjustments.tsx | 26 +++ 9 files changed, 352 insertions(+), 79 deletions(-) diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index b198c1a48..a5e4799fe 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -64,6 +64,144 @@ fn emit_thumbnail_cache_setup_error(app_handle: &AppHandle, path: &str, reason: ); } +fn all_sections_visible_value() -> Value { + serde_json::json!({ + "basic": true, + "color": true, + "curves": true, + "details": true, + "effects": true, + }) +} + +fn default_adjustments_value() -> Value { + serde_json::from_str( + r#"{ + "aiPatches": [], + "blacks": 0, + "brightness": 0, + "centré": 0, + "clarity": 0, + "chromaticAberrationBlueYellow": 0, + "chromaticAberrationRedCyan": 0, + "colorCalibration": { + "blueHue": 0, + "blueSaturation": 0, + "greenHue": 0, + "greenSaturation": 0, + "redHue": 0, + "redSaturation": 0, + "shadowsTint": 0 + }, + "colorGrading": { + "balance": 0, + "blending": 50, + "highlights": { "hue": 0, "luminance": 0, "saturation": 0 }, + "midtones": { "hue": 0, "luminance": 0, "saturation": 0 }, + "shadows": { "hue": 0, "luminance": 0, "saturation": 0 } + }, + "colorNoiseReduction": 0, + "contrast": 0, + "crop": null, + "curves": { + "blue": [{ "x": 0, "y": 0 }, { "x": 255, "y": 255 }], + "green": [{ "x": 0, "y": 0 }, { "x": 255, "y": 255 }], + "luma": [{ "x": 0, "y": 0 }, { "x": 255, "y": 255 }], + "red": [{ "x": 0, "y": 0 }, { "x": 255, "y": 255 }] + }, + "dehaze": 0, + "exposure": 0, + "flareAmount": 0, + "flipHorizontal": false, + "flipVertical": false, + "glowAmount": 0, + "grainAmount": 0, + "grainRoughness": 50, + "grainSize": 25, + "halationAmount": 0, + "highlights": 0, + "hsl": { + "aquas": { "hue": 0, "luminance": 0, "saturation": 0 }, + "blues": { "hue": 0, "luminance": 0, "saturation": 0 }, + "greens": { "hue": 0, "luminance": 0, "saturation": 0 }, + "magentas": { "hue": 0, "luminance": 0, "saturation": 0 }, + "oranges": { "hue": 0, "luminance": 0, "saturation": 0 }, + "purples": { "hue": 0, "luminance": 0, "saturation": 0 }, + "reds": { "hue": 0, "luminance": 0, "saturation": 0 }, + "yellows": { "hue": 0, "luminance": 0, "saturation": 0 } + }, + "lensDistortionAmount": 100, + "lensDistortionEnabled": true, + "lensDistortionParams": null, + "lensMaker": null, + "lensModel": null, + "lensTcaAmount": 100, + "lensTcaEnabled": true, + "lensVignetteAmount": 100, + "lensVignetteEnabled": true, + "lumaNoiseReduction": 0, + "lutData": null, + "lutIntensity": 100, + "lutName": null, + "lutPath": null, + "lutSize": 0, + "masks": [], + "orientationSteps": 0, + "rating": 0, + "rotation": 0, + "saturation": 0, + "sectionVisibility": { + "basic": true, + "color": true, + "curves": true, + "details": true, + "effects": true + }, + "shadows": 0, + "sharpness": 0, + "showClipping": false, + "structure": 0, + "temperature": 0, + "tint": 0, + "toneMapper": "basic", + "transformAspect": 0, + "transformDistortion": 0, + "transformHorizontal": 0, + "transformRotate": 0, + "transformScale": 100, + "transformVertical": 0, + "transformXOffset": 0, + "transformYOffset": 0, + "vibrance": 0, + "vignetteAmount": 0, + "vignetteFeather": 50, + "vignetteMidpoint": 50, + "vignetteRoundness": 0, + "whites": 0 + }"#, + ) + .expect("default adjustments JSON should be valid") +} + +fn is_meaningfully_edited(adjustments: &Value, is_raw: bool) -> bool { + if adjustments.is_null() { + return false; + } + + let mut normalized_adjustments = adjustments.clone(); + if let Some(obj) = normalized_adjustments.as_object_mut() { + // Treat aspect ratio as a UI/default value, not a user edit. + obj.remove("aspectRatio"); + obj.insert("sectionVisibility".to_string(), all_sections_visible_value()); + obj.insert("showClipping".to_string(), Value::Bool(false)); + } + + let current = get_all_adjustments_from_json(&normalized_adjustments, is_raw); + let default = get_all_adjustments_from_json(&default_adjustments_value(), is_raw); + + current != default +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Preset { pub id: String, @@ -617,9 +755,7 @@ pub fn list_images_in_dir(path: String, app_handle: AppHandle) -> Result 1 || (a.keys().len() == 1 && !a.contains_key("rating")) - }); + let edited = is_meaningfully_edited(&metadata.adjustments, is_raw_file(&source_path_buf)); (edited, metadata.tags) }; @@ -733,9 +869,7 @@ pub fn list_images_recursive( } } - let edited = metadata.adjustments.as_object().map_or(false, |a| { - a.keys().len() > 1 || (a.keys().len() == 1 && !a.contains_key("rating")) - }); + let edited = is_meaningfully_edited(&metadata.adjustments, is_raw_file(&source_path_buf)); (edited, metadata.tags) }; @@ -753,6 +887,25 @@ pub fn list_images_recursive( Ok(result_list) } +#[tauri::command] +pub fn get_image_edit_state(path: String) -> Result { + let (source_path, sidecar_path) = parse_virtual_path(&path); + + if !sidecar_path.exists() { + return Ok(false); + } + + let metadata: ImageMetadata = fs::read_to_string(&sidecar_path) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + .unwrap_or_default(); + + Ok(is_meaningfully_edited( + &metadata.adjustments, + is_raw_file(&source_path), + )) +} + #[derive(Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct FolderNode { @@ -1622,30 +1775,44 @@ pub fn move_files(source_paths: Vec, destination_folder: String) -> Resu pub fn save_metadata_and_update_thumbnail( path: String, adjustments: Value, + generation: u64, app_handle: AppHandle, state: tauri::State, ) -> Result<(), String> { let (source_path, sidecar_path) = parse_virtual_path(&path); let source_path_str = source_path.to_string_lossy().to_string(); - let mut metadata: ImageMetadata = if sidecar_path.exists() { - fs::read_to_string(&sidecar_path) - .ok() - .and_then(|content| serde_json::from_str(&content).ok()) - .unwrap_or_default() - } else { - ImageMetadata::default() - }; + { + let mut generations = state.metadata_write_generations.lock().unwrap(); + let current_generation = generations.get(&path).copied().unwrap_or(0); + if generation < current_generation { + return Ok(()); + } + generations.insert(path.clone(), generation); + + let mut metadata: ImageMetadata = if sidecar_path.exists() { + fs::read_to_string(&sidecar_path) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + .unwrap_or_default() + } else { + ImageMetadata::default() + }; - metadata.rating = adjustments["rating"].as_u64().unwrap_or(0) as u8; - metadata.adjustments = adjustments; + metadata.rating = adjustments["rating"].as_u64().unwrap_or(0) as u8; + metadata.adjustments = adjustments.clone(); - let json_string = serde_json::to_string_pretty(&metadata).map_err(|e| e.to_string())?; - std::fs::write(&sidecar_path, json_string).map_err(|e| e.to_string())?; + let json_string = serde_json::to_string_pretty(&metadata).map_err(|e| e.to_string())?; + std::fs::write(&sidecar_path, json_string).map_err(|e| e.to_string())?; + } if let Ok(settings) = load_settings(app_handle.clone()) { if settings.enable_xmp_sync.unwrap_or(false) { let create_if_missing = settings.create_xmp_if_missing.unwrap_or(false); + let metadata: ImageMetadata = fs::read_to_string(&sidecar_path) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + .unwrap_or_default(); sync_metadata_to_xmp(&source_path, &metadata, create_if_missing); } } @@ -1816,6 +1983,7 @@ pub fn apply_adjustments_to_paths( pub fn reset_adjustments_for_paths( paths: Vec, app_handle: AppHandle, + state: tauri::State, ) -> Result<(), String> { let settings = load_settings(app_handle.clone()).unwrap_or_default(); let enable_xmp_sync = settings.enable_xmp_sync.unwrap_or(false); @@ -1824,24 +1992,32 @@ pub fn reset_adjustments_for_paths( paths.par_iter().for_each(|path| { let (_, sidecar_path) = parse_virtual_path(path); - let mut existing_metadata: ImageMetadata = if sidecar_path.exists() { - fs::read_to_string(&sidecar_path) - .ok() - .and_then(|content| serde_json::from_str(&content).ok()) - .unwrap_or_default() - } else { - ImageMetadata::default() - }; + let existing_metadata = { + let mut generations = state.metadata_write_generations.lock().unwrap(); + let next_generation = generations.get(path).copied().unwrap_or(0) + 1; + generations.insert(path.clone(), next_generation); - let new_adjustments = serde_json::json!({ - "rating": existing_metadata.rating - }); + let mut metadata: ImageMetadata = if sidecar_path.exists() { + fs::read_to_string(&sidecar_path) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + .unwrap_or_default() + } else { + ImageMetadata::default() + }; - existing_metadata.adjustments = new_adjustments; + let new_adjustments = serde_json::json!({ + "rating": metadata.rating + }); - if let Ok(json_string) = serde_json::to_string_pretty(&existing_metadata) { - let _ = std::fs::write(&sidecar_path, json_string); - } + metadata.adjustments = new_adjustments; + + if let Ok(json_string) = serde_json::to_string_pretty(&metadata) { + let _ = std::fs::write(&sidecar_path, json_string); + } + + metadata + }; if enable_xmp_sync { let source_path = parse_virtual_path(path).0; diff --git a/src-tauri/src/image_processing.rs b/src-tauri/src/image_processing.rs index 08f4847db..d9abf6fb6 100644 --- a/src-tauri/src/image_processing.rs +++ b/src-tauri/src/image_processing.rs @@ -962,7 +962,7 @@ pub struct AutoAdjustmentResults { pub centre: f64, } -#[derive(Serialize, Deserialize, Debug, Clone, Copy, Pod, Zeroable, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Pod, Zeroable, Default)] #[repr(C)] pub struct Point { x: f32, @@ -971,7 +971,7 @@ pub struct Point { _pad2: f32, } -#[derive(Serialize, Deserialize, Debug, Clone, Copy, Pod, Zeroable, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Pod, Zeroable, Default)] #[repr(C)] pub struct HslColor { hue: f32, @@ -980,7 +980,7 @@ pub struct HslColor { _pad: f32, } -#[derive(Serialize, Deserialize, Debug, Clone, Copy, Pod, Zeroable, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Pod, Zeroable, Default)] #[repr(C)] pub struct ColorGradeSettings { pub hue: f32, @@ -989,7 +989,7 @@ pub struct ColorGradeSettings { _pad: f32, } -#[derive(Serialize, Deserialize, Debug, Clone, Copy, Pod, Zeroable, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Pod, Zeroable, Default)] #[repr(C)] pub struct ColorCalibrationSettings { pub shadows_tint: f32, @@ -1002,7 +1002,7 @@ pub struct ColorCalibrationSettings { _pad1: f32, } -#[derive(Serialize, Deserialize, Debug, Clone, Copy, Pod, Zeroable)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Pod, Zeroable)] #[repr(C)] pub struct GpuMat3 { col0: [f32; 4], @@ -1020,7 +1020,7 @@ impl Default for GpuMat3 { } } -#[derive(Serialize, Deserialize, Debug, Clone, Copy, Pod, Zeroable, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Pod, Zeroable, Default)] #[repr(C)] pub struct GlobalAdjustments { pub exposure: f32, @@ -1105,7 +1105,7 @@ pub struct GlobalAdjustments { _pad_creative_1: f32, } -#[derive(Serialize, Deserialize, Debug, Clone, Copy, Pod, Zeroable, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Pod, Zeroable, Default)] #[repr(C)] pub struct MaskAdjustments { pub exposure: f32, @@ -1158,7 +1158,7 @@ pub struct MaskAdjustments { _pad_end7: f32, } -#[derive(Debug, Clone, Copy, Pod, Zeroable, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable, Default)] #[repr(C)] pub struct AllAdjustments { pub global: GlobalAdjustments, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 82e5548cb..b3b612002 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -160,6 +160,7 @@ pub struct AppState { pub lens_db: Mutex>, pub load_image_generation: Arc, pub full_warped_cache: Mutex)>>, + pub metadata_write_generations: Mutex>, } #[derive(serde::Serialize)] @@ -4309,6 +4310,7 @@ fn main() { lens_db: Mutex::new(None), load_image_generation: Arc::new(AtomicUsize::new(0)), full_warped_cache: Mutex::new(None), + metadata_write_generations: Mutex::new(HashMap::new()), }) .invoke_handler(tauri::generate_handler![ load_image, @@ -4354,6 +4356,7 @@ fn main() { file_management::read_exif_for_paths, file_management::list_images_in_dir, file_management::list_images_recursive, + file_management::get_image_edit_state, file_management::get_folder_tree, file_management::get_pinned_folder_trees, file_management::generate_thumbnails, diff --git a/src/App.tsx b/src/App.tsx index 9fbeebd61..d3fff5f48 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -82,6 +82,7 @@ import { COPYABLE_ADJUSTMENT_KEYS, INITIAL_ADJUSTMENTS, MaskContainer, + isMeaningfullyEdited, normalizeLoadedAdjustments, PasteMode, CopyPasteSettings, @@ -309,6 +310,8 @@ function App() { const [overlayRotation, setOverlayRotation] = useState(0); const [transformedOriginalUrl, setTransformedOriginalUrl] = useState(null); const patchesSentToBackend = useRef>(new Set()); + const userInitiatedAdjustmentChangeRef = useRef(false); + const autoSaveGenerationRef = useRef(0); const handleDisplaySizeChange = useCallback( ( @@ -520,6 +523,7 @@ function App() { const setAdjustments = useCallback( (value: any) => { + userInitiatedAdjustmentChangeRef.current = true; setLiveAdjustments((prevAdjustments: Adjustments) => { const newAdjustments = typeof value === 'function' ? value(prevAdjustments) : value; debouncedSetHistory(newAdjustments); @@ -582,12 +586,14 @@ function App() { const undo = useCallback(() => { if (canUndo) { + userInitiatedAdjustmentChangeRef.current = true; undoAdjustments(); debouncedSetHistory.cancel(); } }, [canUndo, undoAdjustments, debouncedSetHistory]); const redo = useCallback(() => { if (canRedo) { + userInitiatedAdjustmentChangeRef.current = true; redoAdjustments(); debouncedSetHistory.cancel(); } @@ -1364,14 +1370,34 @@ function App() { } }, [adjustments, activeRightPanel, selectedImage?.isReady, generateUncroppedPreview]); + const syncImageEditedState = useCallback(async (path: string) => { + try { + const isEdited: boolean = await invoke(Invokes.GetImageEditState, { path }); + setImageList((prevList) => + prevList.map((image) => (image.path === path ? { ...image, is_edited: isEdited } : image)), + ); + return isEdited; + } catch (err) { + console.error('Failed to sync image edit state:', err); + return null; + } + }, []); + 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}`); - }); + debounce((path, adjustmentsToSave, generation) => { + if (generation !== autoSaveGenerationRef.current) { + return; + } + invoke(Invokes.SaveMetadataAndUpdateThumbnail, { path, adjustments: adjustmentsToSave, generation }) + .then(() => { + void syncImageEditedState(path); + }) + .catch((err) => { + console.error('Auto-save failed:', err); + setError(`Failed to save changes: ${err}`); + }); }, 300), - [], + [autoSaveGenerationRef, syncImageEditedState], ); const createResizeHandler = (setter: any, startSize: number) => (e: any) => { @@ -1899,7 +1925,8 @@ function App() { return newFile; } const existing = prevMap.get(newFile.path); - if (existing && existing.modified === newFile.modified) { + const tagsMatch = JSON.stringify(existing?.tags ?? null) === JSON.stringify(newFile.tags ?? null); + if (existing && existing.modified === newFile.modified && existing.is_edited === newFile.is_edited && tagsMatch) { return existing; } @@ -1969,6 +1996,9 @@ function App() { const handleBackToLibrary = useCallback(() => { const lastActivePath = selectedImage?.path ?? null; + debouncedSave.cancel(); + userInitiatedAdjustmentChangeRef.current = false; + autoSaveGenerationRef.current += 1; setSelectedImage(null); setFinalPreviewUrl(null); setUncroppedAdjustedPreviewUrl(null); @@ -1984,7 +2014,7 @@ function App() { setSlideDirection(1); setLiveAdjustments(INITIAL_ADJUSTMENTS); resetAdjustmentsHistory(INITIAL_ADJUSTMENTS); - }, [selectedImage?.path]); + }, [selectedImage?.path, debouncedSave]); const handleImageSelect = useCallback( (path: string) => { @@ -1993,6 +2023,8 @@ function App() { } debouncedSave.cancel(); patchesSentToBackend.current.clear(); + userInitiatedAdjustmentChangeRef.current = false; + autoSaveGenerationRef.current += 1; setSelectedImage({ exif: null, @@ -2489,6 +2521,20 @@ function App() { clearTimeout(dragIdleTimer.current); } + const allowAutoSave = userInitiatedAdjustmentChangeRef.current; + const saveGeneration = autoSaveGenerationRef.current; + if (allowAutoSave) { + const nextIsEdited = isMeaningfullyEdited(adjustments); + const affectedPaths = new Set([ + selectedImage.path, + ...multiSelectedPaths.filter((path) => path !== selectedImage.path), + ]); + + setImageList((prevList) => + prevList.map((image) => (affectedPaths.has(image.path) ? { ...image, is_edited: nextIsEdited } : image)), + ); + } + const targetRes = calculateTargetRes(); if (isSliderDragging) { @@ -2504,22 +2550,24 @@ function App() { dragIdleTimer.current = setTimeout(() => { currentResRef.current = targetRes; applyAdjustments(adjustments, false, targetRes); - debouncedSave(selectedImage.path, adjustments); - - const otherPaths = multiSelectedPaths.filter((p) => p !== selectedImage.path); - if (otherPaths.length > 0) { - const prev = prevAdjustmentsRef.current; - if (prev && prev.path === selectedImage.path) { - const delta: Partial = {}; - for (const key of Object.keys(adjustments) as Array) { - if (JSON.stringify(adjustments[key]) !== JSON.stringify(prev.adjustments[key])) { - (delta as any)[key] = adjustments[key]; + if (allowAutoSave) { + debouncedSave(selectedImage.path, adjustments, saveGeneration); + + const otherPaths = multiSelectedPaths.filter((p) => p !== selectedImage.path); + if (otherPaths.length > 0) { + const prev = prevAdjustmentsRef.current; + if (prev && prev.path === selectedImage.path) { + const delta: Partial = {}; + for (const key of Object.keys(adjustments) as Array) { + if (JSON.stringify(adjustments[key]) !== JSON.stringify(prev.adjustments[key])) { + (delta as any)[key] = adjustments[key]; + } + } + if (Object.keys(delta).length > 0) { + invoke(Invokes.ApplyAdjustmentsToPaths, { paths: otherPaths, adjustments: delta }).catch((err) => { + console.error('Failed to apply adjustments to multi-selection:', err); + }); } - } - if (Object.keys(delta).length > 0) { - invoke(Invokes.ApplyAdjustmentsToPaths, { paths: otherPaths, adjustments: delta }).catch((err) => { - console.error('Failed to apply adjustments to multi-selection:', err); - }); } } } @@ -2540,6 +2588,7 @@ function App() { applyAdjustments, debouncedSave, appSettings?.enableLivePreviews, + setImageList, ]); const handleZoomChange = useCallback( @@ -3408,8 +3457,10 @@ function App() { initialAdjusts = { ...INITIAL_ADJUSTMENTS }; } + userInitiatedAdjustmentChangeRef.current = false; setLiveAdjustments(initialAdjusts); resetAdjustmentsHistory(initialAdjusts); + void syncImageEditedState(selectedImage.path); } catch (err) { console.error('Failed to load metadata early:', err); } @@ -3571,9 +3622,20 @@ function App() { } debouncedSetHistory.cancel(); + debouncedSave.cancel(); + userInitiatedAdjustmentChangeRef.current = false; + autoSaveGenerationRef.current += 1; invoke(Invokes.ResetAdjustmentsForPaths, { paths: pathsToReset }) .then(() => { + const resetPathSet = new Set(pathsToReset); + setImageList((prevList: Array) => + prevList.map((image: ImageFile) => (resetPathSet.has(image.path) ? { ...image, is_edited: false } : image)), + ); + pathsToReset.forEach((path: string) => { + void syncImageEditedState(path); + }); + if (libraryActivePath && pathsToReset.includes(libraryActivePath)) { setLibraryActiveAdjustments((prev: Adjustments) => ({ ...INITIAL_ADJUSTMENTS, rating: prev.rating })); } @@ -3603,6 +3665,9 @@ function App() { adjustments.rating, resetAdjustmentsHistory, debouncedSetHistory, + debouncedSave, + setImageList, + syncImageEditedState, ], ); @@ -3934,6 +3999,7 @@ function App() { }); if (metadata.adjustments && !metadata.adjustments.is_null) { const normalized = normalizeLoadedAdjustments(metadata.adjustments); + userInitiatedAdjustmentChangeRef.current = true; setLiveAdjustments(normalized); resetAdjustmentsHistory(normalized); } diff --git a/src/components/panel/Editor.tsx b/src/components/panel/Editor.tsx index 29e481880..2c6cc94a8 100644 --- a/src/components/panel/Editor.tsx +++ b/src/components/panel/Editor.tsx @@ -522,16 +522,13 @@ export default function Editor({ if (currentAdjCrop === null || geometryChanged) { prevCropParams.current = { rotation, aspectRatio, orientationSteps }; - const isDifferent = - !currentAdjCrop || - currentAdjCrop.x !== maxPixelCrop.x || - currentAdjCrop.y !== maxPixelCrop.y || - currentAdjCrop.width !== maxPixelCrop.width || - currentAdjCrop.height !== maxPixelCrop.height; - - if (isDifferent) { - setAdjustments((prev: Partial) => ({ ...prev, crop: maxPixelCrop })); - } + setCrop({ + unit: '%', + x: (maxPixelCrop.x / W) * 100, + y: (maxPixelCrop.y / H) * 100, + width: (maxPixelCrop.width / W) * 100, + height: (maxPixelCrop.height / H) * 100, + }); } } } diff --git a/src/components/panel/Filmstrip.tsx b/src/components/panel/Filmstrip.tsx index a36a7930f..00249daf9 100644 --- a/src/components/panel/Filmstrip.tsx +++ b/src/components/panel/Filmstrip.tsx @@ -213,15 +213,17 @@ const FilmstripThumbnail = memo( {isEdited && (
- +
)} {(colorLabel || rating > 0) && ( -
+
{colorLabel && (
@@ -1037,7 +1037,9 @@ function Thumbnail({ )} {(colorLabel || rating > 0) && ( -
+
{colorLabel && (
{ + if (Array.isArray(value)) { + return value.map(stripEditStateNoise); + } + + if (value && typeof value === 'object') { + return Object.entries(value).reduce((acc: Record, [key, nestedValue]) => { + if (!EDIT_STATE_IGNORED_KEYS.has(key)) { + acc[key] = stripEditStateNoise(nestedValue); + } + return acc; + }, {}); + } + + return value; +}; + export const normalizeLoadedAdjustments = (loadedAdjustments: Adjustments): any => { if (!loadedAdjustments) { return INITIAL_ADJUSTMENTS; @@ -598,6 +617,13 @@ export const normalizeLoadedAdjustments = (loadedAdjustments: Adjustments): any }; }; +export const isMeaningfullyEdited = (loadedAdjustments: Adjustments): boolean => { + const normalizedLoaded = normalizeLoadedAdjustments(loadedAdjustments); + const normalizedDefault = normalizeLoadedAdjustments(INITIAL_ADJUSTMENTS); + + return JSON.stringify(stripEditStateNoise(normalizedLoaded)) !== JSON.stringify(stripEditStateNoise(normalizedDefault)); +}; + export const COPYABLE_ADJUSTMENT_KEYS: Array = [ BasicAdjustment.Blacks, BasicAdjustment.Brightness,