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