feat: update tool page - add step card steps

This commit is contained in:
2026-08-28 11:43:26 +05:00
parent a16a7b92c7
commit d373cc0daf
2 changed files with 273 additions and 332 deletions
+1 -1
View File
@@ -332,7 +332,7 @@ src/routes/preview/tools/[id]/+page.svelte
- [x] **[C11]** `/preview/demo` — зеркало воркспейса (4 шага + панель превью), статичный приёмочный стенд. - [x] **[C11]** `/preview/demo` — зеркало воркспейса (4 шага + панель превью), статичный приёмочный стенд.
- [x] **[C12]** `/preview/tools/[id]` — data-driven settings-panel + preview-panel (gradient live, остальные — плейсхолдер); `NumberField`, `entries()` для prerender, ToolCard-ссылки подключены. - [x] **[C12]** `/preview/tools/[id]` — data-driven settings-panel + preview-panel (gradient live, остальные — плейсхолдер); `NumberField`, `entries()` для prerender, ToolCard-ссылки подключены.
- [x] **[C13]** `/preview/tools/[id]` — comparison-grid для file-source инструментов (`Dropzone` + before/after `CheckerCanvas`, `result-meta`); охватывает background-remover и прочие. - [x] **[C13]** `/preview/tools/[id]` — comparison-grid для file-source инструментов (`Dropzone` + before/after `CheckerCanvas`, `result-meta`); охватывает background-remover и прочие.
- [ ] **[C14]** step-cards для chain в tool-page. - [x] **[C14]** `/preview/tools/[id]` — исполняемая цепочка: `StepCard`-шаги (параметры inline) + «Add step» (chainable), живой прогон пайплайна на исходнике, превью каждого шага + итог + Download.
### Шаг 4. Переключатель и проверка ### Шаг 4. Переключатель и проверка
+272 -331
View File
@@ -1,7 +1,13 @@
<script lang="ts"> <script lang="ts">
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { untrack } from "svelte"; import { untrack } from "svelte";
import { getTool, defaultParams } from "$lib/registry"; import {
getTool,
defaultParams,
isChainable,
TOOLS,
type ParamDef,
} from "$lib/registry";
import { encode, downloadBlob, toDataUrl, decodeBytes } from "$lib/core/io"; import { encode, downloadBlob, toDataUrl, decodeBytes } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import Panel from "$lib/components/kit/Panel.svelte"; import Panel from "$lib/components/kit/Panel.svelte";
@@ -21,94 +27,134 @@
import EmptyState from "$lib/components/kit/EmptyState.svelte"; import EmptyState from "$lib/components/kit/EmptyState.svelte";
import Button from "$lib/components/kit/Button.svelte"; import Button from "$lib/components/kit/Button.svelte";
import Dropzone from "$lib/components/kit/Dropzone.svelte"; import Dropzone from "$lib/components/kit/Dropzone.svelte";
import StepCard from "$lib/components/kit/StepCard.svelte";
import PreviewTile from "$lib/components/kit/PreviewTile.svelte";
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte"; import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
type ParamValue = string | number | boolean; type ParamValue = string | number | boolean;
interface ChainStep {
toolId: string;
params: Record<string, ParamValue>;
}
interface Props { interface Props {
data: { id: string }; data: { id: string };
} }
let { data }: Props = $props(); let { data }: Props = $props();
const initialTool = untrack(() => getTool(data.id)); const initialTool = untrack(() => getTool(data.id));
let params = $state<Record<string, ParamValue>>( let steps = $state<ChainStep[]>(
initialTool initialTool
? (defaultParams(initialTool) as Record<string, ParamValue>) ? [
: {}, {
toolId: initialTool.id,
params: defaultParams(initialTool) as Record<string, ParamValue>,
},
]
: [],
); );
let previewUrl = $state("");
let previewError = $state("");
let sourceImg = $state<PixelImage | null>(null); let sourceImg = $state<PixelImage | null>(null);
let sourceUrl = $state(""); let sourceUrl = $state("");
let sourceName = $state(""); let sourceName = $state("");
let stepOutputs = $state<{ toolId: string; title: string; url: string }[]>(
[],
);
let resultImg = $state<PixelImage | null>(null); let resultImg = $state<PixelImage | null>(null);
let resultUrl = $state(""); let resultUrl = $state("");
let showMask = $state(false); let chainError = $state("");
let processing = $state(false);
const tool = $derived(getTool(data.id)); const chainOptions = TOOLS.filter(isChainable).map((t) => ({
value: t.id,
label: t.title,
}));
let addId = $state(chainOptions[0]?.value ?? "");
const firstTool = $derived(steps[0] ? getTool(steps[0].toolId) : undefined);
const needsSource = $derived(
firstTool ? firstTool.sourceMode !== "none" : false,
);
$effect(() => { $effect(() => {
const next = getTool(data.id); const t = getTool(data.id);
if (next) params = defaultParams(next) as Record<string, ParamValue>; if (t) {
steps = [
{
toolId: t.id,
params: defaultParams(t) as Record<string, ParamValue>,
},
];
sourceImg = null;
sourceUrl = "";
sourceName = "";
}
}); });
$effect(() => { $effect(() => {
if (!tool || !tool.generate) {
previewUrl = "";
return;
}
if (!browser) return; if (!browser) return;
const snapshot = { ...params }; const chain = steps.map((s) => ({
try { tool: getTool(s.toolId),
const result = tool.generate(snapshot); params: { ...s.params },
if (result instanceof Promise) { }));
result
.then((img) => {
previewUrl = toDataUrl(img);
previewError = "";
})
.catch((e) => {
previewError = String(e);
});
} else {
previewUrl = toDataUrl(result);
previewError = "";
}
} catch (e) {
previewError = String(e);
}
});
$effect(() => {
const src = sourceImg; const src = sourceImg;
if (!tool || !tool.run || !src) { if (!chain.length || chain.some((c) => !c.tool)) {
resultImg = null; resultImg = null;
resultUrl = ""; resultUrl = "";
stepOutputs = [];
return; return;
} }
if (!browser) return; const first = chain[0].tool!;
const snapshot = { ...params }; if (first.sourceMode !== "none" && !src) {
const mask = showMask && !!tool.preview; resultImg = null;
try { resultUrl = "";
const base = mask stepOutputs = [];
? tool.preview!(src, snapshot) return;
: tool.run(src, snapshot);
const apply = (img: PixelImage) => {
resultImg = img;
resultUrl = toDataUrl(img);
previewError = "";
};
if (base instanceof Promise) {
base.then(apply).catch((e) => (previewError = String(e)));
} else {
apply(base);
}
} catch (e) {
previewError = String(e);
} }
void runPipeline(chain, src);
}); });
async function runPipeline(
chain: {
tool: ReturnType<typeof getTool>;
params: Record<string, ParamValue>;
}[],
src: PixelImage | null,
) {
processing = true;
try {
let current: PixelImage | null = src;
const outs: { toolId: string; title: string; url: string }[] = [];
for (const c of chain) {
const tool = c.tool!;
const resolve = (x: PixelImage | Promise<PixelImage>) =>
x instanceof Promise ? x : Promise.resolve(x);
let out: PixelImage;
if (!current && tool.sourceMode === "none" && tool.generate) {
out = await resolve(tool.generate(c.params));
} else if (current && tool.run) {
out = await resolve(tool.run(current, c.params));
} else if (!current && src && tool.run) {
out = await resolve(tool.run(src, c.params));
} else {
continue;
}
current = out;
outs.push({ toolId: tool.id, title: tool.title, url: toDataUrl(out) });
}
stepOutputs = outs;
resultImg = current;
resultUrl = current ? toDataUrl(current) : "";
chainError = "";
} catch (e) {
chainError = String(e);
} finally {
processing = false;
}
}
async function onFile(file: File) { async function onFile(file: File) {
if (!browser) return; if (!browser) return;
try { try {
@@ -117,9 +163,9 @@
sourceImg = img; sourceImg = img;
sourceName = file.name; sourceName = file.name;
sourceUrl = toDataUrl(img); sourceUrl = toDataUrl(img);
previewError = ""; chainError = "";
} catch (e) { } catch (e) {
previewError = String(e); chainError = String(e);
} }
} }
@@ -127,126 +173,106 @@
sourceImg = null; sourceImg = null;
sourceUrl = ""; sourceUrl = "";
sourceName = ""; sourceName = "";
resultImg = null;
resultUrl = "";
} }
async function download() { function setStepParam(index: number, id: string, value: ParamValue) {
if (!tool?.generate) return; steps[index].params[id] = value;
const result = tool.generate({ ...params }); }
const img = result instanceof Promise ? await result : result;
const blob = await encode(img, "image/png"); function addStep() {
downloadBlob(blob, `${tool.id}.png`); const tool = getTool(addId);
if (!tool) return;
steps = [
...steps,
{
toolId: tool.id,
params: defaultParams(tool) as Record<string, ParamValue>,
},
];
}
function removeStep(index: number) {
steps = steps.filter((_, i) => i !== index);
} }
async function downloadResult() { async function downloadResult() {
if (!resultImg || !tool) return; if (!resultImg || !firstTool) return;
const blob = await encode(resultImg, "image/png"); const blob = await encode(resultImg, "image/png");
const base = sourceName.replace(/\.[^.]+$/, ""); const base = sourceName ? sourceName.replace(/\.[^.]+$/, "") : firstTool.id;
downloadBlob(blob, `${base}-${tool.id}.png`); downloadBlob(blob, `${base}-pipeline.png`);
} }
function setParam(id: string, value: ParamValue) { const resultMeta = $derived(
params[id] = value; resultImg
}
const isFileTool = $derived(
tool ? tool.sourceMode === "file" || tool.sourceMode === undefined : false,
);
const meta = $derived(
tool
? [ ? [
{ caption: "TOOL", value: tool.id }, { caption: "STEPS", value: String(steps.length) },
{ caption: "CATEGORY", value: tool.category.toUpperCase() }, {
...(tool.sourceMode === "none" && caption: "DIMENSIONS",
"width" in params && value: `${resultImg.width} × ${resultImg.height} px`,
"height" in params },
? [ { caption: "FORMAT", value: "PNG-24" },
{
caption: "OUTPUT",
value: `${params.width} × ${params.height} px`,
},
]
: []),
] ]
: [], : [],
); );
</script> </script>
<svelte:head> <svelte:head>
<title>easy-png-tools / {tool?.title ?? "Tool"}</title> <title>easy-png-tools / {firstTool?.title ?? "Tool"}</title>
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
</svelte:head> </svelte:head>
{#snippet settingsPanel()} {#snippet control(
<Panel> p: ParamDef,
<PanelHeading title={tool!.title} eyebrow={tool!.category}> value: ParamValue,
{#snippet actions()} onInput: (v: ParamValue) => void,
<Badge tone="accent">AUTO</Badge> )}
{/snippet} {#if p.type === "color"}
</PanelHeading> <ColorField
<p class="tool-desc">{tool!.description}</p> label={p.label}
<div class="settings-body"> value={value as string}
{#each tool!.params as p (p.id)} oninput={(v) => onInput(v)}
<div class="setting-row"> />
{#if p.type === "color"} {:else if p.type === "slider"}
<ColorField <SliderField
label={p.label} label={p.label}
value={params[p.id] as string} value={value as number}
oninput={(v) => setParam(p.id, v)} min={p.min}
/> max={p.max}
{:else if p.type === "slider"} step={p.step}
<SliderField oninput={(v) => onInput(v)}
label={p.label} />
value={params[p.id] as number} {:else if p.type === "select"}
min={p.min} <SelectField
max={p.max} label={p.label}
step={p.step} value={value as string}
oninput={(v) => setParam(p.id, v)} options={p.options}
/> onchange={(v) => onInput(v)}
{:else if p.type === "select"} />
<SelectField {:else if p.type === "checkbox"}
label={p.label} <label class="toggle-row"
value={params[p.id] as string} ><span>{p.label}</span>
options={p.options} <Toggle checked={value as boolean} onchange={(v) => onInput(v)} /></label
onchange={(v) => setParam(p.id, v)} >
/> {:else if p.type === "text"}
{:else if p.type === "checkbox"} <TextField
<label class="toggle-row" label={p.label}
><span>{p.label}</span> value={value as string}
<Toggle placeholder={p.placeholder}
checked={params[p.id] as boolean} oninput={(v) => onInput(v)}
onchange={(v) => setParam(p.id, v)} />
/></label {:else if p.type === "number"}
> <NumberField
{:else if p.type === "text"} label={p.label}
<TextField value={value as number}
label={p.label} min={p.min}
value={params[p.id] as string} max={p.max}
placeholder={p.placeholder} step={p.step}
oninput={(v) => setParam(p.id, v)} oninput={(v) => onInput(v)}
/> />
{:else if p.type === "number"} {/if}
<NumberField
label={p.label}
value={params[p.id] as number}
min={p.min}
max={p.max}
step={p.step}
oninput={(v) => setParam(p.id, v)}
/>
{/if}
</div>
{/each}
</div>
<SettingsFooter>
<StatusLine label="changes applied automatically" />
</SettingsFooter>
</Panel>
{/snippet} {/snippet}
{#if !tool} {#if !firstTool}
<div class="tool-grid"> <div class="tool-grid">
<EmptyState <EmptyState
title="Tool not found" title="Tool not found"
@@ -254,45 +280,59 @@
icon={ToolIcon} icon={ToolIcon}
/> />
</div> </div>
{:else if tool.sourceMode === "none"} {:else}
<div class="tool-grid"> <div class="tool-grid">
<section class="settings-panel">{@render settingsPanel()}</section> <section class="pipeline-panel">
<section class="preview-panel">
<Panel> <Panel>
<PanelHeading title="Preview" eyebrow="OUTPUT"> <PanelHeading title="Pipeline" eyebrow="STEPS">
{#snippet actions()} {#snippet actions()}
<DownloadButton label="Download PNG" onclick={download} /> <Badge tone="accent">AUTO</Badge>
{/snippet} {/snippet}
</PanelHeading> </PanelHeading>
<div class="preview-body"> <div class="pipeline-body">
{#if previewError} {#if steps.length === 0}
<EmptyState title="Preview failed" description={previewError} />
{:else if previewUrl}
<CheckerCanvas size="large">
<img class="result-img" src={previewUrl} alt="result" />
</CheckerCanvas>
{:else if tool.generate}
<div class="preview-loading">
<StatusLine label="generating preview" />
</div>
{:else}
<EmptyState <EmptyState
title="No live preview" title="Pipeline empty"
description="This tool runs on an uploaded image." description="Add a tool step below."
icon={ToolIcon}
/> />
{/if} {/if}
<MetaList items={meta} /> {#each steps as step, i (step.toolId + i)}
{@const st = getTool(step.toolId)}
<StepCard
index={i + 1}
title={st?.title ?? step.toolId}
type={st?.category}
onremove={() => removeStep(i)}
>
{#if st}
<div class="step-controls">
{#each st.params as p (p.id)}
{@render control(p, step.params[p.id] as ParamValue, (v) =>
setStepParam(i, p.id, v),
)}
{/each}
</div>
{/if}
</StepCard>
{/each}
</div> </div>
<SettingsFooter>
<div class="add-step">
<SelectField
label="Add step"
value={addId}
options={chainOptions}
onchange={(v) => (addId = v)}
/>
<Button variant="ghost" onclick={addStep}>Add step</Button>
</div>
</SettingsFooter>
</Panel> </Panel>
</section> </section>
</div>
{:else if isFileTool}
<div class="tool-grid remover-grid">
<section class="settings-panel">{@render settingsPanel()}</section>
<section class="preview-panel"> <section class="preview-panel">
<Panel> <Panel>
<PanelHeading title="Source / Result" eyebrow="OUTPUT"> <PanelHeading title="Pipeline output" eyebrow="RESULT">
{#snippet actions()} {#snippet actions()}
{#if resultUrl} {#if resultUrl}
<DownloadButton <DownloadButton
@@ -303,69 +343,34 @@
{/snippet} {/snippet}
</PanelHeading> </PanelHeading>
<div class="preview-body"> <div class="preview-body">
{#if previewError} {#if chainError}
<EmptyState title="Processing failed" description={previewError} /> <EmptyState title="Pipeline failed" description={chainError} />
{/if} {/if}
{#if !sourceImg} {#if needsSource && !sourceImg}
<Dropzone onfile={onFile} /> <Dropzone onfile={onFile} />
{:else} {:else if needsSource && sourceImg}
<div class="comparison-grid"> <Button variant="ghost" onclick={clearSource}>Change image</Button>
<div class="image-card">
<div class="image-label">
<span>SOURCE</span><b>{sourceName}</b>
</div>
<div class="remover-canvas">
<CheckerCanvas size="large">
<img class="cmp-img" src={sourceUrl} alt="source" />
</CheckerCanvas>
<span class="canvas-size"
>{sourceImg.width} × {sourceImg.height}</span
>
</div>
</div>
<div class="image-card">
<div class="image-label">
<span>RESULT</span><b>{sourceName}</b>
</div>
<div class="remover-canvas">
{#if resultUrl}
<CheckerCanvas size="large">
<img class="cmp-img" src={resultUrl} alt="result" />
</CheckerCanvas>
{/if}
<span class="canvas-size"
>{resultImg
? `${resultImg.width} × ${resultImg.height}`
: "—"}</span
>
</div>
</div>
</div>
<div class="result-meta">
<span>FORMAT <b>PNG-24</b></span>
<span>ALPHA <b>ENABLED</b></span>
<span>STATUS <b class="ok">PROCESSED</b></span>
</div>
<Button variant="ghost" onclick={clearSource}
>Change image</Button
>
{/if} {/if}
</div> <div class="preview-stack">
</Panel> {#if needsSource && sourceImg}
</section> <PreviewTile label="SOURCE">
</div> <CheckerCanvas size="sm">
{:else} <img class="tile-img" src={sourceUrl} alt="source" />
<div class="tool-grid"> </CheckerCanvas>
<section class="settings-panel">{@render settingsPanel()}</section> </PreviewTile>
<section class="preview-panel"> {/if}
<Panel> {#each stepOutputs as out, i (out.toolId + i)}
<PanelHeading title="Preview" eyebrow="OUTPUT" /> <PreviewTile label={`STEP ${String(i + 1).padStart(2, "0")}`}>
<div class="preview-body"> <CheckerCanvas size="sm">
<EmptyState <img class="tile-img" src={out.url} alt={out.title} />
title="Text source" </CheckerCanvas>
description="Paste text on the left — arrives in a later step." </PreviewTile>
icon={ToolIcon} {/each}
/> </div>
{#if processing}
<StatusLine label="processing pipeline" />
{/if}
<MetaList items={resultMeta} />
</div> </div>
</Panel> </Panel>
</section> </section>
@@ -375,31 +380,23 @@
<style> <style>
.tool-grid { .tool-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
align-items: start; align-items: start;
gap: 40px; gap: 40px;
max-width: 1680px; max-width: 1680px;
margin: 0 auto; margin: 0 auto;
padding: 48px clamp(24px, 4vw, 72px) 72px; padding: 48px clamp(24px, 4vw, 72px) 72px;
} }
.remover-grid { .pipeline-body {
grid-template-columns: minmax(360px, 0.62fr) minmax(0, 1.38fr);
}
.tool-desc {
margin: 0;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--line);
color: var(--muted);
font-size: 0.85rem;
line-height: 1.55;
}
.settings-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px;
padding: 0.75rem;
} }
.setting-row { .step-controls {
padding: 0.7rem 1rem; display: flex;
border-bottom: 1px solid var(--line); flex-direction: column;
gap: 0.4rem;
} }
.toggle-row { .toggle-row {
display: flex; display: flex;
@@ -410,6 +407,16 @@
font-size: 0.85rem; font-size: 0.85rem;
cursor: pointer; cursor: pointer;
} }
.add-step {
display: flex;
align-items: flex-end;
gap: 0.75rem;
width: 100%;
}
.add-step :global(.select-field),
.add-step :global(select) {
min-width: 0;
}
.preview-panel { .preview-panel {
position: sticky; position: sticky;
top: 84px; top: 84px;
@@ -420,82 +427,19 @@
gap: 1rem; gap: 1rem;
padding: 1rem; padding: 1rem;
} }
.preview-loading { .preview-stack {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--muted);
}
.result-img {
max-width: 100%;
max-height: 360px;
display: block;
border-radius: var(--radius);
}
.comparison-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px; gap: 12px;
} }
.image-card { .tile-img {
min-width: 0;
}
.image-label {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
font: 10px var(--font-mono);
color: var(--muted);
letter-spacing: 0.08em;
}
.image-label span {
color: var(--blue);
}
.image-label b {
font-weight: 400;
}
.remover-canvas {
position: relative;
overflow: hidden;
}
.cmp-img {
max-width: 100%; max-width: 100%;
max-height: 420px; max-height: 220px;
display: block; display: block;
}
.canvas-size {
position: absolute;
bottom: 9px;
right: 10px;
font: 9px var(--font-mono);
color: var(--muted);
background: color-mix(in srgb, var(--panel) 70%, transparent);
padding: 2px 5px;
border-radius: var(--radius); border-radius: var(--radius);
} }
.result-meta {
display: flex;
flex-wrap: wrap;
gap: 18px;
padding-top: 14px;
border-top: 1px solid var(--line);
font: 10px var(--font-mono);
color: var(--muted);
letter-spacing: 0.08em;
}
.result-meta b {
margin-left: 6px;
color: var(--foreground);
font-weight: 600;
}
.result-meta b.ok {
color: var(--success);
}
@media (max-width: 900px) { @media (max-width: 900px) {
.tool-grid, .tool-grid {
.remover-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 24px; gap: 24px;
padding: 32px 16px 48px; padding: 32px 16px 48px;
@@ -503,8 +447,5 @@
.preview-panel { .preview-panel {
position: static; position: static;
} }
.comparison-grid {
grid-template-columns: 1fr;
}
} }
</style> </style>