feat: add step picker\list\editor

This commit is contained in:
2026-08-23 20:12:43 +05:00
parent 23c985bde9
commit 8532eb98c3
5 changed files with 506 additions and 0 deletions
@@ -0,0 +1,41 @@
<script lang="ts">
import ParamForm from '../ParamForm.svelte';
import type { PipelineStep } from '$lib/tools/pipeline';
import { getTool } from '$lib/registry';
interface Props {
step: PipelineStep;
}
let { step }: Props = $props();
const tool = $derived(getTool(step.toolId));
</script>
{#if tool}
<div class="editor panel">
<h3 class="heading-section">{tool.title}</h3>
<p class="description text-caption text-muted">{tool.description}</p>
{#if tool.params.length > 0}
<ParamForm params={tool.params} bind:values={step.values} />
{:else}
<p class="hint text-caption text-muted">У этого шага нет параметров.</p>
{/if}
</div>
{/if}
<style>
.editor {
padding: var(--space-3);
}
h3 {
margin-bottom: var(--space-1);
font-size: var(--text-m);
}
.description,
.hint {
margin: 0 0 var(--space-2);
}
</style>
@@ -0,0 +1,128 @@
<script lang="ts">
import type { PipelineStep } from '$lib/tools/pipeline';
import { getTool } from '$lib/registry';
interface Props {
steps: PipelineStep[];
selectedIndex: number;
onSelect: (index: number) => void;
onMoveUp: (index: number) => void;
onMoveDown: (index: number) => void;
onRemove: (index: number) => void;
}
let { steps, selectedIndex, onSelect, onMoveUp, onMoveDown, onRemove }: Props = $props();
function toolTitle(step: PipelineStep): string {
return getTool(step.toolId)?.title ?? step.toolId;
}
</script>
{#if steps.length === 0}
<p class="empty text-caption text-muted">Цепочка пуста — добавьте первый инструмент.</p>
{:else}
<ol>
{#each steps as step, index (step.id)}
<li>
<button type="button" class="label" class:selected={index === selectedIndex} onclick={() => onSelect(index)}>
<span class="num">{index + 1}</span>
{toolTitle(step)}
</button>
<span class="controls">
<button type="button" aria-label="Выше" disabled={index === 0} onclick={() => onMoveUp(index)}></button>
<button
type="button"
aria-label="Ниже"
disabled={index === steps.length - 1}
onclick={() => onMoveDown(index)}
></button>
<button type="button" class="remove" aria-label="Удалить шаг" onclick={() => onRemove(index)}>✕</button>
</span>
</li>
{/each}
</ol>
{/if}
<style>
ol {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
li {
display: flex;
align-items: center;
gap: var(--space-1);
}
.label {
flex: 1;
min-width: 0;
text-align: left;
padding: var(--space-1) var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-s);
background: none;
color: var(--text);
cursor: pointer;
font-size: var(--text-m);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.label:hover,
.label.selected {
background: color-mix(in srgb, var(--accent) 8%, var(--surface));
border-color: var(--border);
}
.num {
display: inline-block;
min-width: 1.4ch;
margin-right: var(--space-1);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.controls {
display: flex;
gap: 2px;
}
.controls button {
width: 1.6rem;
height: 1.6rem;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--surface);
color: var(--text-muted);
line-height: 1;
cursor: pointer;
}
.controls button:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.controls .remove:hover:not(:disabled) {
border-color: var(--danger);
color: var(--danger);
}
.controls button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.empty {
margin: 0;
}
</style>
@@ -0,0 +1,50 @@
<script lang="ts">
import Button from '../ui/Button.svelte';
import { CATEGORIES } from '$lib/categories';
import { TOOLS } from '$lib/registry';
interface Props {
onAdd: (toolId: string) => void;
}
let { onAdd }: Props = $props();
const steppable = TOOLS.filter(
(tool) => (tool.resultType ?? 'image') === 'image' && (!tool.sourceMode || tool.sourceMode === 'file')
);
let selected = $state(steppable[0]?.id ?? '');
</script>
<div class="picker">
<select bind:value={selected} aria-label="Инструмент для нового шага">
{#each CATEGORIES as category (category.id)}
{@const categoryTools = steppable.filter((tool) => tool.category === category.id)}
{#if categoryTools.length > 0}
<optgroup label={category.label}>
{#each categoryTools as tool (tool.id)}
<option value={tool.id}>{tool.title}</option>
{/each}
</optgroup>
{/if}
{/each}
</select>
<Button variant="secondary" onclick={() => onAdd(selected)}>Добавить шаг</Button>
</div>
<style>
.picker {
display: flex;
gap: var(--space-2);
align-items: center;
}
select {
flex: 1;
min-width: 0;
padding: var(--space-1) var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--surface);
}
</style>
+6
View File
@@ -14,6 +14,7 @@
<header> <header>
<a href="/" class="brand">easy-png-tools</a> <a href="/" class="brand">easy-png-tools</a>
<nav aria-label="Категории инструментов"> <nav aria-label="Категории инструментов">
<a class="nav-link workspace-link" href="/workspace">Рабочая область</a>
{#each CATEGORIES as category (category.id)} {#each CATEGORIES as category (category.id)}
<a class="nav-link" href="/#{category.id}">{category.label}</a> <a class="nav-link" href="/#{category.id}">{category.label}</a>
{/each} {/each}
@@ -79,6 +80,11 @@
text-decoration: none; text-decoration: none;
} }
.workspace-link {
font-weight: 600;
color: var(--accent);
}
main { main {
flex: 1; flex: 1;
width: 100%; width: 100%;
+281
View File
@@ -0,0 +1,281 @@
<script lang="ts">
import { clonePixelImage, type PixelImage } from '$lib/core/types';
import { decodeFile, isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
import { getTool, PNG_OUTPUT, sanitizeParams } from '$lib/registry';
import { createAutoRunner } from '$lib/tools/auto-run';
import { createStep, type PipelineStep } from '$lib/tools/pipeline';
import DownloadButton from '$lib/components/DownloadButton.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Preview from '$lib/components/Preview.svelte';
import SourceCard from '$lib/components/tool/SourceCard.svelte';
import StepEditor from '$lib/components/workspace/StepEditor.svelte';
import StepList from '$lib/components/workspace/StepList.svelte';
import StepPicker from '$lib/components/workspace/StepPicker.svelte';
type Status = 'idle' | 'loaded' | 'processing' | 'error';
const runner = createAutoRunner();
let hasLastRun = false;
let lastRunSource: PixelImage | null = null;
let lastRunStepsJson = '';
let status = $state<Status>('idle');
let source = $state<PixelImage | null>(null);
let steps = $state<PipelineStep[]>([]);
let selectedId = $state<string | null>(null);
let previews = $state<(PixelImage | null)[]>([]);
let errorText = $state('');
let selectedIndex = $derived.by(() => {
if (selectedId !== null) {
const index = steps.findIndex((step) => step.id === selectedId);
if (index >= 0) return index;
}
return steps.length - 1;
});
let shownResult = $derived.by(() => {
if (!source) return null;
if (previews.length === 0) return steps.length === 0 ? source : null;
const idx = Math.min(Math.max(selectedIndex, 0), previews.length - 1);
return previews[idx];
});
function addStep(toolId: string) {
steps.push(createStep(toolId));
selectedId = steps[steps.length - 1].id;
}
function moveUp(index: number) {
if (index <= 0) return;
const [moved] = steps.splice(index, 1);
steps.splice(index - 1, 0, moved);
}
function moveDown(index: number) {
if (index >= steps.length - 1) return;
const [moved] = steps.splice(index, 1);
steps.splice(index + 1, 0, moved);
}
function removeStep(index: number) {
if (index < 0 || index >= steps.length) return;
const removed = steps[index];
steps.splice(index, 1);
if (selectedId === removed.id) selectedId = null;
}
async function runChain() {
if (!source) return;
const token = runner.next();
hasLastRun = true;
lastRunSource = source;
lastRunStepsJson = JSON.stringify(steps);
status = 'processing';
errorText = '';
try {
const collected: PixelImage[] = [];
let current: PixelImage = clonePixelImage(source);
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const tool = getTool(step.toolId);
if (!tool?.run) continue;
try {
current = await tool.run(current, sanitizeParams(tool, step.values));
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
throw new Error(`Шаг ${i + 1} (${tool.title}): ${message}`);
}
if (!runner.isCurrent(token)) return;
collected.push(current);
}
previews = collected;
status = 'loaded';
} catch (e) {
if (!runner.isCurrent(token)) return;
errorText = e instanceof Error ? e.message : String(e);
status = 'loaded';
}
}
$effect(() => {
const stepsJson = JSON.stringify(steps);
if (hasLastRun && source === lastRunSource && stepsJson === lastRunStepsJson) return;
if (!source) return;
return runner.schedule(() => void runChain());
});
function showError(message: string) {
errorText = message;
}
function reset() {
source = null;
previews = [];
status = 'idle';
errorText = '';
}
async function handleFile(file: File) {
if (!isSupportedImage(file)) {
showError(unsupportedImageMessage(file));
return;
}
try {
source = await decodeFile(file);
} catch (e) {
showError(e instanceof Error ? e.message : String(e));
}
}
function handlePaste(event: ClipboardEvent) {
for (const item of event.clipboardData?.items ?? []) {
if (!item.type.startsWith('image/')) continue;
const file = item.getAsFile();
if (!file) break;
event.preventDefault();
if (!isSupportedImage(file)) {
showError(unsupportedImageMessage(file));
break;
}
void handleFile(file);
break;
}
}
</script>
<svelte:window onpaste={handlePaste} />
<section>
<h1>Рабочая область</h1>
<p class="description text-muted">
Цепочка инструментов: результат каждого шага становится входом следующего.
</p>
{#if errorText}
<div class="error-banner" role="alert">{errorText}</div>
{/if}
<div class="stage panel">
<div class="cell">
<SourceCard {source} onFile={handleFile} onError={showError} onReset={reset} />
</div>
<div class="cell middle">
<h2 class="heading-section">Шаги</h2>
<StepPicker onAdd={addStep} />
<StepList
{steps}
selectedIndex={selectedIndex}
onSelect={(index) => (selectedId = steps[index]?.id ?? null)}
onMoveUp={moveUp}
onMoveDown={moveDown}
onRemove={removeStep}
/>
{#if selectedIndex >= 0 && steps[selectedIndex]}
<StepEditor step={steps[selectedIndex]} />
{/if}
</div>
<div class="cell">
<h2 class="heading-section">Итог</h2>
{#if !source}
<div class="media">
<EmptyState
title="Итог появится здесь"
hint="Загрузите изображение и соберите цепочку шагов"
/>
</div>
{:else if status === 'processing' && !shownResult}
<div class="media">
<EmptyState title="Обработка…" hint="Выполняется цепочка шагов" />
</div>
{:else}
<div class="media">
<Preview image={shownResult} />
{#if status === 'processing'}
<span class="recalc" aria-live="polite">Пересчёт…</span>
{/if}
</div>
<DownloadButton
image={shownResult}
format={PNG_OUTPUT}
baseName="workspace"
params={{}}
onError={(e) => showError(e instanceof Error ? e.message : String(e))}
/>
{/if}
</div>
</div>
</section>
<style>
h1 {
margin-bottom: var(--space-1);
}
.description {
max-width: 48rem;
margin-bottom: var(--space-4);
}
.error-banner {
margin-bottom: var(--space-3);
}
.stage {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(16rem, 22rem) minmax(0, 1fr);
gap: var(--space-4);
padding: var(--space-4);
align-items: start;
}
.cell {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.cell h2 {
margin-bottom: var(--space-1);
}
.middle {
position: sticky;
top: var(--space-4);
}
.media {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 14rem;
position: relative;
width: 100%;
}
.recalc {
position: absolute;
top: var(--space-2);
right: var(--space-2);
padding: 2px var(--space-2);
border-radius: var(--radius-s);
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
color: var(--accent);
font-size: var(--text-xs);
}
@media (max-width: 64rem) {
.stage {
grid-template-columns: 1fr;
}
.middle {
position: static;
}
}
</style>