mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 13:36:36 +00:00
feat: add chain tools workflow
This commit is contained in:
@@ -154,3 +154,71 @@ input.control[type='number'] {
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.tool-stage {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-stage.single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tool-stage .cell {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.tool-stage .cell + .cell {
|
||||
border-left: 1px solid var(--border);
|
||||
padding-left: var(--space-4);
|
||||
}
|
||||
|
||||
.cell-media {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 14rem;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chain-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actions-row > button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.tool-stage {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tool-stage .cell + .cell {
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
import { imageInfo, type ImageInfo } from '$lib/core/analyze';
|
||||
import { decodeFile, isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
|
||||
import type { PixelImage } from '$lib/core/types';
|
||||
import { defaultParams, sanitizeParams, type ToolEntry } from '$lib/registry';
|
||||
import { defaultParams, getTool, outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
|
||||
import { newStepId, type PipelineStep } from '$lib/tools/pipeline';
|
||||
import DownloadButton from './DownloadButton.svelte';
|
||||
import ParamForm from './ParamForm.svelte';
|
||||
import Preview from './Preview.svelte';
|
||||
import ToolSearch from './search/ToolSearch.svelte';
|
||||
import ChainToolBlock from './chain/ChainToolBlock.svelte';
|
||||
import { createAutoRunner } from '$lib/tools/auto-run';
|
||||
import ParamsCard from './tool/ParamsCard.svelte';
|
||||
import ResultCard from './tool/ResultCard.svelte';
|
||||
@@ -22,11 +28,47 @@
|
||||
let info = $state<ImageInfo | null>(null);
|
||||
let errorText = $state('');
|
||||
let values = $state<Record<string, any>>({});
|
||||
let chain = $state<PipelineStep[]>([]);
|
||||
let chainResults = $state<(PixelImage | null)[]>([]);
|
||||
let lastRunChainJson = '';
|
||||
|
||||
const isInfo = $derived(tool.resultType === 'info');
|
||||
const isSourceless = $derived(tool.sourceMode === 'none');
|
||||
const isTextSource = $derived(tool.sourceMode === 'text');
|
||||
const sanitized = $derived(sanitizeParams(tool, values));
|
||||
const canChainBase = $derived(
|
||||
(tool.resultType ?? 'image') === 'image' && tool.sourceMode !== 'text'
|
||||
);
|
||||
const hasFilledSteps = $derived(chain.some((step) => step.toolId !== ''));
|
||||
|
||||
function addChainStep() {
|
||||
chain.push({ id: newStepId(), toolId: '', values: {} });
|
||||
}
|
||||
|
||||
function removeChainStep(index: number) {
|
||||
chain.splice(index, 1);
|
||||
chainResults = [];
|
||||
}
|
||||
|
||||
function removeChain() {
|
||||
chain.length = 0;
|
||||
chainResults = [];
|
||||
}
|
||||
|
||||
function toggleChain() {
|
||||
if (chain.length > 0) {
|
||||
removeChain();
|
||||
} else {
|
||||
addChainStep();
|
||||
}
|
||||
}
|
||||
|
||||
function applyChainTool(index: number, toolId: string) {
|
||||
const stepTool = getTool(toolId);
|
||||
if (!stepTool || !chain[index]) return;
|
||||
chain[index].toolId = toolId;
|
||||
chain[index].values = defaultParams(stepTool);
|
||||
}
|
||||
|
||||
const runner = createAutoRunner();
|
||||
let hasLastRun = false;
|
||||
@@ -91,6 +133,7 @@
|
||||
hasLastRun = true;
|
||||
lastRunSource = source;
|
||||
lastRunValuesJson = JSON.stringify(sanitized);
|
||||
lastRunChainJson = JSON.stringify(chain);
|
||||
try {
|
||||
let next: PixelImage | null = null;
|
||||
let nextText: string | null = null;
|
||||
@@ -116,6 +159,26 @@
|
||||
result = next;
|
||||
previewResult = nextPreview;
|
||||
textResult = nextText;
|
||||
|
||||
const collected: (PixelImage | null)[] = [];
|
||||
let current: PixelImage | null = next ?? source;
|
||||
for (let i = 0; i < chain.length; i++) {
|
||||
const step = chain[i];
|
||||
const stepTool = step.toolId === '' ? undefined : getTool(step.toolId);
|
||||
if (!current || !stepTool?.run) {
|
||||
collected.push(null);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
current = await stepTool.run(current, sanitizeParams(stepTool, step.values));
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
throw new Error(`Шаг ${i + 1} (${stepTool.title}): ${message}`);
|
||||
}
|
||||
if (!runner.isCurrent(token)) return;
|
||||
collected.push(current);
|
||||
}
|
||||
chainResults = collected;
|
||||
status = 'loaded';
|
||||
} catch (e) {
|
||||
if (!runner.isCurrent(token)) return;
|
||||
@@ -125,7 +188,15 @@
|
||||
|
||||
$effect(() => {
|
||||
const valuesJson = JSON.stringify(sanitized);
|
||||
if (hasLastRun && source === lastRunSource && valuesJson === lastRunValuesJson) return;
|
||||
const chainJson = JSON.stringify(chain);
|
||||
if (
|
||||
hasLastRun &&
|
||||
source === lastRunSource &&
|
||||
valuesJson === lastRunValuesJson &&
|
||||
chainJson === lastRunChainJson
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!source && !isSourceless) return;
|
||||
if (isInfo) return;
|
||||
return runner.schedule(() => void runTool());
|
||||
@@ -178,7 +249,7 @@
|
||||
<div class="error-banner" role="alert">{errorText}</div>
|
||||
{/if}
|
||||
|
||||
<div class="stage panel" class:single={isSourceless}>
|
||||
<div class="panel tool-stage" class:single={isSourceless}>
|
||||
{#if !isSourceless}
|
||||
<div class="cell">
|
||||
{#if isTextSource && !source}
|
||||
@@ -206,20 +277,60 @@
|
||||
{info}
|
||||
{isInfo}
|
||||
params={sanitized}
|
||||
{textResult}
|
||||
textResult={textResult}
|
||||
onDownloadError={showError}
|
||||
onChainToggle={canChainBase ? toggleChain : undefined}
|
||||
hasChain={chain.length > 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if (source || isSourceless) && !isInfo}
|
||||
<div class="base-params">
|
||||
<ParamsCard
|
||||
params={tool.params}
|
||||
bind:values
|
||||
{pipetteTargetId}
|
||||
onPipetteToggle={handlePipetteToggle}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="chain-stack">
|
||||
{#each chain as step, index (step.id)}
|
||||
{#if step.toolId === ''}
|
||||
<div class="panel empty-slot">
|
||||
<header>
|
||||
<h3 class="heading-section">Шаг {index + 1}</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="remove-step"
|
||||
aria-label="Убрать шаг"
|
||||
onclick={() => removeChainStep(index)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
<ToolSearch onSelect={(id) => applyChainTool(index, id)} />
|
||||
</div>
|
||||
{:else if getTool(step.toolId)}
|
||||
{@const stepTool = getTool(step.toolId)!}
|
||||
<ChainToolBlock
|
||||
index={index}
|
||||
tool={stepTool}
|
||||
bind:values={step.values}
|
||||
input={index === 0 ? result : (chainResults[index - 1] ?? null)}
|
||||
result={chainResults[index] ?? null}
|
||||
busy={status === 'processing'}
|
||||
isLast={index === chain.length - 1}
|
||||
onRemove={() => removeChainStep(index)}
|
||||
onError={showError}
|
||||
onAddStep={addChainStep}
|
||||
onRemoveChain={removeChain}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@@ -236,37 +347,40 @@
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
.base-params {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.stage.single {
|
||||
grid-template-columns: 1fr;
|
||||
.empty-slot {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.cell {
|
||||
min-width: 0;
|
||||
.empty-slot header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.cell + .cell {
|
||||
border-left: 1px solid var(--border);
|
||||
padding-left: var(--space-4);
|
||||
.empty-slot h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.stage {
|
||||
grid-template-columns: 1fr;
|
||||
.remove-step {
|
||||
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;
|
||||
}
|
||||
|
||||
.cell + .cell {
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: var(--space-4);
|
||||
}
|
||||
.remove-step:hover {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import { outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
|
||||
import type { PixelImage } from '$lib/core/types';
|
||||
import DownloadButton from '../DownloadButton.svelte';
|
||||
import Button from '../ui/Button.svelte';
|
||||
import EmptyState from '../ui/EmptyState.svelte';
|
||||
import ParamsCard from '../tool/ParamsCard.svelte';
|
||||
import Preview from '../Preview.svelte';
|
||||
|
||||
interface Props {
|
||||
index: number;
|
||||
tool: ToolEntry;
|
||||
values: Record<string, any>;
|
||||
input: PixelImage | null;
|
||||
result: PixelImage | null;
|
||||
busy: boolean;
|
||||
isLast: boolean;
|
||||
onRemove: () => void;
|
||||
onError: (e: unknown) => void;
|
||||
onAddStep: () => void;
|
||||
onRemoveChain: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
index,
|
||||
tool,
|
||||
values = $bindable(),
|
||||
input,
|
||||
result,
|
||||
busy,
|
||||
isLast,
|
||||
onRemove,
|
||||
onError,
|
||||
onAddStep,
|
||||
onRemoveChain
|
||||
}: Props = $props();
|
||||
|
||||
const format = $derived(outputOf(tool));
|
||||
const safeParams = $derived(sanitizeParams(tool, values));
|
||||
</script>
|
||||
|
||||
<div class="block">
|
||||
<header>
|
||||
<h3 class="heading-section">Шаг {index + 1}: {tool.title}</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="remove"
|
||||
aria-label="Убрать шаг"
|
||||
title="Убрать шаг"
|
||||
onclick={onRemove}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="tool-stage panel">
|
||||
<div class="cell">
|
||||
<h4 class="heading-section">Вход</h4>
|
||||
<div class="cell-media">
|
||||
<Preview image={input} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h4 class="heading-section">Результат</h4>
|
||||
{#if busy && !result}
|
||||
<div class="cell-media">
|
||||
<EmptyState title="Обработка…" hint="Выполняется шаг цепочки" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="cell-media">
|
||||
<Preview image={result} />
|
||||
{#if busy}
|
||||
<span class="recalc" aria-live="polite">Пересчёт…</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="actions-row">
|
||||
<DownloadButton
|
||||
image={result}
|
||||
format={format}
|
||||
baseName="{index + 1}-{tool.id}"
|
||||
params={safeParams}
|
||||
{onError}
|
||||
/>
|
||||
<Button variant="secondary" fullWidth onclick={isLast ? onAddStep : onRemoveChain}>
|
||||
{isLast ? '⛓ Следующий инструмент' : '✂ Оборвать цепочку'}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ParamsCard params={tool.params} bind:values />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.remove {
|
||||
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;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
</style>
|
||||
@@ -2,11 +2,10 @@
|
||||
import { isChainable, TOOLS } from '$lib/registry';
|
||||
|
||||
interface Props {
|
||||
size?: 'hero' | 'compact';
|
||||
onSelect: (toolId: string) => void;
|
||||
}
|
||||
|
||||
let { size = 'hero', onSelect }: Props = $props();
|
||||
let { onSelect }: Props = $props();
|
||||
|
||||
const candidates = TOOLS.filter(isChainable);
|
||||
|
||||
@@ -51,7 +50,7 @@
|
||||
}
|
||||
}
|
||||
found.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
|
||||
return found.slice(0, size === 'hero' ? 12 : 8);
|
||||
return found.slice(0, 12);
|
||||
});
|
||||
|
||||
function choose(id: string) {
|
||||
@@ -79,7 +78,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="search {size}">
|
||||
<div class="search">
|
||||
<input
|
||||
type="search"
|
||||
bind:value={query}
|
||||
@@ -120,25 +119,16 @@
|
||||
.search {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: var(--control-max-width);
|
||||
}
|
||||
|
||||
.hero {
|
||||
max-width: 36rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-m);
|
||||
background: var(--surface);
|
||||
font-size: var(--text-l);
|
||||
}
|
||||
|
||||
.hero input {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: var(--text-xl);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -163,6 +153,7 @@
|
||||
box-shadow: var(--shadow-card);
|
||||
max-height: 24rem;
|
||||
overflow-y: auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
li button {
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
|
||||
<style>
|
||||
.root {
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import CheckboxField from '../ui/CheckboxField.svelte';
|
||||
import Button from '../ui/Button.svelte';
|
||||
import DownloadButton from '../DownloadButton.svelte';
|
||||
import EmptyState from '../ui/EmptyState.svelte';
|
||||
import InfoPanel from '../InfoPanel.svelte';
|
||||
@@ -22,6 +23,8 @@
|
||||
isInfo: boolean;
|
||||
params: Record<string, unknown>;
|
||||
textResult: string | null;
|
||||
onChainToggle?: () => void;
|
||||
hasChain?: boolean;
|
||||
onDownloadError: (e: unknown) => void;
|
||||
}
|
||||
|
||||
@@ -36,6 +39,8 @@
|
||||
isInfo,
|
||||
params,
|
||||
textResult,
|
||||
onChainToggle,
|
||||
hasChain = false,
|
||||
onDownloadError
|
||||
}: Props = $props();
|
||||
|
||||
@@ -81,6 +86,7 @@
|
||||
<span class="recalc" aria-live="polite">Пересчёт…</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="actions-row">
|
||||
<DownloadButton
|
||||
image={result}
|
||||
format={outputOf(tool)}
|
||||
@@ -88,6 +94,12 @@
|
||||
{params}
|
||||
onError={onDownloadError}
|
||||
/>
|
||||
{#if onChainToggle}
|
||||
<Button variant="secondary" fullWidth onclick={onChainToggle}>
|
||||
{hasChain ? '✂ Оборвать цепочку' : '⛓ Следующий инструмент'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<section class="hero">
|
||||
<h1>Что делаем с изображением?</h1>
|
||||
<p class="lead text-muted">Найдите инструмент — все операции выполняются локально в браузере.</p>
|
||||
<ToolSearch size="hero" onSelect={(id) => (selectedId = id)} />
|
||||
<ToolSearch onSelect={(id) => (selectedId = id)} />
|
||||
</section>
|
||||
{:else}
|
||||
<section class="workbench">
|
||||
|
||||
Reference in New Issue
Block a user