fix: preset saving and global pipeline selection

- Use pipelineStore.globalConfig instead of DEFAULT_PIPELINE_CONFIG for
  image processing, so global preset changes affect images
- Fix preset saving from modal to save actual edited config, not global
- Add local preset selection tracking in PipelinePanel for modal context
- Prevent modal config reset when store changes using untrack()
- Derive editingImage from store to always get latest data
- Fix ImagePreview to compare against lastProcessedConfig, not initial
- Preserve custom image overrides when global preset changes
- Clean up unused store functions (updateConfig, updateStep, isModified,
  getEffectiveConfig)
- Use clonePipelineConfig consistently instead of JSON.parse/stringify

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 08:08:44 -04:00
parent e37dd6f25d
commit d9d10cd6fb
6 changed files with 89 additions and 73 deletions

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte';
import { X, RotateCcw } from 'lucide-svelte'; import { X, RotateCcw } from 'lucide-svelte';
import type { ImageEntry, Device, PipelineConfig } from '$lib/types'; import type { ImageEntry, Device, PipelineConfig } from '$lib/types';
import { clonePipelineConfig, DEFAULT_PIPELINE_CONFIG } from '$lib/types'; import { clonePipelineConfig, DEFAULT_PIPELINE_CONFIG } from '$lib/types';
@@ -11,7 +12,7 @@
image: ImageEntry | null; image: ImageEntry | null;
device: Device; device: Device;
onClose: () => void; onClose: () => void;
onApply: (imageId: string, config: PipelineConfig, processedDataUrl: string | null) => void; onApply: (imageId: string, config: PipelineConfig, processedDataUrl: string | null, processedBlob: Blob | null) => void;
} }
let { open, image, device, onClose, onApply }: Props = $props(); let { open, image, device, onClose, onApply }: Props = $props();
@@ -21,22 +22,33 @@
// Preview state // Preview state
let previewDataUrl = $state<string | null>(null); let previewDataUrl = $state<string | null>(null);
let previewBlob = $state<Blob | null>(null);
let isProcessing = $state(false); let isProcessing = $state(false);
// Initialize local config when modal opens or image changes // Track which image we've initialized for to avoid re-initializing on store changes
let initializedForImageId = $state<string | null>(null);
// Initialize local config when modal opens with a new image
$effect(() => { $effect(() => {
if (open && image) { if (open && image && image.id !== initializedForImageId) {
initializedForImageId = image.id;
// Use image's override if it has one, otherwise use global config // Use image's override if it has one, otherwise use global config
const sourceConfig = image.pipelineOverride ?? pipelineStore.globalConfig; // Use untrack to prevent re-running this effect when globalConfig changes
const globalConfig = untrack(() => pipelineStore.globalConfig);
const sourceConfig = image.pipelineOverride ?? globalConfig;
localConfig = clonePipelineConfig(sourceConfig); localConfig = clonePipelineConfig(sourceConfig);
// Set initial preview to current processed image // Set initial preview to current processed image
previewDataUrl = image.processedDataUrl; previewDataUrl = image.processedDataUrl;
previewBlob = image.processedBlob;
} else if (!open) {
// Reset when modal closes so next open will reinitialize
initializedForImageId = null;
} }
}); });
function handleApply(): void { function handleApply(): void {
if (!image) return; if (!image) return;
onApply(image.id, localConfig, previewDataUrl); onApply(image.id, localConfig, previewDataUrl, previewBlob);
onClose(); onClose();
} }
@@ -48,8 +60,9 @@
localConfig = newConfig; localConfig = newConfig;
} }
function handlePreviewUpdate(dataUrl: string): void { function handlePreviewUpdate(dataUrl: string, blob: Blob): void {
previewDataUrl = dataUrl; previewDataUrl = dataUrl;
previewBlob = blob;
} }
function handleProcessingChange(processing: boolean): void { function handleProcessingChange(processing: boolean): void {

View File

@@ -8,7 +8,7 @@
image: ImageEntry; image: ImageEntry;
device: Device; device: Device;
config: PipelineConfig; config: PipelineConfig;
onPreviewUpdate: (dataUrl: string) => void; onPreviewUpdate: (dataUrl: string, blob: Blob) => void;
onProcessingChange: (processing: boolean) => void; onProcessingChange: (processing: boolean) => void;
onConfigChange?: (config: PipelineConfig) => void; onConfigChange?: (config: PipelineConfig) => void;
} }
@@ -16,9 +16,11 @@
let { image, device, config, onPreviewUpdate, onProcessingChange, onConfigChange }: Props = $props(); let { image, device, config, onPreviewUpdate, onProcessingChange, onConfigChange }: Props = $props();
let previewDataUrl = $state<string | null>(null); let previewDataUrl = $state<string | null>(null);
let previewBlob = $state<Blob | null>(null);
let isProcessing = $state(false); let isProcessing = $state(false);
let debounceTimer: ReturnType<typeof setTimeout> | null = null; let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let initialEffectiveConfig = $state<string | null>(null); let lastProcessedConfig = $state<string | null>(null);
let isFirstRender = $state(true);
const isManualCrop = $derived(config.crop.enabled && config.crop.mode === 'manual'); const isManualCrop = $derived(config.crop.enabled && config.crop.mode === 'manual');
const targetAspect = $derived(device.width / device.height); const targetAspect = $derived(device.width / device.height);
@@ -81,19 +83,20 @@
return JSON.stringify(effective); return JSON.stringify(effective);
} }
// Capture initial effective config on mount
$effect(() => {
if (initialEffectiveConfig === null) {
initialEffectiveConfig = getEffectiveConfig(config);
}
});
// Track config changes and trigger debounced processing // Track config changes and trigger debounced processing
$effect(() => { $effect(() => {
const effectiveConfig = getEffectiveConfig(config); const effectiveConfig = getEffectiveConfig(config);
// Skip if effective config hasn't changed (prevents unnecessary reprocessing) // On first render, just record the config without processing
if (effectiveConfig === initialEffectiveConfig) { // (the image already has a processed preview from before)
if (isFirstRender) {
isFirstRender = false;
lastProcessedConfig = effectiveConfig;
return;
}
// Skip if effective config hasn't changed from last processed
if (effectiveConfig === lastProcessedConfig) {
return; return;
} }
@@ -109,6 +112,7 @@
// Debounce processing // Debounce processing
debounceTimer = setTimeout(() => { debounceTimer = setTimeout(() => {
lastProcessedConfig = effectiveConfig;
processPreview(); processPreview();
}, 150); }, 150);
@@ -126,9 +130,10 @@
onProcessingChange(true); onProcessingChange(true);
try { try {
const { dataUrl } = await processImageWithPipeline(image.file, device, config); const { dataUrl, blob } = await processImageWithPipeline(image.file, device, config);
previewDataUrl = dataUrl; previewDataUrl = dataUrl;
onPreviewUpdate(dataUrl); previewBlob = blob;
onPreviewUpdate(dataUrl, blob);
} catch (err) { } catch (err) {
console.error('Preview processing failed:', err); console.error('Preview processing failed:', err);
} finally { } finally {
@@ -141,6 +146,7 @@
$effect(() => { $effect(() => {
if (image.processedDataUrl && !previewDataUrl) { if (image.processedDataUrl && !previewDataUrl) {
previewDataUrl = image.processedDataUrl; previewDataUrl = image.processedDataUrl;
previewBlob = image.processedBlob;
} }
}); });

View File

@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte';
import { Save } from 'lucide-svelte'; import { Save } from 'lucide-svelte';
import type { PipelineConfig, Device, CropMode, ResizeMode, GreyscaleMethod, DitherAlgorithm } from '$lib/types'; import type { PipelineConfig, Device, CropMode, ResizeMode, GreyscaleMethod, DitherAlgorithm } from '$lib/types';
import { clonePipelineConfig } from '$lib/types';
import { pipelineStore } from '$lib/stores/pipeline.svelte'; import { pipelineStore } from '$lib/stores/pipeline.svelte';
import PipelineStep from './PipelineStep.svelte'; import PipelineStep from './PipelineStep.svelte';
@@ -27,19 +29,34 @@
}); });
} }
// Track which preset is selected locally (for modal use)
let localSelectedPresetId = $state<string | null>(null);
// Use local selection if set, otherwise fall back to global
const displayPresetId = $derived(localSelectedPresetId ?? pipelineStore.selectedPresetId);
function handlePresetChange(e: Event): void { function handlePresetChange(e: Event): void {
const select = e.target as HTMLSelectElement; const select = e.target as HTMLSelectElement;
const preset = pipelineStore.allPresets.find((p) => p.id === select.value); const presetId = select.value;
// Prevent re-applying the same preset
if (presetId === localSelectedPresetId) return;
const preset = pipelineStore.allPresets.find((p) => p.id === presetId);
if (preset) { if (preset) {
onConfigChange(JSON.parse(JSON.stringify(preset.config))); localSelectedPresetId = presetId;
onConfigChange(clonePipelineConfig(preset.config));
} }
} }
function handleSavePreset(): void { async function handleSavePreset(): Promise<void> {
if (!newPresetName.trim()) return; if (!newPresetName.trim()) return;
pipelineStore.saveAsPreset(newPresetName.trim()); const newPreset = pipelineStore.saveAsPreset(newPresetName.trim(), config);
showSaveDialog = false; showSaveDialog = false;
newPresetName = ''; newPresetName = '';
// Wait for the DOM to update with new preset options before selecting
await tick();
localSelectedPresetId = newPreset.id;
} }
// Options for selects // Options for selects
@@ -86,7 +103,7 @@
border border-grey-200 rounded-md border border-grey-200 rounded-md
focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent
" "
value={pipelineStore.selectedPresetId ?? ''} value={displayPresetId ?? ''}
onchange={handlePresetChange} onchange={handlePresetChange}
> >
{#each pipelineStore.allPresets as preset} {#each pipelineStore.allPresets as preset}

View File

@@ -1,6 +1,7 @@
import type { ImageEntry, Dimensions, Device, PipelineConfig } from '$lib/types'; import type { ImageEntry, Dimensions, Device, PipelineConfig } from '$lib/types';
import { CONSTRAINTS, isSupportedFormat, DEFAULT_PIPELINE_CONFIG } from '$lib/types'; import { CONSTRAINTS, isSupportedFormat, clonePipelineConfig } from '$lib/types';
import { processImageWithPipeline } from '$lib/processing/pipeline'; import { processImageWithPipeline } from '$lib/processing/pipeline';
import { pipelineStore } from './pipeline.svelte';
function createImagesStore() { function createImagesStore() {
let images = $state<ImageEntry[]>([]); let images = $state<ImageEntry[]>([]);
@@ -101,7 +102,7 @@ function createImagesStore() {
updateImage(id, { originalDataUrl, originalDimensions }); updateImage(id, { originalDataUrl, originalDimensions });
const img = getImage(id); const img = getImage(id);
const pipelineConfig = img?.pipelineOverride ?? DEFAULT_PIPELINE_CONFIG; const pipelineConfig = img?.pipelineOverride ?? pipelineStore.globalConfig;
const { blob, dataUrl } = await processImageWithPipeline(file, device, pipelineConfig); const { blob, dataUrl } = await processImageWithPipeline(file, device, pipelineConfig);
@@ -166,7 +167,7 @@ function createImagesStore() {
} }
function setPipelineOverride(id: string, config: PipelineConfig | null): void { function setPipelineOverride(id: string, config: PipelineConfig | null): void {
updateImage(id, { pipelineOverride: config }); updateImage(id, { pipelineOverride: config ? clonePipelineConfig(config) : null });
} }
async function reprocessImage(id: string, device: Device): Promise<void> { async function reprocessImage(id: string, device: Device): Promise<void> {

View File

@@ -19,11 +19,6 @@ function createPipelineStore() {
allPresets.find((p) => p.id === selectedPresetId) ?? null allPresets.find((p) => p.id === selectedPresetId) ?? null
); );
const isModified = $derived<boolean>(() => {
if (!selectedPreset) return true;
return JSON.stringify(globalConfig) !== JSON.stringify(selectedPreset.config);
});
function loadPresets(): void { function loadPresets(): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
try { try {
@@ -53,31 +48,22 @@ function createPipelineStore() {
} }
} }
function updateConfig(updates: Partial<PipelineConfig>): void { function saveAsPreset(name: string, configToSave?: PipelineConfig): PipelinePreset {
globalConfig = { ...globalConfig, ...updates };
}
function updateStep<K extends keyof PipelineConfig>(
step: K,
updates: Partial<PipelineConfig[K]>
): void {
globalConfig = {
...globalConfig,
[step]: { ...globalConfig[step], ...updates }
};
}
function saveAsPreset(name: string): PipelinePreset {
const id = `user-${Date.now()}`; const id = `user-${Date.now()}`;
// Clone config immediately to capture current state
const configSnapshot = clonePipelineConfig(configToSave ?? globalConfig);
const preset: PipelinePreset = { const preset: PipelinePreset = {
id, id,
name, name,
config: clonePipelineConfig(globalConfig), config: configSnapshot,
isBuiltIn: false isBuiltIn: false
}; };
userPresets = [...userPresets, preset]; userPresets = [...userPresets, preset];
selectedPresetId = id; // Only update global selection if saving from global context (no configToSave)
if (!configToSave) {
selectedPresetId = id;
}
savePresets(); savePresets();
return preset; return preset;
@@ -113,10 +99,6 @@ function createPipelineStore() {
selectPreset('default'); selectPreset('default');
} }
function getEffectiveConfig(imageOverride: PipelineConfig | null): PipelineConfig {
return imageOverride ?? globalConfig;
}
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
loadPresets(); loadPresets();
} }
@@ -137,18 +119,12 @@ function createPipelineStore() {
get userPresets() { get userPresets() {
return userPresets; return userPresets;
}, },
get isModified() {
return isModified;
},
selectPreset, selectPreset,
updateConfig,
updateStep,
saveAsPreset, saveAsPreset,
updatePreset, updatePreset,
deletePreset, deletePreset,
renamePreset, renamePreset,
resetToDefault, resetToDefault,
getEffectiveConfig,
loadPresets loadPresets
}; };
} }

View File

@@ -32,7 +32,12 @@
let showCustomModal = $state(false); let showCustomModal = $state(false);
let showEditModal = $state(false); let showEditModal = $state(false);
let editingImage = $state<ImageEntry | null>(null); let editingImageId = $state<string | null>(null);
// Derive the editing image from the store to always get the latest data
const editingImage = $derived(
editingImageId ? imagesStore.images.find((img) => img.id === editingImageId) ?? null : null
);
// View mode for image comparison // View mode for image comparison
let viewMode = $state<ViewMode>('compare'); let viewMode = $state<ViewMode>('compare');
@@ -50,16 +55,17 @@
} }
}); });
// Watch for pipeline preset changes and reprocess // Watch for pipeline preset changes and reprocess images without custom overrides
$effect(() => { $effect(() => {
const currentPresetId = pipelineStore.selectedPresetId; const currentPresetId = pipelineStore.selectedPresetId;
if (currentPresetId !== previousPresetId && imagesStore.hasImages) { if (currentPresetId !== previousPresetId && imagesStore.hasImages) {
previousPresetId = currentPresetId; previousPresetId = currentPresetId;
// Clear all image overrides and reprocess with new global config // Only reprocess images that don't have custom overrides
for (const img of imagesStore.images) { for (const img of imagesStore.images) {
imagesStore.setPipelineOverride(img.id, null); if (!img.pipelineOverride) {
imagesStore.reprocessImage(img.id, deviceStore.selected);
}
} }
imagesStore.reprocessAll(deviceStore.selected);
} }
}); });
@@ -77,23 +83,20 @@
} }
function handleEditImage(id: string): void { function handleEditImage(id: string): void {
const image = imagesStore.images.find((img) => img.id === id); editingImageId = id;
if (image) { showEditModal = true;
editingImage = image;
showEditModal = true;
}
} }
function closeEditModal(): void { function closeEditModal(): void {
showEditModal = false; showEditModal = false;
editingImage = null; editingImageId = null;
} }
function handleApplyPipeline(imageId: string, config: PipelineConfig, processedDataUrl: string | null): void { function handleApplyPipeline(imageId: string, config: PipelineConfig, processedDataUrl: string | null, processedBlob: Blob | null): void {
imagesStore.setPipelineOverride(imageId, config); imagesStore.setPipelineOverride(imageId, config);
if (processedDataUrl) { if (processedDataUrl && processedBlob) {
// Use the already-processed preview directly // Use the already-processed preview directly
imagesStore.updateImage(imageId, { processedDataUrl }); imagesStore.updateImage(imageId, { processedDataUrl, processedBlob });
} else { } else {
// Fallback: reprocess if no preview available // Fallback: reprocess if no preview available
imagesStore.reprocessImage(imageId, deviceStore.selected); imagesStore.reprocessImage(imageId, deviceStore.selected);