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">
import { untrack } from 'svelte';
import { X, RotateCcw } from 'lucide-svelte';
import type { ImageEntry, Device, PipelineConfig } from '$lib/types';
import { clonePipelineConfig, DEFAULT_PIPELINE_CONFIG } from '$lib/types';
@@ -11,7 +12,7 @@
image: ImageEntry | null;
device: Device;
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();
@@ -21,22 +22,33 @@
// Preview state
let previewDataUrl = $state<string | null>(null);
let previewBlob = $state<Blob | null>(null);
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(() => {
if (open && image) {
if (open && image && image.id !== initializedForImageId) {
initializedForImageId = image.id;
// 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);
// Set initial preview to current processed image
previewDataUrl = image.processedDataUrl;
previewBlob = image.processedBlob;
} else if (!open) {
// Reset when modal closes so next open will reinitialize
initializedForImageId = null;
}
});
function handleApply(): void {
if (!image) return;
onApply(image.id, localConfig, previewDataUrl);
onApply(image.id, localConfig, previewDataUrl, previewBlob);
onClose();
}
@@ -48,8 +60,9 @@
localConfig = newConfig;
}
function handlePreviewUpdate(dataUrl: string): void {
function handlePreviewUpdate(dataUrl: string, blob: Blob): void {
previewDataUrl = dataUrl;
previewBlob = blob;
}
function handleProcessingChange(processing: boolean): void {

View File

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

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import { tick } from 'svelte';
import { Save } from 'lucide-svelte';
import type { PipelineConfig, Device, CropMode, ResizeMode, GreyscaleMethod, DitherAlgorithm } from '$lib/types';
import { clonePipelineConfig } from '$lib/types';
import { pipelineStore } from '$lib/stores/pipeline.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 {
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) {
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;
pipelineStore.saveAsPreset(newPresetName.trim());
const newPreset = pipelineStore.saveAsPreset(newPresetName.trim(), config);
showSaveDialog = false;
newPresetName = '';
// Wait for the DOM to update with new preset options before selecting
await tick();
localSelectedPresetId = newPreset.id;
}
// Options for selects
@@ -86,7 +103,7 @@
border border-grey-200 rounded-md
focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent
"
value={pipelineStore.selectedPresetId ?? ''}
value={displayPresetId ?? ''}
onchange={handlePresetChange}
>
{#each pipelineStore.allPresets as preset}

View File

@@ -1,6 +1,7 @@
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 { pipelineStore } from './pipeline.svelte';
function createImagesStore() {
let images = $state<ImageEntry[]>([]);
@@ -101,7 +102,7 @@ function createImagesStore() {
updateImage(id, { originalDataUrl, originalDimensions });
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);
@@ -166,7 +167,7 @@ function createImagesStore() {
}
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> {

View File

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

View File

@@ -32,7 +32,12 @@
let showCustomModal = $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
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(() => {
const currentPresetId = pipelineStore.selectedPresetId;
if (currentPresetId !== previousPresetId && imagesStore.hasImages) {
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) {
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 {
const image = imagesStore.images.find((img) => img.id === id);
if (image) {
editingImage = image;
showEditModal = true;
}
editingImageId = id;
showEditModal = true;
}
function closeEditModal(): void {
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);
if (processedDataUrl) {
if (processedDataUrl && processedBlob) {
// Use the already-processed preview directly
imagesStore.updateImage(imageId, { processedDataUrl });
imagesStore.updateImage(imageId, { processedDataUrl, processedBlob });
} else {
// Fallback: reprocess if no preview available
imagesStore.reprocessImage(imageId, deviceStore.selected);