feat: add pipeline editing components
- PipelinePanel: full pipeline configuration UI - PipelineStep: reusable step toggle with controls - EditImageModal: per-image pipeline editing with preview Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
191
src/lib/components/EditImageModal.svelte
Normal file
191
src/lib/components/EditImageModal.svelte
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { X, RotateCcw } from 'lucide-svelte';
|
||||||
|
import type { ImageEntry, Device, PipelineConfig } from '$lib/types';
|
||||||
|
import { clonePipelineConfig, DEFAULT_PIPELINE_CONFIG } from '$lib/types';
|
||||||
|
import { pipelineStore } from '$lib/stores/pipeline.svelte';
|
||||||
|
import PipelinePanel from './PipelinePanel.svelte';
|
||||||
|
import ImagePreview from './ImagePreview.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
image: ImageEntry | null;
|
||||||
|
device: Device;
|
||||||
|
onClose: () => void;
|
||||||
|
onApply: (imageId: string, config: PipelineConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open, image, device, onClose, onApply }: Props = $props();
|
||||||
|
|
||||||
|
// Local pipeline config for editing (cloned from image override or global)
|
||||||
|
let localConfig = $state<PipelineConfig>(clonePipelineConfig(DEFAULT_PIPELINE_CONFIG));
|
||||||
|
|
||||||
|
// Preview state
|
||||||
|
let previewDataUrl = $state<string | null>(null);
|
||||||
|
let isProcessing = $state(false);
|
||||||
|
|
||||||
|
// Initialize local config when modal opens or image changes
|
||||||
|
$effect(() => {
|
||||||
|
if (open && image) {
|
||||||
|
// Use image's override if it has one, otherwise use global config
|
||||||
|
const sourceConfig = image.pipelineOverride ?? pipelineStore.globalConfig;
|
||||||
|
localConfig = clonePipelineConfig(sourceConfig);
|
||||||
|
// Set initial preview to current processed image
|
||||||
|
previewDataUrl = image.processedDataUrl;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleApply(): void {
|
||||||
|
if (!image) return;
|
||||||
|
onApply(image.id, localConfig);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset(): void {
|
||||||
|
localConfig = clonePipelineConfig(pipelineStore.globalConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfigChange(newConfig: PipelineConfig): void {
|
||||||
|
localConfig = newConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePreviewUpdate(dataUrl: string): void {
|
||||||
|
previewDataUrl = dataUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProcessingChange(processing: boolean): void {
|
||||||
|
isProcessing = processing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropClick(e: MouseEvent): void {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open && image}
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="edit-modal-title"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-ink/50 backdrop-blur-sm"
|
||||||
|
onclick={handleBackdropClick}
|
||||||
|
onkeydown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="
|
||||||
|
bg-white rounded-lg shadow-[--shadow-modal]
|
||||||
|
w-full h-full
|
||||||
|
sm:w-[95vw] sm:h-[90vh] sm:max-w-6xl
|
||||||
|
flex flex-col
|
||||||
|
overflow-hidden
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="flex items-center justify-between px-4 py-3 border-b border-grey-200">
|
||||||
|
<h2 id="edit-modal-title" class="text-lg font-semibold text-ink truncate">
|
||||||
|
Edit: {image.filename}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="
|
||||||
|
flex items-center gap-1.5
|
||||||
|
px-3 py-1.5
|
||||||
|
text-grey-500
|
||||||
|
hover:text-ink hover:bg-grey-100
|
||||||
|
rounded-md
|
||||||
|
text-sm font-medium
|
||||||
|
transition-colors duration-150
|
||||||
|
"
|
||||||
|
onclick={handleReset}
|
||||||
|
title="Reset to global defaults"
|
||||||
|
>
|
||||||
|
<RotateCcw class="w-4 h-4" />
|
||||||
|
<span class="hidden sm:inline">Reset</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="
|
||||||
|
px-4 py-1.5
|
||||||
|
bg-white text-ink
|
||||||
|
border border-grey-200
|
||||||
|
rounded-md
|
||||||
|
text-sm font-medium
|
||||||
|
hover:bg-grey-50 hover:border-grey-300
|
||||||
|
transition-colors duration-150
|
||||||
|
"
|
||||||
|
onclick={onClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="
|
||||||
|
px-4 py-1.5
|
||||||
|
bg-accent text-white
|
||||||
|
rounded-md
|
||||||
|
text-sm font-medium
|
||||||
|
hover:bg-accent-hover
|
||||||
|
transition-colors duration-150
|
||||||
|
disabled:opacity-50
|
||||||
|
"
|
||||||
|
onclick={handleApply}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-1.5 text-grey-400 hover:text-grey-600 hover:bg-grey-100 rounded-md sm:hidden"
|
||||||
|
onclick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 flex flex-col lg:flex-row overflow-hidden">
|
||||||
|
<!-- Preview section -->
|
||||||
|
<div class="flex-1 p-4 overflow-auto bg-grey-50">
|
||||||
|
<ImagePreview
|
||||||
|
{image}
|
||||||
|
{device}
|
||||||
|
config={localConfig}
|
||||||
|
onPreviewUpdate={handlePreviewUpdate}
|
||||||
|
onProcessingChange={handleProcessingChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pipeline panel -->
|
||||||
|
<div class="w-full lg:w-80 xl:w-96 border-t lg:border-t-0 lg:border-l border-grey-200 overflow-auto">
|
||||||
|
<PipelinePanel
|
||||||
|
config={localConfig}
|
||||||
|
{device}
|
||||||
|
onConfigChange={handleConfigChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer with device info -->
|
||||||
|
<footer class="px-4 py-2 border-t border-grey-200 bg-grey-50">
|
||||||
|
<p class="text-xs text-grey-400 font-mono">
|
||||||
|
Device: {device.brand} {device.name} · {device.width} × {device.height} · {device.greyLevels} levels
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
372
src/lib/components/PipelinePanel.svelte
Normal file
372
src/lib/components/PipelinePanel.svelte
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Save } from 'lucide-svelte';
|
||||||
|
import type { PipelineConfig, Device, CropMode, ResizeMode, GreyscaleMethod, DitherAlgorithm } from '$lib/types';
|
||||||
|
import { pipelineStore } from '$lib/stores/pipeline.svelte';
|
||||||
|
import PipelineStep from './PipelineStep.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
config: PipelineConfig;
|
||||||
|
device: Device;
|
||||||
|
onConfigChange: (config: PipelineConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { config, device, onConfigChange }: Props = $props();
|
||||||
|
|
||||||
|
// Preset management
|
||||||
|
let showSaveDialog = $state(false);
|
||||||
|
let newPresetName = $state('');
|
||||||
|
|
||||||
|
// Helper to update a step
|
||||||
|
function updateStep<K extends keyof PipelineConfig>(
|
||||||
|
step: K,
|
||||||
|
updates: Partial<PipelineConfig[K]>
|
||||||
|
): void {
|
||||||
|
onConfigChange({
|
||||||
|
...config,
|
||||||
|
[step]: { ...config[step], ...updates }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePresetChange(e: Event): void {
|
||||||
|
const select = e.target as HTMLSelectElement;
|
||||||
|
const preset = pipelineStore.allPresets.find((p) => p.id === select.value);
|
||||||
|
if (preset) {
|
||||||
|
onConfigChange(JSON.parse(JSON.stringify(preset.config)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSavePreset(): void {
|
||||||
|
if (!newPresetName.trim()) return;
|
||||||
|
pipelineStore.saveAsPreset(newPresetName.trim());
|
||||||
|
showSaveDialog = false;
|
||||||
|
newPresetName = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options for selects
|
||||||
|
const cropModes: { value: CropMode; label: string }[] = [
|
||||||
|
{ value: 'center', label: 'Center' },
|
||||||
|
{ value: 'top', label: 'Top' },
|
||||||
|
{ value: 'bottom', label: 'Bottom' },
|
||||||
|
{ value: 'manual', label: 'Manual (coming soon)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const resizeModes: { value: ResizeMode; label: string }[] = [
|
||||||
|
{ value: 'cover', label: 'Cover (fill, may crop)' },
|
||||||
|
{ value: 'contain', label: 'Contain (fit, may pad)' },
|
||||||
|
{ value: 'stretch', label: 'Stretch (distort)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const greyscaleMethods: { value: GreyscaleMethod; label: string }[] = [
|
||||||
|
{ value: 'luminosity', label: 'Luminosity (natural)' },
|
||||||
|
{ value: 'average', label: 'Average' },
|
||||||
|
{ value: 'lightness', label: 'Lightness' },
|
||||||
|
{ value: 'red', label: 'Red channel' },
|
||||||
|
{ value: 'green', label: 'Green channel' },
|
||||||
|
{ value: 'blue', label: 'Blue channel' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const ditherAlgorithms: { value: DitherAlgorithm; label: string }[] = [
|
||||||
|
{ value: 'floyd-steinberg', label: 'Floyd-Steinberg' },
|
||||||
|
{ value: 'ordered', label: 'Ordered (Bayer)' },
|
||||||
|
{ value: 'atkinson', label: 'Atkinson' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<!-- Preset selector -->
|
||||||
|
<div class="p-4 border-b border-grey-200">
|
||||||
|
<label class="block text-xs font-medium text-grey-500 uppercase tracking-wide mb-2">
|
||||||
|
Preset
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select
|
||||||
|
class="
|
||||||
|
flex-1 px-3 py-2
|
||||||
|
bg-white text-ink text-sm
|
||||||
|
border border-grey-200 rounded-md
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent
|
||||||
|
"
|
||||||
|
value={pipelineStore.selectedPresetId ?? ''}
|
||||||
|
onchange={handlePresetChange}
|
||||||
|
>
|
||||||
|
{#each pipelineStore.allPresets as preset}
|
||||||
|
<option value={preset.id}>
|
||||||
|
{preset.name}{preset.isBuiltIn ? '' : ' (custom)'}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="
|
||||||
|
p-2
|
||||||
|
bg-white text-grey-500
|
||||||
|
border border-grey-200 rounded-md
|
||||||
|
hover:bg-grey-50 hover:text-ink
|
||||||
|
transition-colors duration-150
|
||||||
|
"
|
||||||
|
onclick={() => (showSaveDialog = true)}
|
||||||
|
title="Save as preset"
|
||||||
|
>
|
||||||
|
<Save class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showSaveDialog}
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newPresetName}
|
||||||
|
placeholder="Preset name"
|
||||||
|
class="
|
||||||
|
flex-1 px-3 py-1.5
|
||||||
|
bg-white text-ink text-sm
|
||||||
|
border border-grey-200 rounded-md
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-1.5 bg-accent text-white text-sm rounded-md hover:bg-accent-hover"
|
||||||
|
onclick={handleSavePreset}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-1.5 text-grey-500 text-sm hover:text-ink"
|
||||||
|
onclick={() => (showSaveDialog = false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pipeline steps -->
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
<div class="text-xs font-medium text-grey-500 uppercase tracking-wide px-4 py-2 bg-grey-50">
|
||||||
|
Pipeline Steps
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Crop -->
|
||||||
|
<PipelineStep
|
||||||
|
label="Crop"
|
||||||
|
enabled={config.crop.enabled}
|
||||||
|
onToggle={(enabled) => updateStep('crop', { enabled })}
|
||||||
|
defaultExpanded
|
||||||
|
>
|
||||||
|
{#snippet children()}
|
||||||
|
<label class="block text-xs text-grey-500 mb-1">Mode</label>
|
||||||
|
<select
|
||||||
|
class="w-full px-2 py-1.5 text-sm border border-grey-200 rounded-md"
|
||||||
|
value={config.crop.mode}
|
||||||
|
onchange={(e) => updateStep('crop', { mode: (e.target as HTMLSelectElement).value as CropMode })}
|
||||||
|
>
|
||||||
|
{#each cropModes as mode}
|
||||||
|
<option value={mode.value}>{mode.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</PipelineStep>
|
||||||
|
|
||||||
|
<!-- Resize -->
|
||||||
|
<PipelineStep
|
||||||
|
label="Resize"
|
||||||
|
enabled={config.resize.enabled}
|
||||||
|
onToggle={(enabled) => updateStep('resize', { enabled })}
|
||||||
|
defaultExpanded
|
||||||
|
>
|
||||||
|
{#snippet children()}
|
||||||
|
<label class="block text-xs text-grey-500 mb-1">Mode</label>
|
||||||
|
<select
|
||||||
|
class="w-full px-2 py-1.5 text-sm border border-grey-200 rounded-md"
|
||||||
|
value={config.resize.mode}
|
||||||
|
onchange={(e) => updateStep('resize', { mode: (e.target as HTMLSelectElement).value as ResizeMode })}
|
||||||
|
>
|
||||||
|
{#each resizeModes as mode}
|
||||||
|
<option value={mode.value}>{mode.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</PipelineStep>
|
||||||
|
|
||||||
|
<!-- Brightness -->
|
||||||
|
<PipelineStep
|
||||||
|
label="Brightness"
|
||||||
|
enabled={config.brightness.enabled}
|
||||||
|
onToggle={(enabled) => updateStep('brightness', { enabled })}
|
||||||
|
>
|
||||||
|
{#snippet children()}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="-100"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
value={config.brightness.value}
|
||||||
|
oninput={(e) => updateStep('brightness', { value: parseInt((e.target as HTMLInputElement).value) })}
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-grey-500 w-8 text-right">{config.brightness.value}</span>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</PipelineStep>
|
||||||
|
|
||||||
|
<!-- Contrast -->
|
||||||
|
<PipelineStep
|
||||||
|
label="Contrast"
|
||||||
|
enabled={config.contrast.enabled}
|
||||||
|
onToggle={(enabled) => updateStep('contrast', { enabled })}
|
||||||
|
>
|
||||||
|
{#snippet children()}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="-100"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
value={config.contrast.value}
|
||||||
|
oninput={(e) => updateStep('contrast', { value: parseInt((e.target as HTMLInputElement).value) })}
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-grey-500 w-8 text-right">{config.contrast.value}</span>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</PipelineStep>
|
||||||
|
|
||||||
|
<!-- Gamma -->
|
||||||
|
<PipelineStep
|
||||||
|
label="Gamma"
|
||||||
|
enabled={config.gamma.enabled}
|
||||||
|
onToggle={(enabled) => updateStep('gamma', { enabled })}
|
||||||
|
>
|
||||||
|
{#snippet children()}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0.2"
|
||||||
|
max="3"
|
||||||
|
step="0.1"
|
||||||
|
value={config.gamma.value}
|
||||||
|
oninput={(e) => updateStep('gamma', { value: parseFloat((e.target as HTMLInputElement).value) })}
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-grey-500 w-10 text-right">{config.gamma.value.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</PipelineStep>
|
||||||
|
|
||||||
|
<!-- Auto-levels -->
|
||||||
|
<PipelineStep
|
||||||
|
label="Auto-levels"
|
||||||
|
enabled={config.autoLevels.enabled}
|
||||||
|
onToggle={(enabled) => updateStep('autoLevels', { enabled })}
|
||||||
|
>
|
||||||
|
{#snippet children()}
|
||||||
|
<label class="block text-xs text-grey-500 mb-1">Clip %</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="5"
|
||||||
|
step="0.1"
|
||||||
|
value={config.autoLevels.clipPercent}
|
||||||
|
oninput={(e) => updateStep('autoLevels', { clipPercent: parseFloat((e.target as HTMLInputElement).value) })}
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-grey-500 w-10 text-right">{config.autoLevels.clipPercent.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</PipelineStep>
|
||||||
|
|
||||||
|
<!-- Greyscale -->
|
||||||
|
<PipelineStep
|
||||||
|
label="Greyscale"
|
||||||
|
enabled={config.greyscale.enabled}
|
||||||
|
onToggle={(enabled) => updateStep('greyscale', { enabled })}
|
||||||
|
defaultExpanded
|
||||||
|
>
|
||||||
|
{#snippet children()}
|
||||||
|
<label class="block text-xs text-grey-500 mb-1">Method</label>
|
||||||
|
<select
|
||||||
|
class="w-full px-2 py-1.5 text-sm border border-grey-200 rounded-md"
|
||||||
|
value={config.greyscale.method}
|
||||||
|
onchange={(e) => updateStep('greyscale', { method: (e.target as HTMLSelectElement).value as GreyscaleMethod })}
|
||||||
|
>
|
||||||
|
{#each greyscaleMethods as method}
|
||||||
|
<option value={method.value}>{method.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</PipelineStep>
|
||||||
|
|
||||||
|
<!-- Sharpen -->
|
||||||
|
<PipelineStep
|
||||||
|
label="Sharpen"
|
||||||
|
enabled={config.sharpen.enabled}
|
||||||
|
onToggle={(enabled) => updateStep('sharpen', { enabled })}
|
||||||
|
>
|
||||||
|
{#snippet children()}
|
||||||
|
<label class="block text-xs text-grey-500 mb-1">Amount</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
value={config.sharpen.amount}
|
||||||
|
oninput={(e) => updateStep('sharpen', { amount: parseInt((e.target as HTMLInputElement).value) })}
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-grey-500 w-8 text-right">{config.sharpen.amount}</span>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</PipelineStep>
|
||||||
|
|
||||||
|
<!-- Quantize -->
|
||||||
|
<PipelineStep
|
||||||
|
label="Quantize"
|
||||||
|
enabled={config.quantize.enabled}
|
||||||
|
onToggle={(enabled) => updateStep('quantize', { enabled })}
|
||||||
|
>
|
||||||
|
{#snippet children()}
|
||||||
|
<label class="block text-xs text-grey-500 mb-1">Levels</label>
|
||||||
|
<select
|
||||||
|
class="w-full px-2 py-1.5 text-sm border border-grey-200 rounded-md"
|
||||||
|
value={config.quantize.levels}
|
||||||
|
onchange={(e) => updateStep('quantize', { levels: parseInt((e.target as HTMLSelectElement).value) })}
|
||||||
|
>
|
||||||
|
<option value={device.greyLevels}>{device.greyLevels} (device default)</option>
|
||||||
|
<option value="2">2</option>
|
||||||
|
<option value="4">4</option>
|
||||||
|
<option value="8">8</option>
|
||||||
|
<option value="16">16</option>
|
||||||
|
<option value="32">32</option>
|
||||||
|
<option value="64">64</option>
|
||||||
|
<option value="256">256 (no quantization)</option>
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</PipelineStep>
|
||||||
|
|
||||||
|
<!-- Dither -->
|
||||||
|
<PipelineStep
|
||||||
|
label="Dither"
|
||||||
|
enabled={config.dither.enabled}
|
||||||
|
onToggle={(enabled) => updateStep('dither', { enabled })}
|
||||||
|
>
|
||||||
|
{#snippet children()}
|
||||||
|
<label class="block text-xs text-grey-500 mb-1">Algorithm</label>
|
||||||
|
<select
|
||||||
|
class="w-full px-2 py-1.5 text-sm border border-grey-200 rounded-md"
|
||||||
|
value={config.dither.algorithm}
|
||||||
|
onchange={(e) => updateStep('dither', { algorithm: (e.target as HTMLSelectElement).value as DitherAlgorithm })}
|
||||||
|
>
|
||||||
|
{#each ditherAlgorithms as algo}
|
||||||
|
<option value={algo.value}>{algo.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</PipelineStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
76
src/lib/components/PipelineStep.svelte
Normal file
76
src/lib/components/PipelineStep.svelte
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
enabled: boolean;
|
||||||
|
onToggle: (enabled: boolean) => void;
|
||||||
|
children?: Snippet;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { label, enabled, onToggle, children, defaultExpanded = false }: Props = $props();
|
||||||
|
|
||||||
|
let expanded = $state(defaultExpanded);
|
||||||
|
|
||||||
|
function handleToggle(): void {
|
||||||
|
onToggle(!enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpand(): void {
|
||||||
|
if (enabled && children) {
|
||||||
|
expanded = !expanded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="border-b border-grey-100 last:border-b-0">
|
||||||
|
<!-- Header row -->
|
||||||
|
<div class="flex items-center gap-2 px-4 py-3">
|
||||||
|
<!-- Checkbox -->
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onchange={handleToggle}
|
||||||
|
class="
|
||||||
|
w-4 h-4
|
||||||
|
rounded
|
||||||
|
border-grey-300
|
||||||
|
text-accent
|
||||||
|
focus:ring-accent focus:ring-offset-0
|
||||||
|
cursor-pointer
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Label + expand toggle -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="
|
||||||
|
flex-1 flex items-center justify-between
|
||||||
|
text-sm font-medium
|
||||||
|
{enabled ? 'text-ink' : 'text-grey-400'}
|
||||||
|
text-left
|
||||||
|
"
|
||||||
|
onclick={toggleExpand}
|
||||||
|
disabled={!enabled || !children}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
|
||||||
|
{#if children && enabled}
|
||||||
|
{#if expanded}
|
||||||
|
<ChevronDown class="w-4 h-4 text-grey-400" />
|
||||||
|
{:else}
|
||||||
|
<ChevronRight class="w-4 h-4 text-grey-400" />
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expanded content -->
|
||||||
|
{#if expanded && enabled && children}
|
||||||
|
<div class="px-4 pb-3 pl-10">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user