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:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user