diff --git a/src-tauri/src/android_integration.rs b/src-tauri/src/android_integration.rs index 9a027660d9..f67da917e7 100644 --- a/src-tauri/src/android_integration.rs +++ b/src-tauri/src/android_integration.rs @@ -39,17 +39,12 @@ pub fn initialize_android(window: &tauri::WebviewWindow) { .with_env(|env_22| { let verifier_context = unsafe { VerifierJObject::from_raw(env_22, raw_context) }; - rustls_platform_verifier::android::init_with_env( - env_22, - verifier_context, - ) + rustls_platform_verifier::android::init_with_env(env_22, verifier_context) }) .into_outcome() { jni22::Outcome::Ok(()) => { - log::info!( - "Successfully initialized rustls-platform-verifier on Android." - ); + log::info!("Successfully initialized rustls-platform-verifier on Android."); } jni22::Outcome::Err(error) => { log::error!( diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 43d8fac765..082672dce9 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -2260,7 +2260,16 @@ pub async fn reset_adjustments_for_paths( let mut existing_metadata = crate::exif_processing::load_sidecar(&sidecar_path); + // Saved snapshots survive a reset of the working state. + let preserved_snapshots = existing_metadata + .adjustments + .get("snapshots") + .filter(|v| v.as_array().is_some_and(|a| !a.is_empty())) + .cloned(); existing_metadata.adjustments = serde_json::json!({}); + if let Some(snapshots) = preserved_snapshots { + existing_metadata.adjustments["snapshots"] = snapshots; + } if let Ok(json_string) = serde_json::to_string_pretty(&existing_metadata) { let _ = std::fs::write(&sidecar_path, json_string); diff --git a/src-tauri/src/lut_processing.rs b/src-tauri/src/lut_processing.rs index a6bcea6d85..54f4128a8c 100644 --- a/src-tauri/src/lut_processing.rs +++ b/src-tauri/src/lut_processing.rs @@ -330,8 +330,7 @@ pub fn parse_lut_file(path_str: &str) -> anyhow::Result { .and_then(|s| s.to_str()) .unwrap_or("cube") .to_lowercase(); - let uri_bytes = - read_android_content_uri(path_str).map_err(|e| anyhow!("{}", e))?; + let uri_bytes = read_android_content_uri(path_str).map_err(|e| anyhow!("{}", e))?; (ext, Some(uri_bytes)) } #[cfg(not(target_os = "android"))] diff --git a/src/components/panel/right/PresetItemDisplay.tsx b/src/components/panel/right/PresetItemDisplay.tsx new file mode 100644 index 0000000000..2984232244 --- /dev/null +++ b/src/components/panel/right/PresetItemDisplay.tsx @@ -0,0 +1,138 @@ +import { useMemo, ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Crop, Layers, Loader2, Palette, Wrench } from 'lucide-react'; +import Text from '../../ui/Text'; +import Slider from '../../ui/Slider'; +import { TextColors, TextVariants, TextWeights } from '../../../types/typography'; +import { ADJUSTMENT_GROUPS } from '../../../utils/adjustments'; +import { Preset } from '../../ui/AppProperties'; + +export interface PresetItemDisplayProps { + isGeneratingPreviews: boolean; + preset: Preset; + previewUrl: string; + isActive?: boolean; + intensity?: number; + onIntensityChange?: (val: number) => void; + onDragStateChange?: (isDragging: boolean) => void; + // Snapshot mode: replaces the type row and hides preset-only feature badges. + subtitle?: ReactNode; + nameSlot?: ReactNode; +} + +export default function PresetItemDisplay({ + preset, + previewUrl, + isGeneratingPreviews, + isActive, + intensity, + onIntensityChange, + onDragStateChange, + subtitle, + nameSlot, +}: PresetItemDisplayProps) { + const { t } = useTranslation(); + const geometryKeys = ADJUSTMENT_GROUPS.geometry.flatMap((g) => g.keys); + + const supportsMasks = preset.includeMasks ?? (preset.adjustments?.masks && preset.adjustments.masks.length > 0); + const supportsGeometry = + preset.includeCropTransform ?? geometryKeys.some((key) => preset.adjustments?.[key] !== undefined); + const isTool = preset.presetType === 'tool'; + const showBadges = !subtitle && (supportsMasks || supportsGeometry); + const tooltipContent = useMemo(() => { + const features = []; + if (supportsMasks) features.push(t('editor.presets.supports.masks')); + if (supportsGeometry) features.push(t('editor.presets.supports.cropTransform')); + + if (features.length === 0) return undefined; + return t('editor.presets.supports.label', { features: features.join(' + ') }); + }, [supportsMasks, supportsGeometry, t]); + + return ( +
+
+
+ {isGeneratingPreviews && !previewUrl ? ( + + ) : previewUrl ? ( + {`${preset.name} + ) : ( + + )} + + {showBadges && ( + <> +
+ +
+ {supportsMasks && } + {supportsGeometry && } +
+ + )} +
+ +
+ {nameSlot ?? ( + + {preset.name} + + )} +
+ {subtitle ?? ( + <> + {isTool ? ( + + ) : ( + + )} + + {isTool ? t('editor.presets.types.tool') : t('editor.presets.types.style')} + + + )} +
+
+
+ + + {isActive && onIntensityChange && ( + e.stopPropagation()} + onPointerDown={(e: any) => e.stopPropagation()} + > +
+ onIntensityChange(Number(e.target.value))} + onDragStateChange={onDragStateChange} + label={t('editor.presets.amount')} + step={1} + /> +
+
+ )} +
+
+ ); +} diff --git a/src/components/panel/right/PresetsPanel.tsx b/src/components/panel/right/PresetsPanel.tsx index 1c4613866e..80b1ca2a91 100644 --- a/src/components/panel/right/PresetsPanel.tsx +++ b/src/components/panel/right/PresetsPanel.tsx @@ -26,11 +26,7 @@ import { SortAsc, Trash2, Users, - Layers, - Crop, Save, - Wrench, - Palette, Settings2, } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; @@ -39,9 +35,10 @@ import CreateFolderModal from '../../modals/CreateFolderModal'; import RenameFolderModal from '../../modals/RenameFolderModal'; import Button from '../../ui/Button'; import Text from '../../ui/Text'; -import Slider from '../../ui/Slider'; +import SnapshotsSection, { snapshotPreviewId } from './SnapshotsSection'; +import PresetItemDisplay from './PresetItemDisplay'; import { TextColors, TextVariants, TextWeights } from '../../../types/typography'; -import { Adjustments, INITIAL_ADJUSTMENTS, ADJUSTMENT_GROUPS } from '../../../utils/adjustments'; +import { Adjustments, AdjustmentSnapshot, INITIAL_ADJUSTMENTS } from '../../../utils/adjustments'; import { Invokes, OPTION_SEPARATOR, Panel, Preset, SelectedImage } from '../../ui/AppProperties'; import { useEditorStore } from '../../../store/useEditorStore'; import { useUIStore } from '../../../store/useUIStore'; @@ -81,16 +78,6 @@ interface ModalState { preset: Preset | null; } -interface PresetItemDisplayProps { - isGeneratingPreviews: boolean; - preset: Preset; - previewUrl: string; - isActive?: boolean; - intensity?: number; - onIntensityChange?: (val: number) => void; - onDragStateChange?: (isDragging: boolean) => void; -} - interface PresetsPanelProps { onNavigateToCommunity(): void; } @@ -198,113 +185,6 @@ const mixAdjustments = (presetObj: any, intensity: number, initialObj: any = INI return result; }; -function PresetItemDisplay({ - preset, - previewUrl, - isGeneratingPreviews, - isActive, - intensity, - onIntensityChange, - onDragStateChange, -}: PresetItemDisplayProps) { - const { t } = useTranslation(); - const geometryKeys = ADJUSTMENT_GROUPS.geometry.flatMap((g) => g.keys); - - const supportsMasks = preset.includeMasks ?? (preset.adjustments?.masks && preset.adjustments.masks.length > 0); - const supportsGeometry = - preset.includeCropTransform ?? geometryKeys.some((key) => preset.adjustments?.[key] !== undefined); - const isTool = preset.presetType === 'tool'; - const tooltipContent = useMemo(() => { - const features = []; - if (supportsMasks) features.push(t('editor.presets.supports.masks')); - if (supportsGeometry) features.push(t('editor.presets.supports.cropTransform')); - - if (features.length === 0) return undefined; - return t('editor.presets.supports.label', { features: features.join(' + ') }); - }, [supportsMasks, supportsGeometry, t]); - - return ( -
-
-
- {isGeneratingPreviews && !previewUrl ? ( - - ) : previewUrl ? ( - {`${preset.name} - ) : ( - - )} - - {(supportsMasks || supportsGeometry) && ( - <> -
- -
- {supportsMasks && } - {supportsGeometry && } -
- - )} -
- -
- - {preset.name} - -
- {isTool ? ( - - ) : ( - - )} - - {isTool ? t('editor.presets.types.tool') : t('editor.presets.types.style')} - -
-
-
- - - {isActive && onIntensityChange && ( - e.stopPropagation()} - onPointerDown={(e: any) => e.stopPropagation()} - > -
- onIntensityChange(Number(e.target.value))} - onDragStateChange={onDragStateChange} - label={t('editor.presets.amount')} - step={1} - /> -
-
- )} -
-
- ); -} - function FolderItemDisplay({ folder }: FolderProps) { return (
@@ -480,6 +360,7 @@ export default function PresetsPanel({ onNavigateToCommunity }: PresetsPanelProp const activePanel = useUIStore((s) => s.activeRightPanel); const setEditor = useEditorStore((s) => s.setEditor); const { setAdjustments } = useEditorActions(); + const snapshots = useMemo(() => adjustments.snapshots || [], [adjustments.snapshots]); const { addFolder, @@ -537,6 +418,8 @@ export default function PresetsPanel({ onNavigateToCommunity }: PresetsPanelProp item.folder.children.forEach((p: Preset) => allPresetIds.add(p.id)); } }); + // Keep live snapshot previews; a stale (overwritten) one falls out and gets revoked. + snapshots.forEach((s: AdjustmentSnapshot) => allPresetIds.add(snapshotPreviewId(s))); const currentPreviews = previewsRef.current; const previewsToDelete = Object.keys(currentPreviews).filter((id) => !allPresetIds.has(id)); @@ -554,7 +437,7 @@ export default function PresetsPanel({ onNavigateToCommunity }: PresetsPanelProp return newPreviews; }); } - }, [presets]); + }, [presets, snapshots]); useEffect(() => { return () => { @@ -771,6 +654,16 @@ export default function PresetsPanel({ onNavigateToCommunity }: PresetsPanelProp } }, [selectedImage?.isReady, presets, enqueuePreviews]); + // Feed snapshots through the preset preview pipeline as { id, adjustments } items. + const generateSnapshotPreviews = useCallback(() => { + if (!selectedImage?.isReady) { + return; + } + enqueuePreviews( + snapshots.map((s: AdjustmentSnapshot) => ({ id: snapshotPreviewId(s), name: s.name, adjustments: s.state })) as any, + ); + }, [selectedImage?.isReady, snapshots, enqueuePreviews]); + useEffect(() => { const isPathChanged = selectedImage?.path !== currentImagePathRef.current; @@ -795,11 +688,14 @@ export default function PresetsPanel({ onNavigateToCommunity }: PresetsPanelProp } } - if (activePanel === Panel.Presets && selectedImage?.isReady && presets.length > 0) { - generateRootPreviews(); - expandedFolders.forEach((folderId: string) => { - generateFolderPreviews(folderId); - }); + if (activePanel === Panel.Presets && selectedImage?.isReady) { + if (presets.length > 0) { + generateRootPreviews(); + expandedFolders.forEach((folderId: string) => { + generateFolderPreviews(folderId); + }); + } + generateSnapshotPreviews(); } }, [ activePanel, @@ -808,6 +704,7 @@ export default function PresetsPanel({ onNavigateToCommunity }: PresetsPanelProp presets.length, generateRootPreviews, generateFolderPreviews, + generateSnapshotPreviews, expandedFolders, ]); @@ -1179,6 +1076,10 @@ export default function PresetsPanel({ onNavigateToCommunity }: PresetsPanelProp onContextMenu={handleBackgroundContextMenu} ref={setRootNodeRef} > + + + {t('editor.presets.title')} + {isLoading && presets.length === 0 && ( `snapshot-${s.id}-${s.createdAt}`; + +interface SnapshotsSectionProps { + previews: Record; + isGeneratingPreviews: boolean; +} + +export default function SnapshotsSection({ previews, isGeneratingPreviews }: SnapshotsSectionProps) { + const adjustments = useEditorStore((s) => s.adjustments); + const selectedImage = useEditorStore((s) => s.selectedImage); + const { setAdjustments } = useEditorActions(); + const { showContextMenu } = useContextMenu(); + const { t } = useTranslation(); + const [renamingId, setRenamingId] = useState(null); + const [tempName, setTempName] = useState(''); + + if (!selectedImage) { + return null; + } + const snapshots: AdjustmentSnapshot[] = adjustments.snapshots || []; + + const snapshotState = (adj: Adjustments): Partial => { + const { snapshots: _snapshots, ...state } = adj; + return structuredClone(state); + }; + + const updateSnapshots = (fn: (list: AdjustmentSnapshot[], adj: Adjustments) => AdjustmentSnapshot[]) => + setAdjustments((prev: Adjustments) => ({ ...prev, snapshots: fn(prev.snapshots || [], prev) })); + + const handleSave = () => + updateSnapshots((list, adj) => [ + ...list, + { + id: uuidv4(), + name: t('editor.presets.snapshots.defaultName', { count: list.length + 1 }), + createdAt: Date.now(), + state: snapshotState(adj), + }, + ]); + + const handleApply = (version: AdjustmentSnapshot) => + setAdjustments((prev: Adjustments) => ({ ...INITIAL_ADJUSTMENTS, ...version.state, snapshots: prev.snapshots })); + + const handleOverwrite = (version: AdjustmentSnapshot) => + updateSnapshots((list, adj) => + list.map((v) => (v.id === version.id ? { ...v, state: snapshotState(adj), createdAt: Date.now() } : v)), + ); + + const handleDelete = (version: AdjustmentSnapshot) => + updateSnapshots((list) => list.filter((v) => v.id !== version.id)); + + const startRename = (version: AdjustmentSnapshot) => { + setRenamingId(version.id); + setTempName(version.name); + }; + + const commitRename = () => { + const name = tempName.trim(); + if (name && renamingId) { + updateSnapshots((list) => list.map((v) => (v.id === renamingId ? { ...v, name } : v))); + } + setRenamingId(null); + }; + + const handleRowContextMenu = (event: React.MouseEvent, version: AdjustmentSnapshot) => { + event.preventDefault(); + event.stopPropagation(); + showContextMenu(event.clientX, event.clientY, [ + { label: t('editor.presets.snapshots.apply'), icon: Check, onClick: () => handleApply(version) }, + { label: t('editor.presets.snapshots.overwrite'), icon: RefreshCw, onClick: () => handleOverwrite(version) }, + { + label: t('editor.presets.snapshots.rename'), + icon: FileEdit, + onClick: () => startRename(version), + }, + { type: OPTION_SEPARATOR }, + { label: t('editor.presets.snapshots.delete'), icon: Trash2, onClick: () => handleDelete(version) }, + ]); + }; + + const dateLabel = (createdAt: number) => + new Date(createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + + return ( +
+
+ + + {t('editor.presets.snapshots.title')} + + +
+ {snapshots.length === 0 ? ( + + {t('editor.presets.snapshots.empty')} + + ) : ( +
+ {snapshots.map((version) => { + const isRenaming = renamingId === version.id; + return ( +
!isRenaming && handleApply(version)} + onContextMenu={(e) => handleRowContextMenu(e, version)} + > + + + + {dateLabel(version.createdAt)} + + + } + nameSlot={ + isRenaming ? ( + setTempName(e.target.value)} + onBlur={commitRename} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename(); + if (e.key === 'Escape') setRenamingId(null); + }} + /> + ) : undefined + } + /> + {!isRenaming && ( + + )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/hooks/useImageProcessing.ts b/src/hooks/useImageProcessing.ts index adcb67df0b..0d87793efd 100644 --- a/src/hooks/useImageProcessing.ts +++ b/src/hooks/useImageProcessing.ts @@ -123,7 +123,13 @@ export function useImageProcessing( const currentPath = selectedImage?.path; if (!currentPath) return; - const payload = structuredClone(currentAdjustments); + // Snapshots are in-editor checkpoints persisted through the sidecar save + // path; the renderer never reads them. Drop them before cloning so a + // growing snapshots list — each a full edit state, potentially carrying + // base64 mask/patch data — isn't structuredClone'd and shipped over IPC on + // every interactive tick. + const { snapshots: _snapshots, ...renderAdjustments } = currentAdjustments; + const payload = structuredClone(renderAdjustments); const { patchesSentToBackend } = useEditorStore.getState(); const newlySentPatches = new Set(); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e9665b760e..ed9cd47e52 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -642,6 +642,16 @@ }, "presets": { "amount": "Amount", + "snapshots": { + "title": "Snapshots", + "saveTooltip": "Save a snapshot of the current edit", + "empty": "Save checkpoints of your edit and switch between looks without leaving the editor.", + "defaultName": "Snapshot {{count}}", + "apply": "Apply Snapshot", + "overwrite": "Overwrite with Current Edit", + "rename": "Rename", + "delete": "Delete Snapshot" + }, "dialog": { "allPresetFiles": "All Preset Files", "exportAllTitle": "Export All Presets", diff --git a/src/utils/adjustments.ts b/src/utils/adjustments.ts index 174a755a4c..082fdd4d19 100644 --- a/src/utils/adjustments.ts +++ b/src/utils/adjustments.ts @@ -146,9 +146,20 @@ export interface ParametricCurve { red: ParametricCurveSettings; } +// An in-editor checkpoint of the edit state, stored inside the sidecar. `state` is +// a full Adjustments snapshot minus the snapshots list itself. Complements +// library-level Virtual Copies (Duplicate Image > Virtual Copy). +export interface AdjustmentSnapshot { + id: string; + name: string; + createdAt: number; + state: Partial; +} + export interface Adjustments { [index: string]: any; aiPatches: Array; + snapshots: Array; aspectRatio: number | null; blacks: number; brightness: number; @@ -477,6 +488,7 @@ export const INITIAL_MASK_CONTAINER: MaskContainer = { export const INITIAL_ADJUSTMENTS: Adjustments = { aiPatches: [], + snapshots: [], aspectRatio: null, blacks: 0, brightness: 0,